昨天上午接了一个电话面试,聊着聊着接说到了性能优化,然后面试官问到了长列表。其实之前做过的都只是简单的分页处理,但面试官问的肯定不是这个咯,他关心的是虚拟列表,大概以前粗略看过这个效果的实现源码,虽然我自己没实现过但有一些自己的想法,于是blablabla......,可能碍于表达能力有限,也不晓得面试官理解我意思没😂,于是简单实现并记录一下
当时体验这个效果时特意打开performance面板分析了一下,感觉不是很满意。在网上找了一个实现,还原一下当时的场景,看图:

前半段是不断通过滚轮滚动,后半段是快速拖拽滚动条,对于这种滚动相关的功能,我是那么一丢丢强迫症的......,FPS表现很明显,有红色报警了,在看那个CPU图表,有没有想将它抚平的冲动???
长列表优化,本身就是一次优化行为(废话),但优化功能的同时这个优化本身不能不考虑优化,经过昨天晚上的一番捣鼓,我最终达到了如下效果:

同样,前半段通过滚轮滚动,后半段快速拖拽滚动条。但实现后还是有一缺陷的,待日后碰到这种需求时再去优化
- 仅支持固定高度(并且要一致)的列表
- 在滚动非常快的时候,会有闪烁,移动设备上尤为明显,暂时没想到什么好的方法解决,这和scroll事件机制有关
源码基于vue实现,这里统一一下词汇
Item
表示长列表的每个子项
思路
首先,得明确写这个功能要达到什么目的,或者说最终效果
- 提升长列表页面的性能
- 在体验上,用户无法感知你用了长列表
- 让这个功能组件化(暂不考虑)
由以上2点推测,咱们有事情要做了
- 提升性能主要方向还是减少长列表页面的渲染节点数量,优化前是全量渲染,优化后最好只渲染用户能看到的节点,或者说越少越好
- 优化后页面有和普通长列表页面一样的滚动条反馈
- 优化后的滚动体验要非常接近原生滚动体验
- 上拉加载
暂时只能想到这几点,下面,逐个实现它们。
滑动窗口
为什么说是滑动窗口呢?在本地,我们保存着一个超长的数据列表,但没有必要将他们全部加入到视图中,用户只需要也只能看到当前视口范围内显示的数据,既然这样,咱们就可以用一个容器存放当前用户需要看到的数据,然后将这个容器中的数据展示给用户,可以将这个容器看成是一个小窗口,当用户发出要查看更多数据的请求时,移动这个小窗口,然后更新视图。
那么这个窗口的跨度有多大呢?
- 假如刚好是视口的高度,当向下移动窗口的时候,需要将窗口最上方的Item去掉,因为用户不需要看到了,然后把下一个数据push到窗口最下方,那么窗口移动很快的时候,更新的频率也会非常快
- 假如将窗口再放大一些,就能减小上面的更新频率,相当于节流,这取决于窗口大小
现在,我们将窗口放大些,原理简单用图理解一下

具体的做法就是,如果一页展示10条数据,那么实际上我会渲染20条,并且将这20条数据划分为2部分,当可视区移动到容器的边缘时
- 如果可视区的上边缘碰到容器的上边缘,用前半部分Item填充后半部分Item,然后在原始数据中往前拿10条数据填充到前半部分,再将容器的位置上移10个Item高度
- 和上面的情况刚好相反
容器的DOM结构像这样
<div ref="fragment" class="fragment" :style="{ transform: `translate3d(0, ${translateY}px, 0)` }">
<template v-for="item in currentViewList">
<div :key="item.key">
<!-- item content -->
</div>
</template>
</div>
// 原始数据
const sourceList = [/* ... */]
// 状态1
const currentViewList = [...sourceList.slice(20, 30), ...sourceList.slice(30, 40)]
// 状态1 向下
currentViewList = [...sourceList.slice(30, 40), ...sourceList.slice(40, 50)]
// 状态1 向上
currentViewList = [...sourceList.slice(10, 20), ...sourceList.slice(20, 30)]
这里使用translate平移,因为这可以减少不必要的layout,在这个实现中,移动容器是一个非常频繁的操作,所以非常有必要考虑layout消耗
滚动事件
关于滚动行为,有几点需要明确,先看图(浏览器渲染每一帧要做的事情),需要进一步了解的朋友可以去查查相关资料

