•
阅读时长 2 分钟

固定高度虚拟列表应用

目录

列表有一万条数据,不代表要同时创建一万个 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。滚动事件更新不需要顺手去测量所有节点,这会把省下来的成本又加回来。

虚拟列表也有使用限制:浏览器全文查找找不到未挂载的内容,键盘焦点和辅助技术需要额外处理。普通短列表直接渲染更省事,数据多了并且确实有节点或渲染压力,再引入虚拟化。