渲染十万条数据
# 渲染十万条数据
# 使用requestAnimationFrame
requestAnimationFrame最大的优势是由系统来决定回调函数的执行时机
<ul id="container"></ul>
//需要插入的容器
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(function () {
for (let i = 0; i < pageCount; i++) {
let li = document.createElement("li");
li.innerText = curIndex + i + " : " + Math.floor(Math.random() * total);
ul.appendChild(li);
}
loop(curTotal - pageCount, curIndex + pageCount);
});
}
loop(total, index);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 使用documentFragment
概念
DocumentFragment,文档片段接口,表示一个没有父级文件的最小文档对象。
它被作为一个轻量版的Document使用,用于存储已排好版的或尚未打理好格式的XML片段。最大的区别是因为DocumentFragment不是真实DOM树的一部分,它的变化不会触发DOM树的(重新渲染) ,且不会导致性能等问题。
可以使用document.createDocumentFragment方法或者构造函数来创建一个空的DocumentFragment
我们得知DocumentFragments是DOM节点,但并不是DOM树的一部分,可以认为是存在内存中的,所以将子元素插入到文档片段时不会引起页面回流。
当append元素到document中时,被append进去的元素的样式表的计算是同步发生的,此时调用 getComputedStyle 可以得到样式的计算值。
而append元素到documentFragment 中时,是不会计算元素的样式表,所以documentFragment 性能更优。当然现在浏览器的优化已经做的很好了, 当append元素到document中后,没有访问 getComputedStyle 之类的方法时,现代浏览器也可以把样式表的计算推迟到脚本执行之后。
# 虚拟列表
当我们使用暴力插入dom节点的时候

从Performance可以看出,代码从执行到渲染结束,共消耗了960.8ms,其中的主要时间消耗如下:
Event(click):40.84msRecalculate Style:105.08msLayout:731.56msUpdate Layer Tree:58.87msPaint:15.32ms
从这里我们可以看出,我们的代码的执行过程中,消耗时间最多的两个阶段是Recalculate Style和Layout。
Recalculate Style:样式计算,浏览器根据css选择器计算哪些元素应该应用哪些规则,确定每个元素具体的样式。Layout:布局,知道元素应用哪些规则之后,浏览器开始计算它要占据的空间大小及其在屏幕的位置。
在实际的工作中,列表项必然不会像例子中仅仅只由一个li标签组成,必然是由复杂DOM节点组成的。
那么可以想象的是,当列表项数过多并且列表项结构复杂的时候,同时渲染时,会在Recalculate Style和Layout阶段消耗大量的时间。
而虚拟列表就是解决这一问题的一种实现。
虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。
假设有10000条记录需要同时渲染,我们屏幕的可见区域的高度为500px,而列表项的高度为50px,则此时我们在屏幕中最多只能看到10个列表项,那么在首次渲染的时候,我们只需加载10条即可。

说完首次加载,再分析一下当滚动发生时,我们可以通过计算当前滚动值得知此时在屏幕可见区域应该显示的列表项。
假设滚动发生,滚动条距顶部的位置为150px,则我们可得知在可见区域内的列表项为第4项至第13项。

实现
虚拟列表的实现,实际上就是在首屏加载的时候,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。
- 计算当前
可视区域起始数据索引(startIndex) - 计算当前
可视区域结束数据索引(endIndex) - 计算当前
可视区域的数据,并渲染到页面中 - 计算
startIndex对应的数据在整个列表中的偏移位置startOffset并设置到列表上
由于只是对可视区域内的列表项进行渲染,所以为了保持列表容器的高度并可正常的触发滚动,将html结构设计成如下结构:
<div class="infinite-list-container">
<div class="infinite-list-phantom"></div>
<div class="infinite-list">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>
2
3
4
5
6
7
8
9
infinite-list-container为可视区域的容器infinite-list-phantom为容器内的占位,高度为总列表高度,用于形成滚动条infinite-list为列表项的渲染区域
接着,监听infinite-list-container的scroll事件,获取滚动位置scrollTop
- 假定
可视区域高度固定,称之为screenHeight - 假定
列表每项高度固定,称之为itemSize - 假定
列表数据称之为listData - 假定
当前滚动位置称之为scrollTop
则可推算出:
- 列表总高度
listHeight=listData.length * itemSize - 可显示的列表项数
visibleCount=Math.ceil(screenHeight / itemSize) - 数据的起始索引
startIndex=Math.floor(scrollTop / itemSize) - 数据的结束索引
endIndex=startIndex + visibleCount - 列表显示数据为
visibleData=listData.slice(startIndex,endIndex)
当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移至可视区域中。
- 偏移量
startOffset=scrollTop - (scrollTop % itemSize)
<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>
export default {
name:'VirtualList',
props: {
//所有列表数据
listData:{
type:Array,
default:()=>[]
},
//每项高度
itemSize: {
type: Number,
default:200
}
},
data() {
return {
//可视区域高度
screenHeight:0,
//偏移量
startOffset:0,
//起始索引
start:0,
//结束索引
end:null,
};
},
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;
},
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);
}
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77

遇到的问题
# 问题:每一条数据高度不同