- 滚动不一定是连续的,比如快速拖动滚动条
- 滚动事件在每一帧绘制前执行,自带节流效果,并且和每一帧是“同步”的,只需要保证回调逻辑足够简单快捷,尽量不去触发回流操作,就能保证不会影响原有的平滑滚动的效果
滚动条
对滚动行为的要求决定了得使用原生滚动,其实也很简单,由于还需要实现上拉加载功能,我们在底部肯定需要放一个loading,这样的话,就可以给loading设置一个paddingTop值,大小为Item的高度乘以列表长度 ,这样一来滚动条就是真实的滚动条了
<div ref="fragment" class="fragment" :style="{ transform: `translate3d(0, ${translateY}px, 0)` }">
<!---->
</div>
<div class="footer" :style="{ paddingTop: `${loadingTop}px` }">
<div class="footer-loading">Loading......</div>
</div>
用不用key?
那么对于容器内的Item,根据vdom diff算法的特性:
- 设置key的情况下,其中一半在更新时只需要调换位置,另外一半会被移除,然后会新增一半的DOM,如果我手动快速拖动滚动条,那可能所有DOM都要被删除然后重新创建。
- 不设置key的情况下,20个Item都不会被删除,在这种情况下快速拖动滚动条,就不需要重新创建DOM了,但每个Item每次都会被就地复用,缺点就是原本可以只进行移动的节点也被就地复用了
大概猜测没什么说服力,我写完后,对比2种情况进行了多次测试,发现2者差距其实不是很大(可能是我电脑原因😂),综合几次测试,不使用key时情况看起来稍微好些
不使用key

使用key

