Skip to content

长列表优化 vue 版

长列表在各种数据列场景下经常使用,一旦数据量非常大,就会出现卡段,先看下长列表一步一步进化

一次性加载

最开始使用长列表时,会这样写

js
<div id="container"></div>
<script>
  let total = 100000
  let timer = Date.now()
  // 新版浏览器优化,当 js 执行完成后一并插入到页面
  for (let i = 0; i < total; i++) {
    let li = document.createElement('li')
    li.innerHTML = i;
    container.appendChild(li)
  }
  console.log(Date.now() - timer) // js 执行的时间 == 执行很快
  setTimeout(() => {
    console.log(Date.now() - timer) // 渲染完成用的时间 == 时间太长了
  })
</script>

新版浏览器对数据 dom 插入做了优化,当 js 执行完成后一并插入到页面,这样会导致页面空白页很久

使用分片加载

我们知道 js 是单线程的,异步事件是基于 EventLoop 机制,执行顺序为

  • 执行代码时,遇到宏任务(setTimeout,setInterval,Ajax,DOM 事件)或微任务(promise、async/await),都推入到相应的队列
  • 当同步代码执行完,开始清空微任务队列
  • 微任务执行完后,就会尝试进行 dom 渲染
  • 以上完成,这时 EventLoop 开始工作,从宏任务队列取出一个宏任务(可能包含同步代码,promise 等)执行
  • 然后继续循环下一次

从以上知道,可以 dom 渲染是在宏任务之前执行,那么可以通过改进下

js
<div id="container"></div>
<script>
  let total = 100000
  let timer = Date.now()
  // 新版浏览器优化,当 js 执行完成后一并插入到页面
  let index = 0 // 偏移量
  let id = 0 // 递增的内容
  function load() {
    index += 50
    if (index < total) {
      // setTimeout 和 requestAnimationFrame 都是宏任务
      // requestAnimationFrame 可以配合浏览器的刷新频率,效果可能好点
      setTimeout(() => { // 分片渲染,因为定时器是一个宏任务,会等待 ui 渲染完成后执行
        let fragment = document.createDocumentFragment() // ie 浏览器 需要使用文档碎片
        // 先渲染 50 个,等待渲染完成后,再渲染 50 个
        for (let i = 0; i < 50; i++) {

          let li = document.createElement('li')
          li.innerHTML = id++
          fragment.appendChild(li)
        }
        container.appendChild(fragment)
        load()
      }, 0)
    }
  }

  load()
</script>

使用 setTimeout 宏任务先渲染 50 个 dom,这样就能进行分片加载,页面快速显示内容,但依然存在页面卡顿的问题

使用虚拟列表

页面卡顿的根本原因,还是 dom 太多了,使用虚拟列表,只渲染当前的可视化区域,这里采用 vue 来实现

参考实现:https://github.com/tangbc/vue-virtual-scroll-list/tree/v1.4.7

文件 App.vue

  • 包含两个组件 VirtualListItem
  • 通过 mockjs 生产 mock 数据
html
<template>
  <div id="app">
    <!-- 只显示可视区域-->
    <!-- 1、列表每一项多高,算出一个滚动条来-->
    <!-- 2、variable 这个高度不一定多高了 -->
    <VirtualList :size="100" :remain="8" :items="items" :variable="true">
      <Item slot-scope="{item}" :item="item"></Item>
    </VirtualList>
  </div>
</template>

<script>
  import VirtualList from './components/virtual-list';
  import Item from './components/item';
  import mock from 'mockjs';

  let items = [];
  for (let i = 0; i < 10000; i++) {
    items.push({ id: i, value: mock.Random.sentence(5, 50) });
  }

  export default {
    name: 'App',
    components: {
      VirtualList,
      Item,
    },
    data() {
      return { items };
    },
  };
</script>

<style>
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }
</style>

组件 components/Item.vue

  • 接收 item 对象,显示值
html
<template>
  <div style="border: 1px solid red; padding: 20px 0">{{ item.value }}</div>
</template>

<script>
  export default {
    name: 'item',
    props: {
      item: Object,
    },
  };
</script>

最核心的 components/virtual-list.vue

  • 接收四个参数

    • size: 每一项的高度
    • remain: 可视区域显示多少个
    • items: 数据源
    • variable: 每项高度是否固定
  • html 结构:可视区域 ref="viewport" , 滚动条 ref="viewport" 和数据列表 class="scroll-list"

  • 加载完成后,调用 cacheList 方法先缓存每个 dom 的高度,顶部,底部位置;每次渲染后,更新缓存为实际的高度,顶部,底部位置

  • variabletrue 时,利用二分查找算法找到滚动条的位置

  • 可视区域显示的实际 dom 数量应大于 remain 个数,防止出现空白

html
<template>
  <!--  能滚动的盒子-->
  <div class="viewport" ref="viewport" @scroll="scrollFn">
    <!--    自己做一个滚动条-->
    <div class="scroll-bar" ref="scrollBar"></div>
    <!--    渲染的内容-->
    <div
      class="scroll-list"
      :style="{transform:`translate3d(0,${offset}px,0)`}"
    >
      <!--    <div class="scroll-list" :style="{top:`${offset}px`}">-->
      <div
        v-for="item in visibleData"
        :vid="item.id"
        :key="item.id"
        ref="items"
      >
        <slot :item="item"></slot>
      </div>
    </div>
  </div>
