如果后端返回上万条数据,前端如何渲染成长列表呢?
使用虚拟列表
只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能,虚拟列表其实是按需显示的一种实现。
虚拟列表一般包含三个组成部分:可视区域、列表渲染区域、真实列表区域。列表渲染区大于等于可视区。比如容器区域需要渲染三屏的节点,当前展示区上一页下一页,滚动过程中会更流畅
视图结构
按照图示,我们先构造如下的视图结构
1. viewport:可视区域的容器
2. list-phantom:容器内的占位,高度为真实列表区域的高度,用于形成滚动条
3. list-area:列表项的渲染区域
<div className="viewport">
<div className="list-phantom"></div>
<div className="list-area">
<!-- item-1 -->
<!-- item-2 -->
<!-- item-n -->
</div>
</div>
基本思路
虚拟列表的核心思路是 处理用户滚动时可视区域数据的显示 和 可视区外数据的隐藏,这里为了方便说明,引入以下相关变量:
1. startIndex:可视区域的开始索引
2. endIndex:可视区域的结束索引
3. startOffset:可视区第一个元素的向上偏移量
当用户滚动列表时:
1. 计算可视区域的 开始索引 和 结束索引
2. 根据 开始索引 和 结束索引 渲染数据
3. 计算 第一个元素 偏移量并设置到列表渲染区
具体计算:
先假定每个列表项的高度固定为100px,则我们可设置和推导出:
1. 列表项高度: itemSize = 100
2. 可视区的列表项数量: viewcount = viewport / itemSize
3. 可视区结束索引 :endIndex = startIndex + viewcount
当用户滚动时,逻辑处理如下:
1. 获取可视区滚动距离 scrollTop
;
2. 根据 滚动距离 scrollTop 和 单个列表项高度 itemSize 计算出 开始索引 startIndex 和 结束索引 endIndex;
// 获取startIndex
const getStartIndex = (scrollTop) => {
return Math.floor(scrollTop / itemSize); // 这里可以思考下,为什么要用 Math.floor
};
3. 根据 开始索引 和 单个列表项高度 计算出 可视区第一个元素的向上偏移量 ;
4. 只显示 开始索引 和 结束索引 之间的列表项;
5. 设置 列表渲染区域 list-area
的偏移量为 startOffset
动态高度
以上是列表项高度固定的情况,实际项目中列表项高度通常可能是有图文视频在内,高度不固定,我们可以在内容渲染完成后,获得其高度
1. 构造一个数组,缓存列表每行高度及距离顶部及底部高度
2. 动态计算鼠标滑动时当前应展示的列表区间
3. 在dom节点插入后再计算当前显示的节点实际高度替换缓存中的高度修改可视化位置距离顶部距离
当有 item 项高度变化后,我们只需要维护这一个数组的数据即可,从而大大减少了处理起来的复杂度。
使用 ResizeObserver
可以监听到指定元素的高度的变化。ResizeObserver
可以监听到指定元素的高度的变化,而且是原生浏览器层面的支持,性能方面也是可靠的。兼容性方面,除了IE,其他也都是支持的。
缺点
虚拟列表也不是十全十美的,它会有一些问题,主要是:
1. 滚动过快出现会白屏
2. 滚动时有大量的计算
白屏优化
方案一:增加缓存区
在虚拟列表的原理中有提到过,列表渲染区是可以大于等于可视区,这里的采取措施就是列表渲染区域要大于可视区。
措施:在可视区外设置缓存区,额外渲染合适的列表项。
优势:在滚动过快时,会先显示缓存区中的元素,减少白屏出现的情况。
不足:缓存区域设置过大,也会导致渲染性能变差,需要结合具体的业务场景设置合适的缓存值。
方案二:部分渲染
在前面虚拟列表的原理中也有提到过,对非可见区域中的数据不渲染或部分渲染的技术,这里所用到的就是不可见列表项的部分渲染。
措施:采用skeleton
加载骨架屏来代替原有的不渲染部分,这样当滚动过快时,白屏也就替换为了加载屏。
优势:用户体验上会有所增强。
不足:会额外渲染skeleton
的dom
元素。不过对比整个列表元素的dom
节点来看,可以忽略不计的。
计算优化
我们可以采用二分查找法来进行优化
下面是子项目高度固定的虚拟列表的 demo 实现:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<style>
.container {
width: 600px;
height: 500px;
border: 1px solid palevioletred;
}
.vitural-container {
width: 100%;
height: 100%;
overflow: auto;
}
.vitural-list-item {
width: 100%;
height: 100px;
box-sizing: border-box;
border: 1px solid #333;
text-align: center;
line-height: 100px;
font-size: 18px;
font-weight: 700;
}
</style>
<body>
<div class="container">
<div class="vitural-container">
<div class="vitural-list">
<!-- 动态渲染 -->
<!-- <div class="vitural-list-item"></div> -->
</div>
</div>
</div>
<script>
class VituralList {
constructor(containerSelector, listSelector) {
this._state = {
dataSource: [], // 源数据
itemHeight: 100, // 子项目高度
viewHeight: 0, // 渲染列表高度
maxCount: 0, // 列表渲染子项目个数
};
this._startIndex = 0; // 开始索引
this._endIndex = 0; // 结束索引
this._renderList = []; // 渲染的列表
this._scrollStyle = {}; // 列表滚动时的样式
this._$container = document.querySelector(containerSelector); // 外层盒子
this._$list = document.querySelector(listSelector); // 渲染列表的盒子
}
init() {
this.addData();
// 先计算 endIndex,可以通过 startIndex + maxCount(viewHeight/itemHeight)来获得,那么就需要先获取viewHeight
this._state.viewHeight = this._$container.offsetHeight;
// 整除之后,底下漏出来半个也要显示出来
this._state.maxCount = Math.ceil(this._state.viewHeight / this._state.itemHeight) + 1;
this.render();
this.bindEvent();
}
computedEndIndex() {
const end = this._startIndex + this._state.maxCount;
this._endIndex = this._state.dataSource[end] ? end : this._state.dataSource.length;
// 如果滑动到底部就添加数据,模拟分页效果
if (this._endIndex >= this._state.dataSource.length) {
this.addData();
}
}
// 计算渲染的列表,截取开始索引和结束索引之间的数据
computedRenderList() {
this._renderList = this._state.dataSource.slice(this._startIndex, this._endIndex);
}
// 计算列表的高度和偏移量,当滑动到底部时,渲染列表的高度应该根据数据源重新计算,否则右侧滚动条会一直在底部
computedScrollStyle() {
this._scrollStyle = {
height: `${
this._state.itemHeight * (this._state.dataSource.length - this._startIndex)
}px`,
transform: `translate3d(0, ${this._startIndex * this._state.itemHeight}px, 0)`,
};
}
render() {
this.computedEndIndex(); // 计算结束索引
this.computedRenderList(); // 计算渲染列表
this.computedScrollStyle(); // 计算滚动样式
const template = this._renderList.map((i) => `<div class='vitural-list-item'>${i}</div>`).join('')
this._$list.innerHTML = template; // 动态渲染列表内容
this._$list.style.height = this._scrollStyle.height; // 渲染列表的高度
this._$list.style.transform = this._scrollStyle.transform; // 渲染列表的偏移量
}
// 添加数据,一次添加十条,相当于分页
addData() {
for (let i = 1; i <= 10; i++) {
const len = this._state.dataSource.length;
this._state.dataSource.push(len + 1);
}
}
handleScroll() {
const { scrollTop } = this._$container; // 拖动滚动条时可以拿到 container 的 scrollTop值
this._startIndex = Math.floor(scrollTop / this._state.itemHeight); // 计算当前的开始索引
this.render(); // 重新渲染
}
bindEvent() {
this._$container.addEventListener('scroll', () => {
this.handleScroll();
})
}
}
const vituralList = new VituralList('.vitural-container', '.vitural-list')
vituralList.init()
</script>
</body>
</html>
原文章:
https://zhuanlan.zhihu.com/p/444778554
https://blog.csdn.net/weixin_48403384/article/details/123325706
https://www.bilibili.com/video/BV1wh4y1y7TM/?spm_id_from=333.337.search-card.all.click&vd_source=8b94f1b9378a39bec7160c02820cc3a7