虚拟长列表

709 阅读6分钟

背景

实际开发过程中,我们会遇到长列表的渲染需求,用户想要将所有数据加载过来,用户觉得方便也好快也好,总之翻页、添加加载更多按钮等替代方案,用户都不想要。而那么多数据,不说一下子请求过来耗费时间,浏览器一下子渲染那么多元素,页面也会造成很严重的卡顿,特别是在低配电脑表现更加明显,如果你的用户还只用IE,那岂不是需要很好的耐心。为了优化页面性能,我们必须得用虚拟长列表来优化性能了,如果你也有同样的困扰,可以详看本文浅见,若急需解决针对 vue 已有现成的插件供你使用。若你除了会用还想一探究竟,可以继续看下去。希望你有所收获。

本质

虚拟长列表本质其实是:只展示屏幕可见范围内的列表即可,对屏外内容不渲染。DOM 渲染元素减少,性能就优化了。 我们会有这样疑问:渲染数据减少,滚动条长度也会发生变化,用户如何拖拽滚动条查看下方数据? 答:我们通过计算整体数据滚动条长度,附加给我们一个元素的高度,所以我们需要单独写一个真实数据的滚动条。

列表每一项高度一样

要素

虚拟列表所需要参数:一个列表高度 size 、屏幕内需要展示多少个 remain、列表数据 list

效果

虚拟长列表.mp4 (8.94MB)

用法

// 使用
// ....
<zr-virtual-list :size="50" :remain="10" :list="virtualList">
 	<div class="virtual-list" slot-scope="{ item }" :item="{item}" >{{item.item}}</div>
 </zr-virtual-list>
// ....

代码

<template>
  <div class="zr-virtual-viewport" ref="viewport" :style="viewPortHeight" @scroll="handlerScroll">
    <div class="zr-virtual-scroll" :style="scrollHeight"></div> 
    <div class="zr-virtual-content" ref="content">
      <div v-for="(item, index) in virtualList" :key="index">
        <slot :item="{item}"></slot>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: 'zr-virtual-list',
  props: {
    size: {
      type: Number,
      default: 0,
    },
    remain: {
      type: Number,
      default: 0,
    },
    list: {
      type: Array,
      default: () => [],
    },
  },
  computed: {
    viewPortHeight() {
      return `height: ${this.remain * this.size}px`;
    },
    // 生成滚动条
    scrollHeight() {
      return `height: ${this.list.length * this.size}px`;
    },
    // 真实渲染到页面的列表
    virtualList() {
      return this.list.slice(this.start, this.end);
    },
  },
  data() {
    return {
      start: 0,
      end: this.remain,
    };
  },
  methods: {
    // 滚动条监听
    handlerScroll() {
      const top = this.$refs.viewport.scrollTop;
      this.start = Math.floor(top / this.size); // 计算滚动条滑动了多少个列表
      this.end = this.start + this.remain; // 最后列表元素为第几个
       //内容发生改变,content 距离头部距离也需要改变
      this.$refs.content.style.top = `${this.start * this.size}px`;
    },
  },
};
</script>
<style lang="scss">
.zr-virtual-viewport {
  position: relative;
  overflow-y: scroll;
  border: 1px solid #efefef;
  .zr-virtual-content {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
  }
}
</style>

优化

看上面效果图,滑快的化,白屏会出现,为了优化白屏,我们需要预渲染,即除了可视平,头和尾都需要多一部分 remain 数据。 优化代码在 33、38、48、65行

