虚拟列表
俾众周知,DOM数量是影响站点性能的最直接原因之一,如何有效的控制DOM数量,提升页面性能是提高用户粘性,产品转化率的关键手段之一,本篇文章将阐述怎么摧枯拉朽般
完成一个虚拟列表的实现
正文
我们先来看下正常的长列表与虚拟列表的渲染时间差别
正常页面
我们先来模拟一个页面:
<div id="content">
<div class="item">
<div class="item-animation"></div> * 10
</div> * 2000
</div>
.content {
height: 100%;
}
.item-animation {
transform: translate(0);
transtion: transfrom .5s;
}
.item-animation--active {
transform: translate(10px);
}
const item = document.getElementsByClassName('item')[0]
item.onclick = function() {
Array.from(item.children).forEach(div => {
div.classList.add('item-animation--active')
})
}
上面我们定义了一个长列表,一个具有2000个内部有10个子元素的item元素,点击item的时候会让子元素item-animation触发向右移动的动画,我们来测下当前trigger它的执行时间是多久:
我们可以发现在当前执行javascipt点击的Task的执行时间为51ms,这严重的超出了我们正常的刷新流畅标准16.7ms一次,也就意味着该行为让页面掉了2帧左右,这会大大降低用户留存,再通过下图我们可以看到在最后生成renderLayerTree的时间是7.13ms,正是因为页面里的元素体量过大,导致在渲染的最后一步渲染层更新的时候时间过长。
虚拟列表
同样是上面的例子,我们再来看下点击第一个item触发的子元素动画渲染耗费了多久,通过下图我们可以发现同样是一个动画同样数量的子元素,Task执行的时间为1.21ms, 这里比上面的例子中缩短了50倍的时间😱。
实现
前置条件
我们在实现该需求时需要先了解以下几个知识点:
- javascript获取的DOMOM并不是在renderTree上的renderObject, 正如我们用javascript可以获取到display:none的节点表现一致,我们是从DOMTree上去获取的DOM
- DOMTree更新是实时的
- Task分为MacroTask与MicroTask, 每个MacroTask执行完后会交由渲染进程执行一次渲染操作
更多详细资料可以阅读 你理解错误的nextTick
等高虚拟列表
同样是上面的例子,我们先设立HTML
<div class="content">
<div class="virtual-content"></div>
<div class="real-content"></div>
</div>
html, body, #app {
height: 100%;
width: 100%;
}
.content {
position: relative;
height: 100%;
overflow-y: auto;
}
.real-content {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
}
content: 装载真实内容的容器,高度是继承父级的100%
virtual-content: 撑开content的高度
real-content: 装载可视窗口里需要展示信息
我们默认所有item都是等高
的, 那么我们先看下思路:
-
首先我们需要知道所有节点都加载在文档里的时候撑开的高度是多少,我们需要在
Promise微任务里
将所有的节点加载到容器virtual-content里,此时容器virtual-content所对应的DOM在DOMTree上就会更新它的属性值(例如高度),此时我们可以通过js去获取到virtual-content的高度,virual-content是用来撑开整个高度,从而达到模拟滚动
的作用,在获取到高度后将其赋值给virtual-content(为了保证后面删除所有的子元素后virtual-content依然能够撑开高度) -
随后我们记住当前屏幕的可视高度与当前单个item的高度(包括margin, border, padding等),这样我们就可以得出当前屏幕的尺寸下可以加载多少个可视item的个数:
- size = clientHeight / itemHeight
然后将所有的数据利用集合变量childrenSet缓存
(为了后面数据展示而储存),随后清空virtual-content的内容。这样的话我们上面的1 2步骤利用了前置条件第三点内容完成了前置操作
-
定义start(childrenSet截取的开始位置start), end(childrenSet截取的末尾位置end), end的定义很简单:
- end = start(数据开始截取的位置) + size(可视区域item数量)
-
监听content的滚动,不断的去刷新start的值,start的值其实就是content的scrollTop除以itemHeight
取下值
(因为我们还要模拟出item本身划出可视区域外的过程)- start = Math.floor(scrollTop / itemHeight)
-
real-content由于是绝对定位的布局,所以它会随着content的滚动划出可视区域,所以我们需要使用transform: translateY将其重新划回到可视区域内,但是为了模拟真实滑动的场景(因为item本身具有高度,
在item部分划出时,real-content是不需要被拉回到可视区域内的
),我们需要通过计算去设置对应的translateY值,那么我们在上一步说到start的值就起了关键作用,因为start的值取的是scrollTop除以itemHeight的下限值,那么多出的余值其实就是itemHeight,这一部分的值是用来模拟item划出去的过程,我们不需要做任何的计算处理,最后我们只需要将real-content拉回到start * itemHeight即可,这样就完成等高虚拟滚动的一个思路
非等高虚拟列表
非等高与等高的差别其实就是由于item的高度无法确定,导致可视区域能够容载多少个item的size不确定,最终无法确定出end的截止位置,这一点的思路其实也很简单,听我娓娓道来~
前面的细节与等高的基本一致,我们来着重讲下怎么去确定size这一过程
具体思路
- 在我们保存对应的item节点到childrenSet集合里的同时,
我们需要另外设置一个集合childHeightSet: 用来保存item对应的itemHeight
。 - childHeightSet与childrenSet是一一对应的,也就是同样的下标值,childrenHeightSet的值就是childrenSet的值的高度,利用这个特性我们可以这么做
- 首先获取start的时候不能单单的使用scrollTop / itemHeight, 而是需要对比scrollTop与childrenHeightSet的前n个累加的值。当scrollTop大于累加值,则说明childrenSet还未到截取的位置;若是scorllTop <= 累加值,则说明当前的item已经被滑动到可视区域的最顶部,那么start的值就是当前的下标值
function getStart(scrollTop) {
var height = 0
var start = 0
var i = 0
while(true) {
const currentItem = childrenHeight[i]
if (currentItem) {
height += currentItem
if (height >= scrollTop) {
start = i
break
}
} else {
break
}
i++
}
return start
}
4.确定size(最终直接获取end),我们需要使用当前可视区域的高度(screenClientHeight)然后去对比childrenHeightSet中start下标值后累加值。当screenClientHeight大于累加值,则说明childrenSet还未到end的位置;若是screenClientHeight <= 累加值,则说明当前的item已经是可视区域最底部了,那么end的值就是当前的下标值,这样也就解决了非等高虚拟列表的问题啦!
结束语
谢谢观看!