高性能渲染十万级数据

前言

对于一次性插入大量数据的情况,一般有两种做法:

  1. 时间分片
  2. 虚拟列表

接下来将分别介绍如何使用’时间分片‘及’虚拟列表‘的方式来渲染大量数据

时间分片

一次性渲染(最容易想到的极其粗暴的方法)

<ul id="container">

</ul>
<script>
    // 记录任务开始时间
    let now = Date.now()
    // 插入十万条数据
    const total = 100000
    // 获取容器
    let ul = document.getElementById('container')
    //将数据插入到容器中
    for(let i=0;i<total;i++){
        let li = document.createElement('li')
        li.innerText = Math.random()*total
        ul.appendChild(li)
    }
    console.log('js运行时间:', Date.now() - now) // 162ms
    setTimeout(() => {
        console.log('总运行时间:', Date.now() - now) // 2887ms
    },0)
</script>

简单说明一下,为何两次console.log的结果时间差异巨大,并且是如何简单统计JS运行时间和总渲染时间:

  • 在JS的事件队列Event Loop中,当JS引擎所在管理当执行栈中当事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染
  • 第一个console.log的触发时间是中页面进行渲染之前,此时得到的间隔时间为JS运行所需要的时间
  • 第二个console.log是放到定时器中,他的触发时间是在渲染完成,在下一次事件队列中执行的

可以得出的结论:对于大量数据渲染的时候,JS运算并不是性能的瓶颈,性能的瓶颈主要在于渲染阶段

关于Event Loop更多知识点可以参考:详谈JavaScript的Event Loop

使用定时器

从上面简单粗暴的例子可以看出页面的渲染耗时长是由于同时渲染大量DOM所引起的,所以我们考虑将渲染过程分批进行

let ul = document.getElementById('container')
// 插入十万条数据
let total = 100000
//一次插入20条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
 if(curTotal<=0){
    return false;
 }
  //每页多少条
 let pageCount = Math.min(curTotal, once)
  setTimeout(() => {
  for(let i=0;i<pageCount;i++){
       let li = document.createElement('li')
        li.innerText = curIndex + i + ':' + ~~(Math.random()*total)
        ul.appendChild(li)
    }
        loop(curTotal - pageCount,curIndex+pageCount)
    },0)
  }
 loop(total,index)


我们可以看到页面加载速度变快了,但当我们快速滚动页面的时候,会发现页面出现闪屏或白屏的现象。

为什么会出现闪屏现象

首先理清一个概念:FPS表示的是每秒钟画面更新次数,我们平时所看到的连续画面都是由一幅幅静止画面组成的,每幅画面为一帧,FPS是描述帧变化速度的物理量。
大多数电脑显示器的刷新频率是60HZ,大概相当于每秒钟重绘60次,这个值受设定受屏幕分辨率,屏幕尺寸和显卡的影响。所以当对着电脑屏幕什么都不做的情况下,大多数显示器会以每秒60次的频率不断更新屏幕上的图像。

为什么感受不到这个变化?
那是因为人的眼睛有视觉停留效应,即前一副画面留在大脑的印象还没消失,紧接着后一副画面就跟上来了。

setTimeout和闪屏现象

  • setTimeout的执行时间并不是确定。在JS中,setTimeout任务被放进事件队列中,只有主线程执行完才会去检查事件队列中的任务是否需要执行,因此setTimeout的实际执行时间可能会比其设定的时间晚一些。
  • 刷新频率受屏幕分辨率和屏幕尺寸的影响,因此不同设备的刷新频率可能会不同,而setTimeout只能设置一个固定时间间隔,这个时间不一定和屏幕的刷新时间相同。
    以上两种情况导致setTimeout的执行步调和屏幕的刷新步调不一致。

使用requestAnimationFrame

与setTimeout相比,它最大的优势是由系统来决定回调函数的执行时机,可以保证回调函数在屏幕每一次的刷新间隔只被执行一次,这样就不会引起丢帧现象。

let ul = document.getElementById('container')
// 插入十万条数据
let total = 100000
//一次插入20条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
 if(curTotal<=0){
    return false;
 }
  //每页多少条
 let pageCount = Math.min(curTotal, once)
  window.requestAnimationFrame(() => {
  for(let i=0;i<pageCount;i++){
       let li = document.createElement('li')
        li.innerText = curIndex + i + ':' + ~~(Math.random()*total)
        ul.appendChild(li)
    }
        loop(curTotal - pageCount,curIndex+pageCount)
    })
  }
 loop(total,index)

使用 DocumentFragment

DocumentFragment,文档片段接口,表示一个没有父级文件的最小文档对象。被作为一个轻量级版的Document使用,用于存储已排好版的或尚未打理好格式的XML片段。最大区别是因为DocumentFragment不是真实DOM树的一部分,它的变化不会触发DOM树的重新渲染,且不会导致性能问题。
可以使用document.createDocumentFragment方法或者构造函数来创建一个空的DocumentFragment

从上述说明中可以得知DocumentFragments是Dom节点,但并不是DOM树的一部分,可以认为是存在内存中的,所以将子元素插入到文档片段时不会引起页面回流。

