百万量级数据展示虚拟列表/表格(基于Vue)

1,672 阅读4分钟

需求

最近在写一个管理程序,有展示较多数据的可能,虽然实际数据量并不大,但是因为需要在IE中显示,网上找了几个虚拟列表组件虽然在Chrome中表现良好,但是换成IE11就卡的不行,因此需要对虚拟列表性能优化到极致。

实现方式

简单的说就是DOM里只放需要渲染的列表元素。具体的实现方式有两种:

  1. 基于Canvas自绘。

    相当于完全放弃浏览器原有的绝大部分功能,虽然性能不错,但是需要造一大堆轮子(HTML,CSS,事件绑定...),作为一个懒人表示拒绝。

  2. 基于浏览器渲染引擎。

    这个就简单多了,目前绝大多数选择的都是这种,这里也是。

实现细节

怎么计算位置之类的我就不多说了,直接看代码就好。我说下提升性能最关键的部分:

  1. 设置正确的v-bind:key以复用组件。
  2. itemsObject.freeze()冻结。
  3. onScroll里面调用window.requestAnimationFrame()节流。

接下来再说说与IE有关的操蛋事(不影响流畅度):

  1. 滚动时出现频闪。

    拖动滚动条时的频闪没法解决。但是可以解决滚动鼠标滚轮时新出现的列表项闪烁的问题,办法是采用缓冲。IE11中我个人滑一次滚轮的滚动距离在300px左右,所以在代码中增加了headBufferCounttailBufferCount这两项。

  2. IE中元素的最大高度限制比Chrome小,可能存在数据显示不完全的现象(即单个列表条目的高度越高,能显示的列表数量越少)。

    这个问题存在是因为用了浏览器原生滚动条/滚动行为,理论上可以自己模拟个滚动条来解决。这个问题对要做的项目没什么影响,所以我并没有去管它。

代码

// App.vue
<template>
  <div class="parent">
    <div class="root">
      <div class="head">
        <div class="row head" :style="'height: ' + headHeight + 'px;'">
          <template v-for="col in columns">
            <div :class="'col ' + col[0]" :key="col[0]">{{col[0]}}</div>
          </template>
        </div>
      </div>
      <div class="body" :style="'height: ' + bodyHeight + 'px;'" @scroll.passive="body_onScroll">
        <div class="blank" :style="'height: ' + fullHeight + 'px;'">
          <div class="remain" :style="'top: ' + remainTop + 'px;'">
            <template v-for="(item, k) in remainItems">
              <template v-if="item">
                <div :class="'row item ' + (selectedItems.indexOf(item.id) !== -1? 'selected': '')"
                :style="'height: ' + itemHeight + 'px;'" :key="k" @click="item_onClick($event, item.id)">
                  <template v-for="col in columns">
                    <div :class="'col ' + col[0]" :key="col[0]">{{col[1](item, col[0])}}</div>
                  </template>
                </div>
              </template>
              <template v-else>
                <div class="row item" :style="'height: ' + itemHeight + 'px;'" :key="k"></div>
              </template>
            </template>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      scrollTop: 0,
      items: Object.freeze(Array(1000000).fill().map((e, i) => (e, {id: i, A: 'A' + i, B: 'B' + i, C: 'C' + i, D: 'D' + i, E: 'E' + i, }))),
      columns: Object.freeze([['A', (item, col) => item[col]],
                              ['B', (item, col) => item[col]],
                              ['C', (item, col) => item[col]],
                              ['D', (item, col) => item[col]],
                              ['E', (item, col) => item[col]]]),
      selectedItems: [],
    }
  },
  computed: {
    fullHeight() {
      return this.items.length * this.itemHeight;
    },
    remainTop() {
      return (Math.ceil(this.scrollTop / this.itemHeight) - 1 - this.headBufferCount) * this.itemHeight;
    },
    remainCount() {
      return parseInt((Math.min(this.items.length, Math.floor(this.bodyHeight / this.itemHeight)) + 1) + this.headBufferCount + this.tailBufferCount);
    },
    remainItems() {
      let a = Array(this.remainCount).fill();
      for(let i=0; i< a.length; i++) {
        let itemIndex = Math.floor(this.scrollTop / this.itemHeight)+ i - this.headBufferCount;
        if (!(this.scrollTop % this.itemHeight) ) {
          itemIndex = Math.floor(this.scrollTop / this.itemHeight) - 1 + i - this.headBufferCount;
        }
        if (itemIndex >= 0 && itemIndex < this.items.length) {
          a[i] = this.items[itemIndex];
        }
      }
      return a;
    },
    itemHeight() {
      return 20;
    },
    headHeight() {
      return 30;
    },
    bodyHeight() {
      return 270;
    },
    headBufferCount() {
      return Math.round(300 / this.itemHeight);
    },
    tailBufferCount() {
      return Math.round(300 / this.itemHeight);
    }
  },
  methods: {
    body_onScroll(event) {
      let target = event.currentTarget;
      if (!window["3fcff984-32a1-4d20-8c89-3e0c6d720cda"]) {
        window.requestAnimationFrame(() => {
          this.scrollTop = target.scrollTop;
          window["3fcff984-32a1-4d20-8c89-3e0c6d720cda"] = false;
        });
        window["3fcff984-32a1-4d20-8c89-3e0c6d720cda"] = true;
      }
    },
    item_onClick(event, itemId) {
      event;
      let index = this.selectedItems.indexOf(itemId);
      if (-1 === index) {
        this.selectedItems.push(itemId);
      } else {
        this.selectedItems.splice(index, 1);
      }
    }
  }
}
</script>

<style scoped>
.parent {
  overflow: hidden;
  width: 500px;
  height: 300px;
  margin: 100px auto;
}
.root {
  margin: 0;
  padding: 0;
}
.body {
  overflow: auto;
}
.blank {
  overflow: hidden;
}
.remain {
  position: relative;
  overflow: hidden;
}
.row {
  display: flex;
  flex-direction: row;
  overflow: hidden;
  width: 500px;
}
.col {
  flex-basis: 100px;
}
.selected {
  background: red;
}
</style>

说明:保存成App.vue,执行vue serve即可。

提示:把1000000改成小一点的数(比如10000)再用IE打开。

效果

Chrome下,一百万数据量:

IE11下,十万数据量(只显示出七万多,见上文说明):

最后

其实更大数据量也是可以的,前提是电脑内存够( ̄▽ ̄)"。

第一次发文,欢迎大佬们提出宝贵意见。