</template>

<script>
  import throttle from 'lodash/throttle';

  export default {
    name: 'virtual-list',
    props: {
      size: Number, // 每一项的高度
      remain: Number, // 可见多少个
      items: Array,
      variable: Boolean, //
    },
    data() {
      return {
        start: 0,
        end: this.remain, // 默认显示 8 个
        offset: 0,
      };
    },
    created() {
      this.scrollFn = throttle(this.handleScroll, 200, { leading: false });
    },
    computed: {
      // 渲染三个屏幕
      prevCount() {
        // 前面预留几个
        return Math.min(this.start, this.remain);
      },
      nextCount() {
        // 后面预留几个
        return Math.min(this.remain, this.items.length - this.end);
      },
      // 可见数据有哪些
      visibleData() {
        // 根据 start 和 end 截取
        let start = this.start - this.prevCount;
        let end = this.end + this.nextCount;
        return this.items.slice(start, end);
      },
    },
    mounted() {
      // 可视区域高度
      this.$refs.viewport.style.height = this.size * this.remain + 'px';
      // 实际滚动条高度
      this.$refs.scrollBar.style.height = this.items.length * this.size + 'px';

      // 如果加载完毕,需要缓存每一项的高度
      // 1、先记录好,等一会滚动的时候,去渲染页面是获取真实 dom 的高度,来更新缓存内容
      // 2、再重新计算滚动条的高度
      this.cacheList();
    },
    updated() {
      // 页面渲染完成后,需要根据当前展示的数据,更新缓存区的内容
      this.$nextTick(() => {
        // 根据当前显示的,更新缓存中的 height bottom top,最终更新滚动条的高度
        let nodes = this.$refs.items; // 获取真实的节点
        if (!(nodes && nodes.length > 0)) {
          return;
        }
        nodes.forEach((node) => {
          let { height } = node.getBoundingClientRect(); // 真实的高度
          // 更新缓存中老的高度
          let id = node.getAttribute('vid') - 0;
          let oldHeight = this.positions[id].height;
          let val = oldHeight - height; // 计算当前的高度和之前的高度是否变化
          if (val) {
            // 没变化的话,就不用任何操作了
            this.positions[id].height = height;
            this.positions[id].bottom = this.positions[id].bottom - val; // 底部增加了
            // 链表 将后续的所有人 都要向后移动
            for (let i = id + 1; i < this.positions.length; i++) {
              this.positions[i].top = this.positions[i - 1].bottom;
              this.positions[i].bottom = this.positions[i].bottom - val;
            }
          }
        });
        // 只要更新过,会计算出滚动条的最新高度
        this.$refs.scrollBar.style.height =
          this.positions[this.positions.length - 1].bottom + 'px';
        // 就是动态计算滚动条的高度
      });
    },
    methods: {
      getStartIndex(value) {
        // 查找当前滚动的需要找到的值
        let start = 0; // 开始
        let end = this.positions.length - 1; // 结束位置
        let temp = null;
        while (start <= end) {
          let middleIndex = parseInt((start + end) / 2);
          let middleValue = this.positions[middleIndex].bottom; // 找到当前的中间的那个人的结尾点
          if (middleValue == value) {
            // 如果直接找到了,就返回当前的下一个人
            return middleIndex + 1;
          } else if (middleValue < value) {
            // 当前要查找的人,在右边
            start = middleIndex + 1;
          } else if (middleValue > value) {
            // 当前要查找的人,在左边
            if (temp == null || temp > middleIndex) {
              temp = middleIndex; // 找到范围
            }
            end = middleIndex - 1;
          }
        }
        return temp;
      },
      cacheList() {
        // 缓存当前项的高度和 top 值,还有 bottom
        this.positions = this.items.map((item, index) => ({
          height: this.size,
          top: index * this.size,
          bottom: (index + 1) * this.size,
        }));
      },
      handleScroll() {
        // 1、先算出当前滚动过去几个了,应该从第几个开始显示
        let scrollTop = this.$refs.viewport.scrollTop;

        if (this.variable) {
          // 如果有 variable 使用二分查找找到对应的记录
          // 二分查找: 在一个有序的数据列表,先分成两半,从中间开始,判断在哪个一半,然后继续分成两半查找
          this.start = this.getStartIndex(scrollTop);
          this.end = this.start + this.remain;
          // 设置偏移量
          this.offset = this.positions[this.start - this.prevCount]
            ? this.positions[this.start - this.prevCount].top
            : 0;
        } else {
          // 2、获取当前应该从第几个开始渲染
          this.start = Math.floor(scrollTop / this.size); // 已滚动的高度/每一项的高度 = 已滚动的个数, 需要取整
          // 3、计算当前结尾的位置
          this.end = this.start + this.remain; // 当前可渲染的区域
          // 定义当前可视区域,让当前渲染的内容显示在当前 viewport 的可视区域里
          // 如果有预留渲染,应该把这个位置再向上移动当前这么多 this.size * this.prevCount
          this.offset = this.start * this.size - this.size * this.prevCount; // 让可视区域调整偏移位置
        }
      },
    },
  };
</script>

<style>
  .viewport {
    overflow-y: scroll;
    position: relative;
  }

  .scroll-list {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
  }
</style>

基于 MIT 许可发布