昨天上午接了一个电话面试,聊着聊着接说到了性能优化,而后面试官问到了长列表。其实以前作过的都只是简单的分页处理,但面试官问的确定不是这个咯,他关心的是虚拟列表,大概之前粗略看过这个效果的实现源码,虽然我本身没实现过但有一些本身的想法,因而blablabla......,可能碍于表达能力有限,也不晓得面试官理解我意思没😂,因而简单实现并记录一下vue
当时体验这个效果时特地打开performance面板分析了一下,感受不是很满意。在网上找了一个实现,还原一下当时的场景,看图:node
前半段是不断经过滚轮滚动,后半段是快速拖拽滚动条,对于这种滚动相关的功能,我是那么一丢丢强迫症的......,FPS表现很明显,有红色报警了,在看那个CPU图表,有没有想将它抚平的冲动???git
长列表优化,自己就是一次优化行为(废话),但优化功能的同时这个优化自己不能不考虑优化,通过昨天晚上的一番捣鼓,我最终达到了以下效果:github
一样,前半段经过滚轮滚动,后半段快速拖拽滚动条。但实现后仍是有一缺陷的,待往后碰到这种需求时再去优化面试
源码基于vue实现,这里统一一下词汇算法
Item
表示长列表的每一个子项首先,得明确写这个功能要达到什么目的,或者说最终效果浏览器
由以上2点推测,我们有事情要作了性能优化
暂时只能想到这几点,下面,逐个实现它们。服务器
为何说是滑动窗口呢?在本地,咱们保存着一个超长的数据列表,但没有必要将他们所有加入到视图中,用户只须要也只能看到当前视口范围内显示的数据,既然这样,我们就能够用一个容器存放当前用户须要看到的数据,而后将这个容器中的数据展现给用户,能够将这个容器当作是一个小窗口,当用户发出要查看更多数据的请求时,移动这个小窗口,而后更新视图。
那么这个窗口的跨度有多大呢?
如今,咱们将窗口放大些,原理简单用图理解一下
具体的作法就是,若是一页展现10条数据,那么实际上我会渲染20条,而且将这20条数据划分为2部分,当可视区移动到容器的边缘时
容器的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>
复制代码
那么对于容器内的Item,根据vdom diff算法的特性:
大概猜想没什么说服力,我写完后,对比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请留言咯。