本文内容来自于网文,如有侵权,可联系删除
什么是虚拟列表
长列表:是一次性将全部数据以 DOM 的形式渲染到页面上,即使列表项超出了当前屏幕可显示的数量。 缺点:数据量较大时,无疑会占用过多内存,导致各种性能问题,如dom 节点复杂时渲染慢,卡顿等现象。
虚拟列表:只会展示屏幕可视区和屏幕可视区外部上下偏移区的列表项。不增加列表数量,滚动时通过改变这些列表项的内容及位置复用 DOM 元素,达到长列表一样的展示效果。
如图,对比常规滚动和虚拟滚动列表的构成,常规滚动列表是一次性载入全部列表项,虚拟滚动则一直渲染一定的列表项,从而提升渲染性能。
为什么要使用虚拟列表
为了解决长列表带来的性能问题,在数据量较多时出现的卡顿,渲染慢的等现象,一般会采用分片渲染的方式。 切片渲染:
- 分页, 容易打断用户看数据的连续性
- 滚动加载 ,滚动加载能保证数据的连续性,切换方式平和,感知小,但是整体的切片数量会不断叠加,性能下降
- 虚拟列表, 有些特定情况下不能采用分页或懒加载实现时,如ep 的项目需求看板等页面。
下面我们来看一个普通长列表渲染所需要的时间:
假设我们要点击按钮,往页面上插入10000 条数据,对于简单的dom 节点 ,只渲染一个span:
function List() {
const [data, setData] = useState([]);
const onCreateDOMs = () => {
const array = [];
const renderSimpleItem = (i) => {
return `
<div key=${i} className="item">
<span>编号:${i}</span>
</div>`;
};
for (var i = 0; i < COUNT; i++) {
array.push(renderSimpleItem(i));
}
setData(array);
};
return (
<div className="container">
<button onClick={onCreateDOMs}>创建dom元素</button>
<div dangerouslySetInnerHTML={data.length ? { __html: data.join(' ') } : { __html: '' }} />
</div>
);
}
export default List;
通过Chrome
的Performance
工具来详细的分析这段代码从点击按钮到渲染出来的所花费的时间:
稍微父复杂一点的dom 元素所花费的时间:
const renderItem = (i) => {
return `
<div key=${i} className="item">
<span>编号:${i}</span>
<p>#${i} 我是一个p标签,我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签</p>
<p>我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签我是一个p标签</p>
<img src="<https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fimg.jj20.com%2Fup%2Fallimg%2Ftp09%2F210F2130512J47-0-lp.jpg&refer=http%3A%2F%2Fimg.jj20.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1656403600&t=d8ac70744fac3cd1110ce63714b11ee9>"/>
</div>`;
};
复制代码
Loading :加载的时间
Scripting:执行 js 代码所花费的时间
Rendering: 渲染dom 元素所花费的时间
Painting: dom 元素绘制所需要的时间
System: 操作系统所花费的时间
Idel: 浏览器的空闲时间
由此可见主要的时间是花费在渲染阶段,在仅仅是简单dom 就需要花费 2.s 左右,当dom 节点随着业务变复杂时,所需要花费的时间会更久。为了解决这类问题,可以采用虚拟列表来进行优化。
虚拟列表分为定高和不定高的场景,首先来看看定高的场景:
滚动容器元素:一般情况下,滚动容器元素是 window 对象。或是某个元素(div)能在内部产生横向或者纵向的滚动的这个元素。
可滚动区域:滚动容器元素的内部内容区域。假设有 100 条数据,每个列表项的高度是 50,那么可滚动的区域的高度就是 100 * 50。
可视区域:滚动容器元素的视觉可见区域。一般容器元素是 window 对象,可视区域就是浏览器的视口大小;假设容器元素是某个 div ,其高度是 500,那么可视区域就是设置高度为500的区域。
虚拟列表的核心就在于通过计算出startIndex 和endIndex ,只展示视口以内的元素,来提高渲染性能。
高度固定的虚拟列表
定义的dom 结构如下:
scroll-container 为可滚动的区域
list-container 设置其高度 ,position:relative
list-item 列表的每一项,设置position: absolute ,通过设置其向上平移的值 translateY 来模拟真实的位置
<div style={{ height: containerHeight }} className="scroll-container" onScroll={handleScroll} ref={ref}>
<div style={{ height: totalSize }} className="list-container">
{list?.map((item) => {
return (
<div
style={{
transform: `translateY(${item.top}px)`,
}}
key={item.item.id}
className="list-item"
>
{renderItem(item.item)}
</div>
);
})}
</div>
</div>
在滚动后就需要 通过updateVisibleItems 来计算当前索引位置了
const updateVisibleItems = useCallback(
(e) => {
const pool = [];
const scrollTop = e ? Math.abs(e.target.scrollTop) : 0;
const scroll = {
//滚动开始和滚动结束位置
start: scrollTop,
end: scrollTop + ref.current.clientHeight, // 滚动距离加上容器高度
};
// 避免白屏加入buffer
scroll.start -= buffer;
scroll.end += buffer;
let startIndex = Math.floor(scroll.start / itemHeight); // 开始位置 四舍五入取最小整数
let endIndex = Math.ceil(scroll.end / itemHeight); // 结束位置 四舍五入取最大整数
if (startIndex < 0) {
startIndex = 0;
}
if (endIndex > data.length) {
endIndex = data.length;
}
for (let index = startIndex; index < endIndex; index++) {
let viewItem = {
item: data[index],
top: index * itemHeight, // translateY
};
pool.push(viewItem);
}
setList(pool);
},
[buffer, data, itemHeight],
);
const handleScroll = (e) => {
updateVisibleItems(e);
};
定高的虚拟列表实现步骤:
- 计算 totalSize: 列表的高度固定,所以列表的高度可以根据 列表项的总和 * 列表项高度得出
totalSize= data.length * itemHeight 2. 获取滚动的高度 start e.target.scrollTop end : start + ref.current.clientHeight 3. 计算startIndex 和 endIndex ,得到scroll start ,end 之后,分别除以列表每项的高度即可得到截取所需的开始和结束索引值,这里需要注意对开始索引值向下取整,对结束索引值向上取整。 4. 获取实际渲染list 列表 截取startIndx和endIndex之前的元素,给每项设置top值也就是 translateY 的值 5. 为了滚动时减少白屏的情况,我们会在可视区的上下增加偏移的区域,因此在计算滚动开始和结束位置时要分别减去和加上偏移的距离 buffer
相对于固定高度的列表,高度不固定时的难点是在于无法提前得知列表的高度。 需要父子组件来配合实现,子组件渲染之后,通知父组件来更新实际dom元素的高度,并触发父组件重新渲染数据。
高度不固定的虚拟列表
dom 结构如下
// 父组件
<div
style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}
className="scroll-container"
ref={ref}
onScroll={handleScroll}
>
<div style={{ height: totalSize }} className="infinite-list" />
<div
className="infinite-list"
style={{
transform: `translateY(${startOffset}px)`,
}}
>
{visibleData?.map((item, index) => {
return (
<Item
key={index}
updateSize={updateSize}
index={startIndex + index}
renderItem={() => renderItem(item)}
item={item.item}
/>
);
})}
</div>
</div>
// 子组件 Item
class Item extends Component {
componentDidMount() {
this.props.updateSize(this.node, this.props.index);
}
render() {
return (
<div
ref={(node) => {
this.node = node;
}}
>
{this.props.renderItem()}
</div>
);
}
}
// 初始化positions useEffect(() => { const initPositions = () => { const arr = data.map((item, index) => { return { index, height: estimatedItemSize, top: index * estimatedItemSize, bottom: (index + 1) * estimatedItemSize, }; }); setPositions(arr); }; initPositions(); }, []);
// 子组件渲染完成之后调用的方法
const updateSize = (node, index) => {
const rect = node.getBoundingClientRect();
let height = rect?.height || 0;
let oldHeight = positions[index]?.height;
let dValue = oldHeight - height;
if (dValue) {
positions[index].bottom = positions[index].bottom - dValue;
positions[index].height = height;
}
};
// 更新索引和偏移量 const handleScroll = (e) => { const scrollTop = e ? Math.abs(e.target.scrollTop) : 0; // 更新开始和结束索引 查找 bottom > scrollTop 的 const start = binarySearch(positions, scrollTop || 0); const end = binarySearch(positions, scrollTop + ref.current.clientHeight || 0);
setStartIndex(start);
setEndIndex(end);
// 更新偏移量
updateStartOffset();
};
const visibleData = useMemo(() => { let start = startIndex - aboveCount; let end = endIndex + belowCount; return data.slice(start, end); }, [startIndex, aboveCount, endIndex, belowCount, data]);
高度不固定时的虚拟列表实现步骤
1. 初始化positions ,根据预估高度初始化列表元素的信息
2. 计算列表的总高度 const totalSize = positions\[positions.length - 1\]?.bottom;
3. 子组件渲染完成之后 调用 updatesize 方法来更新实际的位置信息
4. 计算startIndex 和endIndex : 由于position是有序的可以利用二分查找来找StartIndex和EndIndex
5. 获取实际的List 截取startIndex 和endIndex 之前的数据为list 6.aboveCount ,belowCount 是为解决白屏加入的缓冲区域
> 本文使用 [文章同步助手](https://juejin.cn/post/6940875049587097631) 同步