列表有一万条数据,不代表要同时创建一万个 DOM 节点。用户能看到的只有当前窗口,再加前后几行,其他行可以先不渲染。
固定行高时,这个计算比较直接:scrollTop 除以行高得到起始行,视口高度决定结束行。外层保留完整列表的高度,滚动条才不会缩成只有十来行。
创建一个空目录,安装 React 和打包工具。示例不用 JSX,省去 Babel 配置,组件逻辑和 JSX 的写法是一样的。
npm init -y
npm install --save-exact react@18.3.1 react-dom@18.3.1
npm install --save-dev --save-exact webpack@5.99.9
创建 build.cjs:
const webpack = require("webpack");
const path = require("path");
webpack(
{
mode: "development",
entry: path.resolve("main.js"),
output: { path: path.resolve("dist"), filename: "main.js" },
devtool: "source-map",
},
(error, stats) => {
if (error || stats.hasErrors()) {
console.error(error || stats.toString("errors-only"));
process.exitCode = 1;
}
},
);
创建 index.html:
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8" /><meta name="viewport" content="width=device-width" />
<title>React 实验</title>
<div id="root"></div>
<script src="./dist/main.js"></script>
</html>
把下面的代码存为 main.js,运行 node build.cjs,然后用浏览器打开 index.html。这里用开发构建和根节点 StrictMode,观察次数时要把这两个条件算进去。
import React, {
useState,
useRef,
useEffect,
useMemo,
useContext,
memo,
StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
const e = React.createElement;
window.lab = { effects: [], contexts: { stable: 0, unstable: 0 }, measurements: [] };
function windowRange(top, viewport, rowHeight, count, overscan = 3) {
if (rowHeight <= 0 || viewport < 0 || count < 0)
throw new Error("invalid dimensions");
const first = Math.min(count, Math.max(0, Math.floor(top / rowHeight)));
return {
start: Math.max(0, first - overscan),
end: Math.min(
count,
Math.ceil((Math.max(0, top) + viewport) / rowHeight) + overscan,
),
};
}
function VirtualList() {
const [top, setTop] = useState(0);
const range = windowRange(top, 240, 32, 10000, 3);
return e(
"section",
null,
e("h2", null, "固定高度列表"),
e(
"div",
{
id: "virtual",
style: { height: 240, overflow: "auto" },
onScroll: (event) => setTop(event.currentTarget.scrollTop),
},
e(
"div",
{ style: { height: 10000 * 32, position: "relative" } },
Array.from({ length: range.end - range.start }, (_, i) => i + range.start).map(
(index) =>
e(
"div",
{
key: index,
className: "row",
"data-index": index,
style: {
height: 32,
position: "absolute",
top: index * 32,
left: 0,
right: 0,
},
},
"第 " + index + " 行",
),
),
),
),
);
}
createRoot(document.querySelector("#root")).render(e(StrictMode, null, e(VirtualList)));
视口 240px,每行 32px,前后多渲染 3 行。顶部渲染索引 0 到 10,共 11 行;滚到底部,最后一行的索引是 9999,页面里的 .row 仍然不到 20 个。
中间占位容器高度是 10000 × 32。每一行用绝对定位放到 index × 32 的位置,没有因为前面的 DOM 被移除就贴到顶部。
几个容易漏掉的细节
行高需要真的固定。加 padding、边框时注意 box-sizing,文字换行也可能让实际高度超过设定值。示例只显示固定的一行文字;如果内容高度不同,就需要测量、累计偏移和更新滚动位置,不能继续只做除法。
overscan 用来减轻快速滚动时的空白,太大又会增加节点。这里的 3 只是小例子的取值,不是通用最优值。
示例数据不排序,所以用索引作 key。业务数据会插入、排序时,应该用稳定的业务 id。滚动事件更新不需要顺手去测量所有节点,这会把省下来的成本又加回来。
虚拟列表也有使用限制:浏览器全文查找找不到未挂载的内容,键盘焦点和辅助技术需要额外处理。普通短列表直接渲染更省事,数据多了并且确实有节点或渲染压力,再引入虚拟化。