实际上我这几年没有碰到过这种需求,这里我就选择不使用key渲染
临界点判断
这里的方式有很多种,可以在滚动事件中通过getBoundingClientRect
获取到容器相对视口的位置后计算。这里有的朋友可能会有疑问,getBoundingClientRect
方法不是会触发回流吗?你在滚动事件中频繁调用这个方法,那对性能不是非常不利吗?来看2个小例子:
// 例1
setInterval(() => {
console.log(document.body.offsetHeight)
}, 100)
// 例2
let height = 1000
setInterval(() => {
document.body.style.height = `${height++}px`
console.log(document.body.offsetHeight)
}, 100)
显然这里的例1不会导致回流,但例2就会了,原因是因为你在当前帧更新了layout相关的属性,同时设置后又进行了一次查询,这就导致浏览器必须进行layout得到正确的值后返回给你。所以,关于我们平常所说的那些导致layout的属性,不是用了就会layout,而是看你如何用。
那么临界点的逻辑大概是这样的:
const innerHeight = window.innerHeight
const { top, bottom } = fragment.getBoundingClientRect()
if (bottom <= innerHeight) {
// 到达最后一个Item,向下
}
if (top >= 0) {
// 到达第一个Item,向上
}
注意在页面滚动时,这里并不会频繁触发向上或者向下的逻辑。以向下为例,当触发向下的逻辑后,立即将容器的translateY
值更新(相当于下移10个Item高度)向下平移,同时更新Item,下一帧渲染后容器下边缘已经回到可视区下方了,然后继续向下滚动一段距离后才会再次触发,这其实就像一个懒加载,只不过这是同步的。
滚动方向
只有在向下滚动时,才有必要执行向下的逻辑,向上滚动同理。为了处理不同方向的逻辑,需要算出当前的滚动方向,这个直接保存上一次的值就能搞定了
let oldTop = 0
const scrollCallback = () => {
const scrollTop = getScrollTop(scroller)
if (scrollTop > oldTop) {
// 向下
} else {
// 向上
}
oldTop = scrollTop
}
实现
结合前面的代码,我们先绑定一下滚动事件
const innerHeight = window.innerHeight
// 滚动容器
const scroller = window
// Item容器
const fragment = this.$refs.fragment
let oldTop = 0
const scrollCallback = () => {
const scrollTop = getScrollTop(scroller)
const { top, bottom } = fragment.getBoundingClientRect()
if (scrollTop > oldTop) {
// 向下
if (bottom <= innerHeight) {
// 到达最后一个Item
this.down(scrollTop, bottom) // 待实现
}
} else {
// 向上
if (top >= 0) {
// 到达第一个Item
this.up(scrollTop, top) // 待实现
}
}
oldTop = scrollTop
}
scroller.addEventListener('scroll', scrollCallback)
懒加载
处理滚动条时,咱们已经添加了loading标签,这里只需要在滚动事件中判断这个loading元素是否出现在可视区,一旦出现就触发加载逻辑。这里有一个边界情况要考虑,一旦触发了加载逻辑,不出意外在拿到响应数据时是要更新原始数据的,如果此时,我停留在底部,需要自动将新的数据渲染出来;如果我在没有拿到数据前,向上滚动了,那么拿到响应后就不需要将新的数据更新到视图了。
const loadCallback = () => {
if (this.finished) {
// 没有数据了
return
}
const { y } = loadGuard.getBoundingClientRect()
if (y <= innerHeight) {
if (this.loading) {
// 不能重复加载
return
}
this.loading = true
// 执行异步请求
}
}
向下滚动
首先,需要做一些相关的边界处理,比如currentViewList
中的数据量不满足向下滚动等。主要还是要注意一点:滚动不一定是连续的
down (scrollTop, y) {
const { size, currentViewList } = this
const currentLength = currentViewList.length
if (currentLength < size) {
// 数据不足以滚动
return
}
const { sourceList } = this
if (currentLength === size) {
// 单独处理第二页
this.currentViewList.push(...sourceList.slice(size, size * 2))
return
}
const length = sourceList.length
const lastKey = currentViewList[currentLength - 1].key
// 已经是当前最后一页了,但可能正在加载新的数据
if (lastKey >= length - 1) {
return
}
let startPoint
const { pageHeight } = this
if (y < 0) {
// 直接拖动滚动条,导致容器底部边缘直接出现在可视区上方,这种情况通过列表高度算出当前位置
const page = (scrollTop - scrollTop % pageHeight) / pageHeight + (scrollTop % pageHeight === 0 ? 0 : 1) - 1
startPoint = Math.min(page * size, length - size * 2)
} else {
// 连续的向下滚动
startPoint = currentViewList[size].key
}
this.currentViewList = sourceList.slice(startPoint, startPoint + size * 2)
}
向上滚动
向上滚动的处理和向下滚动类似,这里就直接贴代码了。
up (scrollTop, y) {
const { size, currentViewList } = this
const currentLength = currentViewList.length
if (currentLength < size) {
return
}
const firstKey = currentViewList[0].key
if (firstKey === 0) {
return
}
let startPoint
const { sourceList, innerHeight, pageHeight } = this
if (y > innerHeight) {
const page = (scrollTop - scrollTop % pageHeight) / pageHeight + (scrollTop % pageHeight === 0 ? 0 : 1) - 1
startPoint = Math.max(page * size, 0)
} else {
startPoint = currentViewList[0].key - size
}
this.currentViewList = sourceList.slice(startPoint, startPoint + size * 2)
},
到此,这些功能差不多已经实现,仔细想想,如果不用任何库或者框架直接用原生操作DOM的方式实现的话,应该能达到更好的性能,因为可以更直接的移动和复用DOM,同时少了一层vnode等减少内层消耗,但却丧失了更好的可维护性,如果能将这个功能单独作为一个插件开发,倒是可以考虑。如果数据在本地服务器中,似乎可以抛弃这个sourceList
,这样的话页面就会内存爆减,带来的结果就是白屏时间稍长。写的比较快,略显粗糙,也可能还有BUG,如果有啥BUG请留言咯。