前端一次性渲染十万条数据的处理思路 & 虚拟列表

1,022 阅读3分钟

一、前置知识

js是单线程的,为了确保主线程不被长时间阻塞,js中有同步和异步的概念

事件循环机制(event loop)

  1. 先执行主线程中同步任务
  2. 遇到异步任务,将异步任务挂到任务队列中
  3. 主线程执行完毕,开始执行异步任务,异步任务分为宏任务和微任务,先执行微任务,再执行宏任务
  4. 微任务:Promise.then() async/await 宏任务:setTimeout ajax 读取文件

二、第一种方案:时间分片

v8引擎执行js代码速度很快,但是渲染页面的时间比较漫长,如果将十万条数据直接给到渲染引擎,页面会出现白屏或卡顿,时间分片就是将一个大的任务分为一个个小的任务,可以使用定时器一次加载20条,这样一直加载结束

setTimeout实现:

    init() {
      const ul = document.getElementById('container')
      const total = 998 // 总数据条数
      const pageSize = 20 // 每次渲染条数
      const index = 0 // 每条记录的索引,防止数据丢失或没有渲染到最后一条
      // 两个参数:剩余需要渲染的数据条数,当前渲染的索引
      function loop(curTotal, curIndex) {
        const pageCount = Math.min(pageSize, curTotal)
        setTimeout(() => {
          for (let i = 0; i < pageCount; i++) {
            const li = document.createElement('li')
            li.innerText = `第${curIndex + i}条数据`
            ul.appendChild(li)
          }
          loop(curTotal - pageCount, curIndex + pageCount)
        })
      }
      loop(total, index)
    }

如果浏览器的性能不够高,完成一次时间循环的时间大于16.7ms,那么可能在16.7ms内完成了当前渲染的20条数据,但是下一次渲染的20条数据还没有给出来,就会造成页面的白屏或卡顿,所以使用requestAnimationFrame替代setTimeout来优化

requestAnimationFrame实现:

    init() {
      const ul = document.getElementById('container')
      const total = 998 // 总数据条数
      const pageSize = 20 // 每次渲染条数
      const index = 0 // 每条记录的索引,防止数据丢失或没有渲染到最后一条
      // 两个参数:剩余需要渲染的数据条数,当前渲染的索引
      function loop(curTotal, curIndex) {
        const pageCount = Math.min(pageSize, curTotal)
        requestAnimationFrame(() => {
          const fragment = document.createDocumentFragment()
          for (let i = 0; i < pageCount; i++) {
            const li = document.createElement('li')
            li.innerText = `第${curIndex + i}条数据`
            fragment.appendChild(li)
          }
          ul.appendChild(fragment)
          loop(curTotal - pageCount, curIndex + pageCount)
        })
      }
      loop(total, index)
    }

三、第二种方案:虚拟列表

动画.gif

app.vue

    <VirtualList />

VirtualList.vue

<template>
  <div ref="listRef" class="infinite-list-container" @scroll="handleScroll">
    <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <ul class="infinite-list" :style="{ transform: getTransform }">
      <li
        v-for="item in visibleData"
        :key="item.id"
        :style="{ height: itemHeight + 'px', lineHeight: itemHeight + 'px' }"
      >
        {{ item.value }}
      </li>
    </ul>
  </div>
</template>

<script>
export default {
  name: 'VirtualList',
  data() {
    return {
      listData: [],
      itemHeight: 50,
      state: {
        screenHeight: 0, // 可视区域的高度
        startOffset: 0, // 偏移量
        start: 0, // 起始数据下标
        end: 0 // 结束数据下标
      }
    }
  },
  computed: {
    // 可视区域显示的数据条数
    visibleCount() {
      return this.state.screenHeight / this.itemHeight
    },
    // 可视区域显示的真实数据
    visibleData() {
      const { listData, state } = this
      return listData.slice(state.start, Math.min(listData.length, state.end))
    },
    // 当前列表总高度
    listHeight() {
      return this.listData.length * this.itemHeight
    },
    // list跟着父容器移动了,现在列表要移回来
    getTransform() {
      return `translateY(${this.state.startOffset}px)`
    }
  },
  mounted() {
    for (let i = 0; i < 100000; i++) {
      this.listData.push({ id: i, value: i })
    }
    this.state.screenHeight = this.$refs.listRef.clientHeight
    this.state.end = this.state.start + this.visibleCount
  },
  methods: {
    handleScroll() {
      const { itemHeight, visibleCount } = this
      const { scrollTop } = this.$refs.listRef
      this.state.start = Math.floor(scrollTop / itemHeight)
      this.state.end = this.state.start + visibleCount
      this.state.startOffset = scrollTop - (scrollTop % itemHeight)
      console.log('this.state.startOffset',this.state.startOffset)
    }
  }
}
</script>
<style lang="less" scoped>
.infinite-list-container {
  height: 100%;
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch; /* 启用触摸滚动 */
  .infinite-list-phantom {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    z-index: -1;
  }
  ul.infinite-list {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    text-align: center;
    li {
      list-style: none;
    }
  }
}
</style>

核心思想:

  1. 不管总数据有多少条,在初始化和滚动条滚动时,当前可视区域内只会渲染固定数量的数据,这样浏览器是不停地创建和销毁固定数量的DOM节点,不会造成性能问题
  2. ul li结构就是实际渲染的DOM,li依赖的数据就是visibleData,visibleData根据start和end在总数据listData中截取
  3. 如果只是渲染ul li结构,那么页面上没有显示出来滚动条啊?所以还需要一个div,它的高度是实际数据的高度,但是它不能显示在页面上,只是为了撑开父容器,把滚动条显示出来,可以用z-index: -1将它置于背景层
  4. 滚动条进行滚动时,做2件事,1是更新start和end,使可视区渲染出实时的数据;2是更新startOffset,使用transform将ul li拉回来(当滚动页面时,ul li会跟着一起滚动出去,所以这里需要再移回来)

vue有许多插件可以实现,包括不定高的实现:

  1. vue-virtual-scroller-demo.netlify.app/recycle
  2. tangbc.github.io/vue-virtual…

建议使用vue-virtual-scroller,vue-virtual-scroller-list实测1万条数据,每条数据比较大时,6千多条以后的数据渲染不出来

如果你觉得这篇文章对你有用,可以看看作者封装的库xtt-utils,里面封装了非常实用的js方法。如果你也是vue开发者,那更好了,除了常用的api,还有大量的基于element-ui组件库二次封装的使用方法和自定义指令等,帮你提升开发效率。不定期更新,欢迎交流~