- 对组件属性
itemSize进行扩展,支持传递类型为数字、数组、函数
- 可以是一个固定值,如
100,此时列表项是固高的 - 可以是一个包含所有列表项高度的数据,如
[50, 20, 100, 80, ...] - 可以是一个根据列表项索引返回其高度的函数:
(index: number): number
- 将列表项
渲染到屏幕外,对其高度进行测量并缓存,然后再将其渲染至可视区域内。
由于预先渲染至屏幕外,再渲染至屏幕内,这导致渲染成本增加一倍,这对于数百万用户在低端移动设备上使用的产品来说是不切实际的。
- 以
预估高度先行渲染,然后获取真实高度并缓存。
定义组件属性estimatedItemSize,用于接收预估高度
props: {
//预估高度
estimatedItemSize:{
type:Number
}
}
2
3
4
5
6
定义positions,用于列表项渲染后存储每一项的高度以及位置信息,
this.positions = [
// {
// top:0,
// bottom:100,
// height:100
// }
];
2
3
4
5
6
7
并在初始时根据estimatedItemSize对positions进行初始化。
initPositions(){
this.positions = this.listData.map((item,index)=>{
return {
index,
height:this.estimatedItemSize,
top:index * this.estimatedItemSize,
bottom:(index + 1) * this.estimatedItemSize
}
})
}
2
3
4
5
6
7
8
9
10
由于列表项高度不定,并且我们维护了positions,用于记录每一项的位置,而列表高度实际就等于列表中最后一项的底部距离列表顶部的位置。
//列表总高度
listHeight(){
return this.positions[this.positions.length - 1].bottom;
}
2
3
4
由于需要在渲染完成后,获取列表每项的位置信息并缓存,所以使用钩子函数updated来实现:
updated(){
let nodes = this.$refs.items;
nodes.forEach((node)=>{
let rect = node.getBoundingClientRect();
let height = rect.height;
let index = +node.id.slice(1)
let oldHeight = this.positions[index].height;
let dValue = oldHeight - height;
//存在差值
if(dValue){
this.positions[index].bottom = this.positions[index].bottom - dValue;
this.positions[index].height = height;
for(let k = index + 1;k<this.positions.length; k++){
this.positions[k].top = this.positions[k-1].bottom;
this.positions[k].bottom = this.positions[k].bottom - dValue;
}
}
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
滚动后获取列表开始索引的方法修改为通过缓存获取:
//获取列表起始索引
getStartIndex(scrollTop = 0){
let item = this.positions.find(i => i && i.bottom > scrollTop);
return item.index;
}
2
3
4
5
由于我们的缓存数据,本身就是有顺序的,所以获取开始索引的方法可以考虑通过二分查找的方式来降低检索次数:
//获取列表起始索引
getStartIndex(scrollTop = 0){
//二分法查找
return this.binarySearch(this.positions,scrollTop)
},
//二分法查找
binarySearch(list,value){
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while(start <= end){
let midIndex = parseInt((start + end)/2);
let midValue = list[midIndex].bottom;
if(midValue === value){
return midIndex + 1;
}else if(midValue < value){
start = midIndex + 1;
}else if(midValue > value){
if(tempIndex === null || tempIndex > midIndex){
tempIndex = midIndex;
}
end = end - 1;
}
}
return tempIndex;
},
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
滚动后将偏移量的获取方式变更:
scrollEvent() {
//...省略
if(this.start >= 1){
this.startOffset = this.positions[this.start - 1].bottom
}else{
this.startOffset = 0;
}
}
2
3
4
5
6
7
8
通过faker.js (opens new window) 来创建一些随机数据
let data = [];
for (let id = 0; id < 10000; id++) {
data.push({
id,
value: faker.lorem.sentences() // 长文本
})
}
2
3
4
5
6
7
点击查看在线DEMO及完整代码 (opens new window)
最终效果如下:
从演示效果上看,我们实现了基于文字内容动态撑高列表项情况下的虚拟列表,但是我们可能会发现,当滚动过快时,会出现短暂的白屏现象。
为了使页面平滑滚动,我们还需要在可见区域的上方和下方渲染额外的项目,在滚动时给予一些缓冲,所以将屏幕分为三个区域:
- 可视区域上方:
above - 可视区域:
screen - 可视区域下方:
below
定义组件属性bufferScale,用于接收缓冲区数据与可视区数据的比例
props: {
//缓冲区比例
bufferScale:{
type:Number,
default:1
}
}
2
3
4
5
6
7
可视区上方渲染条数aboveCount获取方式如下:
aboveCount(){
return Math.min(this.start,this.bufferScale * this.visibleCount)
}
2
3
可视区下方渲染条数belowCount获取方式如下:
belowCount(){
return Math.min(this.listData.length - this.end,this.bufferScale * this.visibleCount);
}
2
3
真实渲染数据visibleData获取方式如下:
visibleData(){
let start = this.start - this.aboveCount;
let end = this.end + this.belowCount;
return this._listData.slice(start, end);
}
2
3
4
5
点击查看在线DEMO及完整代码 (opens new window)
最终效果如下:
在前文中我们使用监听scroll事件的方式来触发可视区域中数据的更新,当滚动发生后,scroll事件会频繁触发,很多时候会造成重复计算的问题,从性能上来说无疑存在浪费的情况。
可以使用IntersectionObserver (opens new window)替换监听scroll事件,IntersectionObserver可以监听目标元素是否出现在可视区域内,在监听的回调事件中执行可视区域数据的更新,并且IntersectionObserver的监听回调是异步触发,不随着目标元素的滚动而触发,性能消耗极低
还是有遗留问题
如果列表包含图片,列表高度由图片撑开的话,由于图片会发送网络请求,无法保证是否完成加载,造成计算不准
这种情况下,如果我们能监听列表项的大小变化就能获取其真正的高度了。我们可以使用ResizeObserver (opens new window)来监听列表项内容区域的高度改变,从而实时获取每一列表项的高度。
不过遗憾的是,在撰写本文的时候,仅有少数浏览器支持 (opens new window)ResizeObserver