虚拟列表
基础知识储备
js操作要比dom渲染快的多
浏览器dom渲染流程如下
构建DOM树-->样式计算-->创建布局树-->分层绘制-->栅格化(raster)操作-->合成与显示
从过程来看,也比js操作要复杂许多
下面我们来从实际代码运行的角度 来进行时间分析
const total = 100000;
const now = Date.now();
document.write('<ul id="container"></ul>');
const ul = document.getElementById("container");
for (let i = 0; i < total; i++) {
const li = document.createElement("li");
li.innerText = ~~(Math.random() * total);
ul.appendChild(li);
}
console.log("JS运行时间:", Date.now() - now);
setTimeout(() => {
console.log("总耗时:", Date.now() - now);
}, 0);
我们对十万条记录进行循环操作,JS的运行时间为200ms左右(和电脑性能有关),还是蛮快的,但是最终渲染完成后的总时间确是5s左右。(和电脑性能有关)
简单说明一下,为何两次console.log的结果时间差异巨大,并且是如何简单来统计JS运行时间和总渲染时间:
- 在 JS 的Event Loop中,当JS引擎所管理的执行栈中的事件以及所有微任务事件全部执行完后,才会触发渲染线程对页面进行渲染
- 第一个console.log的触发时间是在页面进行渲染之前,此时得到的间隔时间为JS运行所需要的时间
- 第二个console.log是放到 setTimeout 中的,它的触发时间是在渲染完成,在下一次Event Loop中执行的
依照两次console.log的结果,可以得出结论:
对于大量数据渲染的时候,JS运算并不是性能的瓶颈,性能的瓶颈主要在于渲染阶段。
虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。
简单实现
我们先看一下下方的列表渲染图
从图中可以看出,我们可以将列表分成三块区域:可视区,缓冲区和虚拟区。
我们主要针对可视区和缓冲区进行渲染。 先罗列一下相关数据:
数据列表:list 数组,包含列表的数据总数,比如渲染一个100000条的列表数据
容器高度:clientHeight
每项高度:itemHeight
占位区高度:listHeight
距离顶部高度:scrollTop 渲染区域的计算点:其实我们渲染的数据只是可视区和缓冲区,我们可以利用slice对list进行截取,所以在我们还需要知道:
- 索引的起始位置:start
- 索引的结束位置:end
- 缓冲个数:bufferCount
- 需要渲染的节点数量: renderCount(可视区能渲染几个节点)
// 渲染节点的数量 = 容器高度/子列表高度(向上取整)+ 缓冲个数
renderCount = Math.ceil(clientHeight / itemHeight) + bufferCount;
// 起始位置
start = Math.floor(scrollTop / itemHeight)
// 结束位置
end = start + renderCount + 1;
// 渲染的数据
data = list.slice(start, end);
实现虚拟列表的代码如下
import React, { useRef, useState, useEffect, useCallback } from "react";
import throttle from "lodash/throttle";
/**
* @param {object} props
* @param {number} [props.height] 容器高度
* @param {number} [props.rowCount] 多少条数据
* @param {number} [props.rowHeight] 每列数据高度,固定高度
* @param {number} [props.bufferRows] 缓冲值,既渲染可视区域内额外的列表条数
* @param {Function} [props.rowRenderer] 列表内容渲染函数
*/
function VirtualList(props) {
// 可见区域的数据
const [visibleRows, setVisibleRows] = useState([]);
// 可见区域起始数据的 startIndex
const startIndex = useRef(0);
// 可见区域结束数据的 endIndex
const endIndex = useRef(0);
// 可见区域的条数
const visibleCount = useRef(0);
// 缓存列表
const cache = useRef([]);
// 外部容器Ref引用
const wrapperRef = useRef();
const { height, rowCount, rowHeight, bufferRows = 4, rowRenderer } = props;
// componentDidMount
useEffect(() => {
// 计算可视区域内可渲染的列表个数
visibleCount.current = Math.ceil(height / rowHeight) + bufferRows;
// 结束数据的 endIndex
endIndex.current = startIndex.current + visibleCount.current;
}, [height, rowHeight, bufferRows]);
useEffect(() => {
store();
calculateVisibleRows();
}, [rowCount, rowHeight]);
const store = useCallback(() => {
if (cache.current.length > 0) {
cache.current = [];
}
// 使用绝对定位显示列表数据
// 记录每条数据的top,并缓存下来
for (let i = 0; i < rowCount; i++) {
const top = i * rowHeight;
cache.current.push({
index: i,
top,
style: {
position: "absolute",
top,
width: "100%"
}
});
}
}, [rowCount, rowHeight]);
// 计算当前可见区域的数据
const calculateVisibleRows = () => {
const visibleRows = cache.current.slice(
startIndex.current,
endIndex.current
);
setVisibleRows(visibleRows);
};
// 滚动,计算 startIndex 和 endIndex
const calculatBoundaryIndex = (scrollTop) => {
const startIdx = Math.floor(scrollTop / rowHeight);
startIndex.current = startIdx;
endIndex.current = startIndex.current + visibleCount.current;
};
// 滚动,计算可视区域列表数据
const handleScroll = () => {
const { scrollTop } = wrapperRef.current;
calculatBoundaryIndex(scrollTop);
calculateVisibleRows();
};
return (
<div
ref={wrapperRef}
style={{ height, overflow: "auto", position: "relative" }}
onScroll={throttle(handleScroll, 50)}
>
{/* 内部容器,高度为列表总数和单列高度相乘 */}
<div style={{ height: rowCount * rowHeight }}>
{visibleRows.map((item) => rowRenderer(item))}
</div>
</div>
);
}
export default VirtualList;
如下使用该组件
import VirtualList from "./VirtualList";
export default function App() {
const data = [...Array(10000)].map((value, index) => index);
const renderCell = ({ index, style }) => {
return (
<div
key={index}
style={{
display: "flex",
alignItems: "center",
boxSizing: "border-box",
height: "48px",
borderBottom: "1px solid #e0e0e0",
...style
}}
>
{`虚拟列表测试数据-${data[index]}`}
</div>
);
};
return (
<div className="App">
<VirtualList
height={300}
rowCount={10000}
rowHeight={48}
rowRenderer={renderCell}
/>
</div>
);
}