Virtual scroller

by Christopher O

HTML

<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/lodash/4.16.6/lodash.min.js"></script>
<script src="https://cdn.rawgit.com/Marak/faker.js/master/examples/browser/js/faker.js"></script>
<!-- https://github.com/marak/Faker.js/ -->


<div id="app">
  <virtual-scroller :items="items" :renderers="renderers" item-height="22"></virtual-scroller>
</div>

CSS

body {
  font-family: sans-serif;
}

.virtual-scroller {
  overflow: auto;
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  right: 0;
  height: 90%;
  width: 250px;
  border: 1px solid blue;
}

.item-container {
  box-sizing: border-box;
}

.item {
  height: 40px; /* 22 */
  padding: 12px;
  box-sizing: border-box;
  cursor: pointer;
  user-select: none;
  -moz-user-select: none;
  -webkit-user-select: none;
}

.letter {
  text-transform: uppercase;
  color: grey;
}

.person:nth-of-type(odd) {
    background: #e0e0e0;
}

JavaScript

console.clear();

Vue.component('virtual-scroller', {
	props: ['items', 'renderers', 'itemHeight'],
  template: `<div class="virtual-scroller" @scroll="updateVisibleItems">
  	<div class="item-container" ref="container">
      <div class="items">
        <component class="item" v-for="item in visibleItems" :is="renderers[item.type]" :item="item"></component>
      </div>
    </div>
  </div>`,
  data: () => ({
  	visibleItems: [],
    containerStyle: null,
  }),
  watch: {
  	items(val) {
    	this.l = this.items.length
    	this.updateVisibleItems()
    }
  },
  methods: {
  	updateVisibleItems() {
      const el = this.$el;
      const scrollTop = el.scrollTop;
      const scrollBottom = scrollTop + el.clientHeight;
      let startIndex = Math.floor(scrollTop/this.itemHeight) - 3
      if(startIndex < 0) {
      	startIndex = 0
      }
      let endIndex = Math.ceil(scrollBottom/this.itemHeight) + 3
      if(endIndex >= this.l) {
      	endIndex = this.l
      }
      const container = this.$refs.container
      container.style.paddingTop = startIndex * this.itemHeight + 'px'
      container.style.height = this.l * this.itemHeight + 'px'
      this.visibleItems = this.items.slice(startIndex, endIndex)
      this.$forceUpdate()
    },
  },
  created() {
  	this.l = this.items.length
  },
  mounted() {
  	this.updateVisibleItems()
  },
})

const renderers = {
	letter: {
  	props: ['item'],
  	template: `<div class="letter">({{item.index}}) {{item.value}}</div>`,
  },
  person: {
  	props: ['item'],
  	template: `<div class="person" @click="edit">({{item.index}}) {{item.value.name}}</div>`,
    methods: {
    	edit() {
      	this.item.value.name += '*'
      },
    },
  },
}

const count = 100000
console.log('Generating ' + count + ' items...')
let time = new Date().getTime(), time2
const items = Object.freeze(getData(count))
console.log('Generated ' + items.length + ' in ' + ((time2 = new Date().getTime()) - time))

new Vue({
	data: () => ({
  	items,
   ...