<template>
  <div class="zr-virtual-viewport" ref="viewport" :style="viewPortHeight" @scroll="handlerScroll">
    <div class="zr-virtual-scroll" :style="scrollHeight">
    </div>
    <div class="zr-virtual-content" ref="content">
      <div v-for="(item, index) in virtualList" :key="index">
        <slot :item="{item}"></slot>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: 'zr-virtual-list',
  props: {
    size: {
      type: Number,
      default: 0,
    },
    remain: {
      type: Number,
      default: 0,
    },
    list: {
      type: Array,
      default: () => [],
    },
  },
  computed: {
    // 向前多一屏 即 remain
    // 若直接 prev = remain
    // 	  需要考虑 start < remain 的情况,此时 this.list.slice() 会从负数开始,
    prev() {
      // start > remain ; prev = remain
      // start < remain ; prev = start
      return Math.min(this.start, this.remain); // 0 10 -> 故取最小
    },
    next() {
      // 当 滑动到最后一个元素的时候,已经没有数据了,所以 next 需要处理;
      return Math.min(this.remain, this.list.length - this.end); // 10, 90000
    },
    viewPortHeight() {
      return `height: ${this.remain * this.size}px`;
    },
    scrollHeight() {
      return `height: ${this.list.length * this.size}px`;
    },
    virtualList() {
      const start = this.start - this.prev; // 真实开始 start - 向前预渲染个数
      const end = this.end + this.next; // 真实结束是 end + 向后预渲染个数
      return this.list.slice(start, end);
    },
  },
  data() {
    return {
      start: 0,
      end: this.remain,
    };
  },
  methods: {
    handlerScroll() {
      const top = this.$refs.viewport.scrollTop;
      this.start = Math.floor(top / this.size);
      this.end = this.start + this.remain;
      // 真实元素的 start 是 this.start - this.prev
			this.$refs.content.style.top = `${(this.start - this.prev) * this.size}px`;
    },
  },
};
</script>
<style lang="scss">
$-border-color: #efefef;

.zr-virtual-viewport {
  position: relative;
  overflow-y: scroll;
  border: 1px solid #efefef;
  .zr-virtual-content {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
  }
}
</style>

列表每一项高度不固定

实际上,我们遇到的是元素高度往往是不固定的,我们无法确切知道 size 高度,所以为了解决这种情况,我们需要进一步优化

要素

虚拟列表所需要参数:一个列表高度 size 、屏幕内需要展示多少个 remain、列表数据 list, 还需要 variable 表示需要高度不固定;

效果

高度不固定.mp4 (6.45MB)
### 代码 其主要思想: 通过默认预期给的列表高度,我们需要一个缓存列表,计算出每个元素 top bottom height 具体位置。 82行 已知当前滚动条距离顶部距离,在通过二分查找法找到 start 值(滚动条对应的列表的下标),从而计算出整个容器高度,并渲染出来。90行 页面渲染,即可视数据发生改变时,执行 updated 钩子函数 59行 此时找到可视 DOM 元素,计算出每个可视列表的真实高度,找到每个元素对应的缓存列表进行替换,方便下次二分查找的使用。在计算出滚动条高度,防止错位。

<template>
  <div class="zr-virtual-viewport" ref="viewport" :style="viewPortHeight" @scroll="handlerScroll">
    <div class="zr-virtual-scroll" ref="scroll" :style="scrollHeight">
    </div>
    <div class="zr-virtual-content" ref="content">
      <div v-for="(item, index) in virtualList"  ref="list" :key="index" :id="item.id">
        <slot :item="{item}"></slot>
      </div>
    </div>
  </div>