当append元素到document中时,被append进去的元素的样式表的计算是同步发生的,此时调用getComputedStyle可以得到样式的计算值。而append元素到documentFragment中时,不会计算元素的样式表,所以性能更优。

let ul = document.getElementById('container')
// 插入十万条数据
let total = 100000
//一次插入20条
let once = 20;
//总页数
let page = total/once
//每条记录的索引
let index = 0;
//循环加载数据
function loop(curTotal,curIndex){
 if(curTotal<=0){
    return false;
 }
  //每页多少条
 let pageCount = Math.min(curTotal, once)
  window.requestAnimationFrame(() => {
  let fragment = document.createDocumentFragment()
  for(let i=0;i<pageCount;i++){
       let li = document.createElement('li')
        li.innerText = curIndex + i + ':' + ~~(Math.random()*total)
        fragment.appendChild(li)
    }
    ul.appendChild(fragment)
        loop(curTotal - pageCount,curIndex+pageCount)
    })
  }
 loop(total,index)

虚拟列表

对于复杂DOM的情况,一般会用虚拟列表的方式来实现

根据容器元素的高度以及列表项元素的高度来显示长列表数据中的某一个部分,而不是去完整地渲染长列表,以提高滚动的性能。虚拟列表是一种根据滚动容器元素的可视区域来渲染长列表数据中某一个部分数据的技术。

虚拟列表指的就是【可视区域渲染】的列表

  • 滚动容器元素:一般情况下滚动容器元素是window对象。
  • 可滚动区域:滚动容器元素的内部内容区域。可滚动区域当前的具体高度可以通过滚动容器元素的scrollHeight属性获取。
  • 可视区域:滚动容器元素的视觉可见区域。如果容器元素是window对象,可视区域就是浏览器的视口大小;如果容器元素是某个div元素,高度为300,右侧有滚动条可以滚动,那么视觉可见的区域就是可视区域。

实现虚拟列表就是在处理用户滚动时,改变列表在可视区域的渲染部分,具体步骤:

  1. 计算当前可视区域的起始数据的startIndex
  2. 计算当前可视区域结束数据的endIndex
  3. 计算当前可见区域的数据,并渲染到页面中。
  4. 计算startIndex对应的数据在整个列表中的偏移位置startOffset,并设置到列表上
  5. 计算endIndex对应的数据相对于可滚动区域最底部的偏移位置endOffset,并设置到列表上。
    可以参考下图:

可以封装一个VirtualList.vue虚拟列表组件

<template>
  <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)">
    <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="infinite-list" :style="{ transform: getTransform }">
      <div ref="items"
        class="infinite-list-item"
        v-for="item in visibleData"
        :key="item.id"
        :style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }"
      >{{ item.value }}</div>
    </div>
  </div>
</template>

<script>
export default {
  name:'VirtualList',
  props: {
    //所有列表数据
    listData:{
      type:Array,
      default:()=>[]
    },
    //每项高度
    itemSize: {
      type: Number,
      default:200
    }
  },
  computed:{
    //列表总高度
    listHeight(){
      return this.listData.length * this.itemSize;
    },
    //可显示的列表项数
    visibleCount(){
      return Math.ceil(this.screenHeight / this.itemSize)
    },
    //偏移量对应的style
    getTransform(){
      return `translate3d(0,${this.startOffset}px,0)`;
    },
    //获取真实显示列表数据
    visibleData(){
      return this.listData.slice(this.start, Math.min(this.end,this.listData.length));
    }
  },
  mounted() {
    this.screenHeight = this.$el.clientHeight;
    this.start = 0;
    this.end = this.start + this.visibleCount;
  },
  data() {
    return {
      //可视区域高度
      screenHeight:0,
      //偏移量
      startOffset:0,
      //起始索引
      start:0,
      //结束索引
      end:null,
    };
  },
  methods: {
    scrollEvent() {
      //当前滚动位置
      let scrollTop = this.$refs.list.scrollTop;
      //此时的开始索引
      this.start = Math.floor(scrollTop / this.itemSize);
      //此时的结束索引
      this.end = this.start + this.visibleCount;
      //此时的偏移量
      this.startOffset = scrollTop - (scrollTop % this.itemSize);
    }
  }
};
</script>


<style 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;
}

.infinite-list {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
  text-align: center;
}

.infinite-list-item {
  padding: 10px;
  color: #555;
  box-sizing: border-box;
  border-bottom: 1px solid #999;
}
</style>

在全局组件中引入

<template>
<div id="app">
  <VirtualList :listData="data" :itemSize="100"/>
</div>
</template>

<script>
import VirtualList from "./components/VirtualList";
let d = [];
for (let i = 0; i < 1000; i++) {
  d.push({ id: i, value: i });
}

export default {
  name: "App",
  data() {
    return {
      data: d
    };
  },
  components: {
    VirtualList
  }
};
</script>

<style>
html{
  height: 100%;
}
body{
  height: 100%;
  margin:0;
}
#app{
  height:100%;
}
</style>

文章作者: Dovis
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Dovis !
  目录