长列表滚动,是一个老生常谈的性能优化问题,在提到性能优化时却又是避不开的话题,其目标无非就是
尽可能在任意时刻内只关注可视窗口区域的数据的流畅展示,而忽视被遮挡区域,同时又不能影响数据的正常渲染。
本文尽可能全的总结一下所有长列表滚动的优化方案,以供参考。(本文仅在编程发布)
0、引
先说说 DOM 元素过多,页面为什么会卡顿呢?有没有想过这个问题?
其实直观感受也会觉得会变慢,因为浏览器要操心的事情多了。一下加载很多的东西,HTML 元素 占用的内存会一下变多,浏览器单薄的小身板就会吃不消,如果给每个dom又添加监听事件,马就会雪上加霜。
而本文讲的长列表滚动呢,会更进一步,每次滚动就会让页面所有元素触发回流重绘,因为其位置变化了嘛。你打一些老游戏,比如红警,视窗内单位过多时,就会卡顿,你用最好的显卡和CPU也还是会卡顿,他是因为老游戏的优化做的不够好。同理,web端也需要此类优化,处理长列表有如下几种方式:
- 分页加载:页次都显示固定的条数,性能可控。
- 无限滚动:初始加载一小部分,越滚动加载越多。这种出现性能问题只是时间问题。
- 虚拟滚动。
第一个分页加载不在今天的讨论范畴,这里就2、3两点开始谈论。
1、懒加载
对于数据很多的列表,一开始界面初始化的时候肯定不能把全部数据都塞在列表里,先别说前端能不能撑得住,后端查询时长都要爆炸。
最常见的就是手机端的商品列表,如下图:
设置一个内部可滚动的容器,固定高度,通过计算 scrollheight
与 scrollTop
的差值与容器的高度就可知道是否滚动到了底部:
// html中css设置container高度 500px
<div class="container">
<div class="item">1</div>
<div class="item">2</div>
<div class="item">3</div>
...
</div>
// js
const container = document.querySelector('.container');
container.addEventListener('scroll', () => {
const offset = container.scrollHeight - container.scrollTop;
const delta = 50;
console.log(offset)
if (offset <= (500 + delta)) {
// 可以设置滚动锁,锁定滚动监听
const newDom = document.createElement('div');
newDom.setAttribute('class', 'item');
newDom.innerText = '我是新加的';
container.appendChild(newDom)
}
})
在用户滚动到最下边自动监听滚动事件后,自动加载固定数目的内容或者提示 "加载更多" 来让用户点击加载。如下边的示意图所示,已经滚到底后,scrollHeight - scrollTop 的计算值就是视窗高度的 500 了:
上面给出了简易实现原理,这里只探讨方案实现,具体实现可加滚动锁或者延时来防止多提添加元素。可以看到这里设置了一个偏移值 50 像素,在滚动到距底部 50 像素时触发元素插入。
- 优点
实现相对简单,不需要额外引入类库。不用考虑每一个 item 的高度有多少。
- 缺点
- 频繁下拉,会造成滚动条越来越短,使得数据展示显得臃肿;
- 已经加载过的滚动隐藏区域的数据并没有做渲染上的处理;
- 全局搜索或页面回退后再次进来查看列表,滚动状态会丢失;
2、IntersectionObserver + 空div占位
顾名思义,这种方式是使用空的div占住隐藏区域之外的元素,这种往往可以和第一个方案组合使用。还是上面的例子:
const container = document.querySelector('.container');
const items = Array.from(container.children) || [];
items.forEach(item => {
const intersectionObserver = new IntersectionObserver(
function (entries) {
// 如果不可见,就返回
if (!entries[0].isVisible) {
entries[0].target.backup = entries[0].target.innerHTML || entries[0].target.backup;
entries[0].target.innerHTML = '';
return;
}
// 在可视区域
entries[0].target.innerHTML =
entries[0].target.backup || entries[0].target.innerHTML;
},
{
threshold: [0, 1],
/* required options*/
trackVisibility: true,
delay: 100
});
// 开始观察
intersectionObserver.observe(item);
})
我这里设置 IntersectionObserver 的配置属性threshold为 [0, 1]
,表示完全可见和完全不可见时才出发回调。通过判断 isVisible
属性来控制 innerHTML
显示内容。这里你需要一个缓存的map(我这里存在了dom的backup里),在可视时,恢复 innerHTML
的值。
这样在滚动时我们再看看dom结构:
可以看到,不显示的区域已经置为了空的div了。
- 优点
- 使用原生的H5 API,兼容性好;
- 结合第一种方案,可监听底部元素是否可见来实现无限滚动,实现简单;
- 没有dom插入删除操作,开销比较小,不破坏原有的结构;
- 空的div占位可以使滚动条的高度不会跳变;
- 缺点
- 就算是空的div,也还是会造成渲染浪费;
- 缓存map造成空间浪费,维护成本提高;
- 无限滚动太频繁,滚动条还是会缩为一个点;
- 占位div的高度固定了,就意味着item的高度需要固定;
3、数据截断式占位
这种方案可以看做是第二种方案的升级版。其不使用小的div占位,而是在列表开头和结尾,分别用两个大的div占位,达到数据截断的效果。我们看草图:
startIndex
和 endIndex
就是数据数组中的真实下标位置,所以要替换的区间是。我们要做的就是把 startIndex 之前和 endIndex 之后的部分替换为div。这里因为要用到 scroll 属性,所以还是用 scroll 事件来监听,同时牵扯到dom结构的变动,所以还是使用js来操作dom。
先定义容器:
// css不是重点,忽略
<div class="container"></div>
定义数据并获取元素:
const data = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20];
const container = document.querySelector('.container');
接下来定义一下添加列表的方法:
function addList(data, container) {
data.forEach(item => {
const dom = document.createElement('div');
dom.setAttribute('class', 'item');
dom.innerHTML = item;
container.appendChild(dom)
});
}
定义滚动事件监听(可加入滚动锁或者节流优化):
container.addEventListener('scroll', () => {
const scrollTop = container.scrollTop;
// 上边空白的高度
const topHeight = scrollTop;
const startIndex = Math.max(Math.ceil(topHeight / 40) - 2, 0);
const endIndex = startIndex + Math.ceil(500 / 40);
const show = data.slice(startIndex, endIndex + 1);
// 计算下边剩余的隐藏区域高度
const dataHeight = data.length * 40;
const bottomHeight = dataHeight - 500 - scrollTop;
const topDom = document.createElement('div');
const bottomDom = document.createElement('div');
topDom.style.height = topHeight + 'px';
bottomDom.style.height = bottomHeight + 'px';
// 还没到底
if (bottomHeight > -100) {
// 清空
container.innerHTML = '';
container.appendChild(topDom);
addList(show, container);
container.appendChild(bottomDom);
}
});
可以看到,这里不能使用 container.scrollHeight 来计算高度了,要通过数据乘以每一行高度获取。最后的bottomHeight > -100
是为了避免出现计算负数而造成的页面抖动。(当然你也可以写死一个阈值,页面足够长时,超过这个阈值,首尾两个div高度就定死。)
最后在页面初始化时加载一次列表:
window.onload = function() {
addList(data, container)
}
最后我们在页面上滚动一下看看效果:
成功!!
更重要的是,这个方案完美契合单页应用框架,所有的dom操作都可以使用框架语言替代,比如用vue的v-for + 计算属性就可以完成。之前在bilibili面试的时候,就问了一下是否做过这个方案