</template>
<script>
export default {
  name: 'zr-virtual-list',
  props: {
    size: {
      type: Number,
      default: 0,
    },
    remain: {
      type: Number,
      default: 0,
    },
    list: {
      type: Array,
      default: () => [],
    },
    variable: {
      type: Boolean,
      default: false,
    },
  },
  computed: {
    prev() {
      return Math.min(this.start, this.remain); // 0 10 -> 0
    },
    next() {
      return Math.min(this.remain, this.list.length - this.end); // 10, 90000
    },
    viewPortHeight() {
      return `height: ${this.remain * this.size}px`;
    },
    scrollHeight() {
      return `height: ${this.list.length * this.size}px`;
    },
    virtualList() {
      const start = this.start - this.prev;
      const end = this.end + this.next;
      return this.list.slice(start, end);
    },
  },
  data() {
    return {
      start: 0,
      end: this.remain,
    };
  },
  updated() {
    this.$nextTick(() => {
      const nodes = this.$refs.list;
      if (!(nodes && nodes.length > 0)) {
        return;
      }
      nodes.forEach((item) => {
        const { height } = item.getBoundingClientRect();
        const id = item.getAttribute('id') - 0;
        const oldHeight = this.position[id].height; // 从缓存列表 position 读取,向上滚动,val 差值 为 0 
        const val = oldHeight - height;
        if (val) {
          this.position[id].height = height;
          this.position[id].bottom = this.position[id].bottom - val;
          for (let i = id + 1; i < this.list.length; i++) { // id 后的每一项 top bottom 都会发生改变
            this.position[i].top = this.position[i - 1].bottom; // 下一项头 = 上一项的尾
            this.position[i].bottom = this.position[i].bottom - val; // 下一项的尾 = 下一项尾 - 差值
          }
        }
      });
      this.$refs.scroll.style.height = `${this.position[this.position.length - 1].bottom}px`; // 重新计算滚动条高度
    });
  },
  methods: {
    cacheList() {
      this.position = this.list.map((item, index) => ({
        height: this.size,
        top: index * this.size,
        bottom: (index + 1) * this.size,
      }));
    },
    // 二分查找 有序列表查找
    getStartIndex(top) {
      let start = 0;
      let end = this.list.length - 1;
      let temp = null;
      while (start <= end) {
        const middleIndex = parseInt((end + start) / 2);
        const middleValue = this.position[middleIndex].bottom;
        if (middleValue === top) {
          return middleIndex + 1;
        } else if (middleValue < top) { // 右边
          start = middleIndex + 1;
        } else if (middleValue > top) { // 左边
          if (temp === null || temp > middleIndex) {
            temp = middleIndex;
          }
          end = middleIndex - 1;
        }
      }
      return temp;
    },
    handlerScroll() {
      const top = this.$refs.viewport.scrollTop;
      if (this.variable) {
        this.start = this.getStartIndex(top);
        this.end = this.start + this.remain;
        this.$refs.content.style.top = this.position[this.start - this.prev] ? `${this.position[this.start - this.prev].top}px` : 0;
      } else {
        // 固定高度列表
        this.start = Math.floor(top / this.size);
        this.end = this.start + this.remain;
        this.$refs.content.style.top = `${(this.start - this.prev) * this.size}px`;
      }
    },
  },
  mounted() {
    this.cacheList();
  },
};
</script>
<style lang="scss">
$-border-color: #efefef;

.zr-virtual-viewport {
  position: relative;
  overflow-y: scroll;
  border: 1px solid #efefef;
  .zr-virtual-content {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
  }
}
</style>

二分查找

虚拟列表所使用的算法为二分查找,来找到当前滚动条对应的第几个元素。抛开上面代码,单独写一个二分查找算法。

实现

function binarySearch(arr, value){
  let start = 0;
  let end = arr.length - 1;
  while(start <= end) {
    let mIndex = parseInt(start + (end - start) / 2); // 大数溢出
    let mValue = arr[mIndex];
    if (mValue === value) {
      return mIndex
    } else if (mValue < value) {
      start = mIndex + 1;
    } else if (mValue > value) {
      end = mIndex - 1;
    } else {
      return -1;
    }
  }
}
// 需要注意二分查找,列表需要是有序的。
const arr = [1,20,22,34,50,60,78,100,120,459]
const index = binarySearch(arr, 34)
console.log(index) // 3

时间复杂度

怎么求二分查找的时间复杂度?

提前高中数学知识:指数与对数之间的转化公式 a^b=N 等价于 loga^N=b

