跳转至

虚拟表格(大列表窗口化渲染)

数据大到全量渲染会卡时的形状:固定行高的滚动容器里,只渲染视口内(及边缘附近)的行,其余高度用 spacer 撑住,滚动时按 scrollTop 重算切片。几千行想一屏滚到底、不分页也不卡,就是它。

试这几下:

  • 拖滚动条快速滚 —— 顶部计数实时变:5000 行里 DOM 始终只有约 30 个行节点
  • 搜索「研发」或点姓名 / 金额表头 —— 过滤后总高重算、自动回顶,窗口化照常工作
  • 点任意行高亮,滚走再滚回来 —— 高亮还在(状态挂在稳定 id 上,不挂在 DOM 上)
  • 勾选关闭虚拟化 —— 5000 行全量进 DOM,警示条报出本次渲染耗时,滚动明显变卡

规矩

  • 行高必须固定ROW_HEIGHT):scrollTop → 起始下标 的窗口化算术全靠它;再配少量 OVERSCAN(demo 上下各 8 行)消除快速滚动的白屏
  • 上下 spacer 行撑起总滚动高度,表头 position: sticky 不随行滚走;过滤后行数变了,spacer 跟着重算
  • 总行数 + 当前渲染区间常驻显示,「只渲染了一小片」随时可验证
  • 不为窗口化引新依赖:蓝本模板和 demo 都手写窗口化(几行算术);真实版遇到行高可变、横向虚拟化等复杂需求,再换 @tanstack/react-virtual
  • 真实数据接法:从 Repository 一次取一大页(或流式)喂进数组,窗口化只管渲染层

蓝本:add-virtual-table.md(open-dashboard @ aa9815f,MIT,Invariants 已消化进上面「规矩」)

demo 源码:assets/demo-virtual-table.html(自包含、未压缩)
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>形状 demo:虚拟表格</title>
<!--
  demo-virtual-table.html —— 「虚拟表格」形状的最小可玩实现(蓝本:open-dashboard 的 add-virtual-table + table-virtual.tsx 模板)。
  复现的不变量:
  - 行高固定(ROW_HEIGHT):scrollTop → 起始下标的窗口化数学全靠它;保留少量 OVERSCAN(上下各 8 行)
  - 上下 spacer 行撑起总滚动高度(过滤后行数变了随之重算),表头 position: sticky 不随行滚走
  - 顶部常驻总行数 + 当前渲染区间与 DOM 行节点数,随滚动实时变化,随时可验证「只渲染了一小片」
  - 零第三方依赖 —— 窗口化自实现(几行算术),与蓝本一致;另加「关闭虚拟化」对照开关演示全量渲染的代价
  运行时:/vendor 的 React 18 UMD + htm(免构建),样式共用同级 demo.css。