已知数组长度为 n, 第一次为 n/2, 第二次为 n/4,第三次是 n/8, ....可以推算出 n 与 次数 k 之间的关系。

当 n/2k = 1 为 2k=n(1 表示找到找到)的时候,指数对数转化等价于 k = log2^n

故 时间复杂度 O为 log2^n 是效率比较高的查找算法,但是需要列表是 有序的。

function binarySearch(arr, value){
  let start = 0;
  let end = arr.length - 1;
  while(start <= end) {
    let mIndex = parseInt(start + (end - start) / 2);
    let mValue = arr[mIndex];
    if (mValue < value) {
      start = mIndex + 1;
    } else if (mValue > value) {
      end = mIndex - 1;
    } else {
      // 若m是最后一个元素,则直接返回当前找到的mindex
      // 若不是最后一个元素,但mindex下一个元素不跟 value相等,则也直接放回
      if (mIndex == arr.length - 1 || (arr[mIndex+1]!= value)) return mIndex
      // m 不是最后一个元素,且 mindex 下一个元素跟 value 相等,则start 往后移动,重新比对
      else start = mIndex + 1
    }
  }
  return -1;
}

const arr = [1,20,22,34,50,60,78,100,120,459, 459, 1000,1000,100,1000]
const index = binarySearch(arr, 1000)
console.log(index) // 14 下标

虚拟列表二叉树

回归到主题,虚拟列表,通过传入当前滚动条距离顶部距离(极大可能性不为数组中某一项)我们找的是一个范围

function binarySearch(arr, value){
  let start = 0;
  let end = arr.length - 1;
  let temp = null;
  while(start <= end) {
    let mIndex = parseInt(start + (end - start) / 2);
    let mValue = arr[mIndex];
    if (mValue < value) {
      start = mIndex + 1;
    } else if (mValue > value) {
      if (temp === null || temp > mIndex){
        temp = mIndex
      }
      end = mIndex - 1;
    } else if (mValue === value) {
       return mIndex + 1;
    }
  }
  return temp;
}
// 数组中存放列表每一项的底 bottom 距离容器头的距离。
// 此时滚动条高度在 55,数据还在可视范围的是 60元素,即下标值为 6,
// 可以分析找的永远是 会小于当前元素, 故会讲逻辑写到 上面 11 行中
const arr = [1,1, 20,22,34,50,60,78,100,120,459, 459, 1000,1000,1000]
const index = binarySearch(arr, 55)
console.log(index) // 6 第六个元素60 所在下标即会滚动条需要渲染的开始点

节流

监听滚动条,会频繁的进行计算,此时不用节流何时用  深入浅出节流函数

function throttle (fn, wait, opts) {
  let args, context;
  let previous = 0;
  let timer = null;
  const laster = () => {
    previous = opts.leading === false ? 0 : Date.now(); // leading = false, 则重置 previous 为0,再次开始执行,会走定时器触发执行
    fn.apply(context, args);
  }
  const throttleFn = () => {
      args = arguments;
      context = this;
      const now = Date.now();
      if(!previous && opts.leading === false) previous = now; // leading 为 false 且 previous = 0 表示第一次点击,则走 定时器执行方法
      const remain = wait - (now - previous)
      if (remain <= 0 ) {
        if (timer) {
          clearTimeout(timer);
          timer = null;
        }
        previous = now;
        fn.apply(context, arguments)
      } else if(opts.trailing !== false && !timer){
        timer = setTimeout(laster, remain)
      }
  }
  return throttleFn;
}
  const btn = document.getElementById('button')
  btn.addEventListener('click', throttle(btnClick, 1000, { leading: false })); 
// leading false 第一次不执行, true 第一次执行
// trailing false 最后一次不执行,true最后一次执行

总结

虚拟长列表,性能优化点实际是不全部渲染所有数据,根据用户滚动条高度,在通过二分查找法,找到列表实际渲染下标,进而动态更新容器高度,滚动条高度。