-->
<link rel="stylesheet" href="demo.css">
<style>
  th.sortable { cursor: pointer; user-select: none; }
  th.sortable:hover { color: var(--fg); }
  .arrow { font-size: 10px; }
  .viewport { height: 400px; overflow: auto; background: var(--card);
              border: 1px solid var(--border); border-radius: 10px; }
  .vtable { width: 100%; border-collapse: separate; border-spacing: 0; }
  .vtable thead th { position: sticky; top: 0; z-index: 2; background: var(--card);
                     text-align: left; font-size: 12px; color: var(--muted); font-weight: 500;
                     padding: 9px 12px; border-bottom: 1px solid var(--border); }
  .vtable tbody td { padding: 0 12px; border-bottom: 1px solid var(--border);
                     white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .vtable tbody tr.row { cursor: pointer; }
  .vtable tbody tr.row:hover td { background: color-mix(in srgb, var(--primary) 7%, var(--card)); }
  .vtable tbody tr.sel td { background: color-mix(in srgb, var(--primary) 15%, var(--card)); }
  .meter { margin-bottom: 8px; font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
  .meter b { color: var(--fg); font-weight: 650; }
  .toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 13px;
            color: var(--muted); cursor: pointer; white-space: nowrap; }
</style>
</head>
<body>
<div id="root"></div>
<script src="/vendor/react.production.min.js"></script>
<script src="/vendor/react-dom.production.min.js"></script>
<script src="/vendor/htm.umd.js"></script>
<script>
"use strict";
const { useState, useMemo, useEffect, useRef } = React;
const html = htm.bind(React.createElement);

const ROW_COUNT = 5000;
const ROW_HEIGHT = 36;        // 不变量:行高固定,窗口化数学全靠它
const VIEWPORT_HEIGHT = 400;  // 滚动容器高度,与 .viewport 的 height 一致
const OVERSCAN = 8;           // 可视区外上下各多渲染 8 行,消除快速滚动的白屏

const SURNAMES = ["陈", "李", "王", "赵", "周", "吴", "郑", "冯", "褚", "卫", "蒋", "沈", "韩", "杨", "朱", "秦", "许", "何", "吕", "施"];
const GIVENS = ["禾", "慕白", "小满", "青", "芷", "限", "舟", "霁", "遂", "疏", "声", "砚", "澈", "湜", "望舒", "云谏", "知行", "以宁"];
const DEPTS = ["研发部", "市场部", "运营部", "财务部", "设计部"];

// 由下标确定性地推导 5000 行数据(无随机数,刷新后完全一致)
const ROWS = Array.from({ length: ROW_COUNT }, (_, i) => ({
  id: i + 1,
  name: SURNAMES[i % SURNAMES.length] + GIVENS[(i * 7) % GIVENS.length],
  email: `user${String(i + 1).padStart(4, "0")}@corp.cn`,
  dept: DEPTS[(i * 13) % DEPTS.length],
  amount: 1000 + ((i * 37) % 9000) + (i % 100),
}));

const fmt = new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", maximumFractionDigits: 0 });

function App() {
  const [q, setQ] = useState("");
  const [sort, setSort] = useState({ by: "id", dir: "asc" });
  const [virtual, setVirtual] = useState(true); // 对照开关:false = 全量渲染
  const [scrollTop, setScrollTop] = useState(0);
  const [selId, setSelId] = useState(null);     // 行高亮挂在稳定 id 上,不挂在 DOM 上
  const [renderMs, setRenderMs] = useState(null);
  const [toast, setToast] = useState("");
  const viewportRef = useRef(null);
  const toggleT = useRef(0);

  useEffect(() => {
    if (!toast) return;
    const t = setTimeout(() => setToast(""), 2600);
    return () => clearTimeout(t);
  }, [toast]);

  const filtered = useMemo(() => {
    const kw = q.trim().toLowerCase();
    const list = kw
      ? ROWS.filter(r => r.name.includes(kw) || r.email.includes(kw) || r.dept.includes(kw))
      : ROWS;
    const mul = sort.dir === "asc" ? 1 : -1;
    return [...list].sort((a, b) => (a[sort.by] < b[sort.by] ? -1 : a[sort.by] > b[sort.by] ? 1 : 0) * mul);
  }, [q, sort]);

  // 过滤 / 排序变化后回到顶部,避免 scrollTop 指向已不存在的区间
  useEffect(() => {
    if (viewportRef.current) viewportRef.current.scrollTop = 0;
    setScrollTop(0);
  }, [q, sort]);

  // ===== 窗口化核心:scrollTop → 可视切片 =====
  const total = filtered.length;
  const visibleCount = Math.ceil(VIEWPORT_HEIGHT / ROW_HEIGHT);
  const start = virtual
    ? Math.min(Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN), Math.max(0, total - 1))
    : 0;
  const end = virtual ? Math.min(total, start + visibleCount + OVERSCAN * 2) : total;
  const slice = filtered.slice(start, end);
  const topPad = start * ROW_HEIGHT;              // 上 spacer:撑起已滚过的高度
  const bottomPad = (total - end) * ROW_HEIGHT;   // 下 spacer:撑起未到达的高度

  const toggleSort = by =>
    setSort(s => ({ by, dir: s.by === by && s.dir === "asc" ? "desc" : "asc" }));

  const toggleVirtual = () => {
    toggleT.current = performance.now();
    const next = !virtual;
    setVirtual(next);
    setToast(next ? "已恢复窗口化渲染" : `警告:已关闭虚拟化,${total} 行全部进入 DOM`);
  };
  // 记一次切换后的渲染耗时,作为对照组的性能证据
  useEffect(() => {
    if (toggleT.current) {
      setRenderMs(Math.round(performance.now() - toggleT.current));
      toggleT.current = 0;
    }
  }, [virtual]);

  const SortTh = ({ by, right, children }) => html`
    <th class=${"sortable" + (right ? " num" : "")} onClick=${() => toggleSort(by)}>
      ${children} <span class="arrow">${sort.by === by ? (sort.dir === "asc" ? "▲" : "▼") : ""}</span>
    </th>`;

  return html`
    <div class="page">
      <header class="head">
        <div>
          <h1>交易流水(5000 行)</h1>
          <p class="sub">滚动看顶部计数 / 搜索过滤 / 点表头排序 / 点行高亮 / 对照开关关闭虚拟化</p>
        </div>
      </header>

      <div class="toolbar">
        <input class="grow" placeholder="搜索姓名、邮箱或部门…" value=${q}
               onChange=${e => setQ(e.target.value)} />
        <label class="toggle">
          <input type="checkbox" checked=${!virtual} onChange=${toggleVirtual} />
          关闭虚拟化(对照)
        </label>
      </div>

      ${!virtual && html`
        <div class="alert danger" style=${{ marginBottom: "10px" }}>
          <p>对照组:虚拟化已关闭,${total} 个行节点全部进入 DOM${
            renderMs != null ? `(本次全量渲染约 ${renderMs} ms)` : ""
          },滚动与重渲染明显变卡 —— 这正是窗口化要解决的问题。</p>
        </div>`}

      <div class="meter">
        共 <b>${ROW_COUNT}</b> 行${q.trim() ? html` · 匹配 <b>${total}</b> 行` : ""} ·
        当前渲染第 <b>${total === 0 ? 0 : start + 1}${end}</b> 行
        (<b>${slice.length}</b> 个 DOM 行节点)
      </div>

      <div class="viewport" ref=${viewportRef}
           onScroll=${e => setScrollTop(e.currentTarget.scrollTop)}>
        <table class="vtable">
          <thead>
            <tr>
              <${SortTh} by="id">ID<//>
              <${SortTh} by="name">姓名<//>
              <th>邮箱</th>
              <th>部门</th>
              <${SortTh} by="amount" right>金额<//>
            </tr>
          </thead>
          <tbody>
            ${topPad > 0 && html`
              <tr aria-hidden="true" style=${{ height: topPad + "px" }}><td colSpan="5"></td></tr>`}
            ${slice.map(r => html`
              <tr key=${r.id} class=${"row" + (selId === r.id ? " sel" : "")}
                  style=${{ height: ROW_HEIGHT + "px" }}
                  onClick=${() => setSelId(id => (id === r.id ? null : r.id))}>
                <td style=${{ color: "var(--muted)", fontVariantNumeric: "tabular-nums", width: "70px" }}>${r.id}</td>
                <td style=${{ fontWeight: 550 }}>${r.name}</td>
                <td style=${{ color: "var(--muted)" }}>${r.email}</td>
                <td>${r.dept}</td>
                <td class="num">${fmt.format(r.amount)}</td>
              </tr>`)}
            ${total === 0 && html`<tr><td class="empty" colSpan="5">没有匹配的行</td></tr>`}
            ${bottomPad > 0 && html`
              <tr aria-hidden="true" style=${{ height: bottomPad + "px" }}><td colSpan="5"></td></tr>`}
          </tbody>
        </table>
      </div>

      <footer class="pager">
        <span>行高固定 ${ROW_HEIGHT}px · overscan 上下各 ${OVERSCAN} 行 · 点击行高亮,再点取消</span>
        <span>${selId ? `已高亮 #${selId}` : "未高亮任何行"}</span>
      </footer>

      ${toast && html`<div class="toast">${toast}</div>`}
    </div>`;
}

ReactDOM.createRoot(document.getElementById("root")).render(html`<${App} />`);
</script>
</body>
</html>