React 基础体系 · 第 39/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 表格与虚拟列表:列模型、窗口、动态高度和交互

表格和列表都可以抽象为“按行组织的数据集合”,但它们面对的问题不同:

  • 列表通常只关心一维的行位置。
  • 表格还要处理列定义、列宽、表头与行对齐、横向滚动、固定列和单元格交互。
  • 虚拟列表则改变了渲染策略:数据可以有上万行,但 DOM 中只保留视口附近的一小部分行。

因此,虚拟表格不是简单地给 <table> 加上 overflow: auto。它至少需要解决四个相互影响的问题:

  1. 如何用稳定的列模型描述列及其宽度。
  2. 如何根据滚动位置计算当前的渲染窗口
  3. 如何在行高固定或动态变化时保持位置、滚动条和交互正确。
  4. 如何让被卸载和重新挂载的行仍然支持选择、编辑、键盘操作和无障碍语义。

下文先建立这些机制,再给出一个可运行的 React + TypeScript 示例。


一、先区分“数据行”“布局行”和“DOM 行”

假设有 100,000 条数据:

type User = {
  id: string;
  name: string;
  email: string;
  department: string;
};

逻辑上有 100,000 个数据行,但虚拟列表不会创建 100,000 个 DOM 行。它通常维护三种不同的集合:

  • 数据行集合:完整数据,或分页、流式加载得到的数据。
  • 布局行集合:每一行的高度、顶部位置、是否位于窗口内等。
  • DOM 行集合:当前实际挂载的少量 React 元素。

这三者不能混为一谈。一个数据行离开窗口后,它的 DOM 可能被卸载,但数据和必要的布局信息仍然必须保留。

如果滚动容器高度为 H,平均行高为 h,视口高度为 V,不使用虚拟化时大约需要创建:

Ndom=H/hN_{\text{dom}} = H / h

个行节点。使用虚拟化后,DOM 行数主要由视口和预渲染区域决定:

Nwindow(V+2O)/hN_{\text{window}} \approx (V + 2O) / h

其中 O 是上下两侧的 overscan,即额外渲染区域。数据量增加并不会等比例增加 DOM 数量,但计算每个可见行的布局位置仍然需要一个可控的数据结构。


二、列模型:表格布局的唯一事实来源

2.1 列模型的职责

列模型(column model)是描述表格列的结构化配置。它至少应包含:

  • 稳定的列标识;
  • 表头内容;
  • 从数据行读取单元格值的方式;
  • 列宽;
  • 是否可排序、筛选、编辑或固定;
  • 单元格渲染器。

一个简单的 TypeScript 定义如下:

type Column<T> = {
  key: string;
  header: React.ReactNode;
  width: number;
  render: (row: T, rowIndex: number) => React.ReactNode;
  pinned?: "left" | "right";
};

width 使用像素值时,列的水平位置可以直接累加计算:

x0=0x_0 = 0

xj+1=xj+wjx_{j+1} = x_j + w_j

其中:

  • w_j 是第 j 列的宽度;
  • x_j 是第 j 列的左边界。

例如:

const columns: Column<User>[] = [
  {
    key: "name",
    header: "姓名",
    width: 160,
    render: (user) => user.name,
  },
  {
    key: "email",
    header: "邮箱",
    width: 280,
    render: (user) => user.email,
  },
  {
    key: "department",
    header: "部门",
    width: 160,
    render: (user) => user.department,
  },
];

总宽度是:

W=j=0m1wjW = \sum_{j=0}^{m-1} w_j

上例的总宽度为:

160+280+160=600160 + 280 + 160 = 600

这个总宽度必须同时用于表头和数据行,否则会出现表头与单元格错位。

2.2 为什么不应让每个单元格独立决定宽度

下面这种写法在普通小表格中可能暂时可用:

<div className="cell">{user.name}</div>
<div className="cell">{user.email}</div>

然后完全交给内容和 CSS 自动布局。但在虚拟表格中,部分行可能没有被挂载,浏览器无法根据所有数据行进行一致的自动布局。某一行被卸载后,列宽可能发生变化,导致:

  • 表头和可见行不对齐;
  • 横向滚动范围不稳定;
  • 行进入窗口时布局跳动;
  • 列虚拟化无法计算哪些列可见。

因此,虚拟表格通常把列宽从内容布局中提取出来,用同一个列模型生成表头和行:

const gridTemplateColumns = columns
  .map((column) => `${column.width}px`)
  .join(" ");
<div
  role="row"
  style={{ display: "grid", gridTemplateColumns }}
>
  {columns.map((column) => (
    <div role="gridcell" key={column.key}>
      {column.render(row, rowIndex)}
    </div>
  ))}
</div>

这里的 key 是 React 识别列身份的标识,不是列的显示名称。列标题可以重复,但列 key 必须稳定且唯一。

2.3 列模型与列虚拟化

只做行虚拟化时,每个可见行仍然渲染全部列。若列数也非常多,例如有几百列,就需要同时对行和列做虚拟化。

二维虚拟化需要分别计算:

  • 行窗口:由 scrollTop 和视口高度决定;
  • 列窗口:由 scrollLeft 和视口宽度决定。

列窗口的计算和行窗口相同。设列前缀和为:

columnPrefix = [0, 160, 440, 600, ...]

当水平滚动位置为 220、视口宽度为 300 时,需要查找满足下式的列:

xj<220+300x_j < 220 + 300

并排除完全位于左侧的列:

xj+1>220x_{j+1} > 220

实际项目中通常还要额外处理:

  • 左固定列和右固定列;
  • 横向滚动层与固定列层之间的遮挡;
  • 表头和数据行共享同一个 scrollLeft
  • 键盘移动到未挂载列时先滚动,再聚焦。

如果只有几十列,优先只做行虚拟化。二维虚拟化会显著增加焦点管理、固定列和测量逻辑的复杂度。


三、窗口:只渲染视口附近的行

3.1 固定高度行的窗口计算

先考虑所有行高度相同的情况。

设:

  • rowHeight = 40
  • scrollTop = 530
  • viewportHeight = 240
  • overscan = 3 行。

第一个进入视口的行索引近似为:

istart=53040=13i_{\text{start}} = \left\lfloor \frac{530}{40} \right\rfloor = 13

视口覆盖的行数为:

24040=6\left\lceil \frac{240}{40} \right\rceil = 6

不考虑 overscan 时,窗口约为 [13, 19);加入上下各 3 行后:

start = max(0, 13 - 3) = 10
end   = min(rowCount, 19 + 3) = 22

因此只渲染第 10 到第 21 行,共 12 行。

行的绝对位置为:

topi=i×rowHeighttop_i = i \times rowHeight

滚动容器内部仍然需要一个总高度为:

Htotal=rowCount×rowHeightH_{\text{total}} = rowCount \times rowHeight

的占位元素。否则浏览器认为内容只有当前十几行,滚动条会过短,用户无法滚到数据末尾。

典型结构是:

滚动容器
└── 总高度占位层
    ├── 可见行 10,top = 400px
    ├── 可见行 11,top = 440px
    └── ...

可见行使用绝对定位,top 由数据索引决定,而不是由当前 DOM 中前一个行的位置决定。这样,卸载中间行不会改变后续行的坐标。

3.2 动态高度行的窗口计算

如果每一行高度不同,就不能用 index * rowHeight

令第 i 行高度为 h_i,其顶部位置为:

topi=k=0i1hktop_i = \sum_{k=0}^{i-1} h_k

底部位置为:

bottomi=topi+hibottom_i = top_i + h_i

一行与视口 [scrollTop, scrollTop + viewportHeight) 相交的条件是:

topi<scrollTop+viewportHeighttop_i < scrollTop + viewportHeight

并且:

bottomi>scrollTopbottom_i > scrollTop

因此,动态高度虚拟列表需要维护高度前缀和:

heights = [40, 64, 40, 96]
prefix  = [0, 40, 104, 144, 240]

第 2 行的顶部是 prefix[2] = 104,总高度是 prefix[4] = 240

scrollTop 查找第一个可见行,不能每次从头扫描全部行,否则每次滚动都可能是 O(n)。常见实现是:

  1. 用估算高度初始化所有行;
  2. 用真实测量结果更新部分行高度;
  3. 维护高度前缀和;
  4. 用二分查找定位窗口起点;
  5. 从起点向后扫描,直到超过视口底部和 overscan 范围。

如果使用普通数组并在每次测量后完整重建前缀和,重建成本是 O(n)。数据量很大时,可以使用 Fenwick Tree 或线段树,把单行高度更新和前缀查询降低到 O(log n)。但树结构会增加实现和调试成本,只有在行数与测量更新频率确实较大时才值得使用。


四、动态高度:估算、测量与位置修正

4.1 为什么动态高度会导致跳动

首次渲染时,列表通常不知道真实高度,只能使用估算高度。例如所有行先按 48px 计算:

估算高度:[48, 48, 48, 48]
估算前缀:[0, 48, 96, 144, 192]

渲染后,浏览器测得真实高度:

真实高度:[48, 80, 48, 120]
真实前缀:[0, 48, 128, 176, 296]

第 3 行原来被放在 144px,测量后应放在 176px。如果用户当前正在查看第 3 行之后的内容,前面的高度变化会把内容向下推 32px

这不是 React 特有的错误,而是坐标系改变的必然结果。实现必须决定如何处理这种变化:

  • 允许视口内容轻微移动;
  • 如果变化发生在当前视口之前,则补偿 scrollTop
  • 使用浏览器的滚动锚定;
  • 在图片、字体等资源稳定后再测量。

4.2 使用 ResizeObserver 获取真实高度

ResizeObserver 可以在元素尺寸变化时通知组件。它适合处理:

  • 文本换行;
  • 异步图片加载;
  • 展开和折叠;
  • 字体加载完成;
  • 浏览器窗口变化导致的列宽变化。

测量结果必须按行的稳定身份保存。对于固定数据集合,可以使用索引;如果支持排序、插入和删除,更安全的是使用 row.id

const measuredHeights = new Map<string, number>();

索引会随着插入和排序改变含义。例如原来的第 10 行被删除后,新的第 10 行并不是原来的第 10 行,旧高度会错误地套用到新数据。

4.3 可运行的动态高度虚拟列表示例

下面的组件使用:

  • CSS Grid 对齐列;
  • ResizeObserver 测量动态行高;
  • 二分查找计算行窗口;
  • 像素 overscan;
  • role="grid"role="row"role="gridcell" 表示网格语义;
  • id 作为 React key
import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type ReactNode,
} from "react";

type User = {
  id: string;
  name: string;
  email: string;
  department: string;
  description: string;
};

type Column<T> = {
  key: string;
  header: ReactNode;
  width: number;
  render: (row: T, rowIndex: number) => ReactNode;
};

type VirtualTableProps<T extends { id: string }> = {
  rows: T[];
  columns: Column<T>[];
  estimatedRowHeight?: number;
  overscanPx?: number;
};

function firstRowEndingAfter(prefix: number[], offset: number): number {
  // 找到第一个满足 prefix[i + 1] > offset 的行 i。
  let low = 0;
  let high = prefix.length - 1;

  while (low < high) {
    const middle = Math.floor((low + high) / 2);

    if (prefix[middle + 1] <= offset) {
      low = middle + 1;
    } else {
      high = middle;
    }
  }

  return Math.min(low, prefix.length - 2);
}

function useElementSize<T extends HTMLElement>(
  elementRef: React.RefObject<T | null>,
) {
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    const element = elementRef.current;
    if (!element) {
      return;
    }

    const observer = new ResizeObserver(([entry]) => {
      const { width, height } = entry.contentRect;

      setSize((previous) => {
        if (previous.width === width && previous.height === height) {
          return previous;
        }
        return { width, height };
      });
    });

    observer.observe(element);
    return () => observer.disconnect();
  }, [elementRef]);

  return size;
}

function VirtualRow<T extends { id: string }>({
  row,
  rowIndex,
  columns,
  gridTemplateColumns,
  top,
  onHeightChange,
}: {
  row: T;
  rowIndex: number;
  columns: Column<T>[];
  gridTemplateColumns: string;
  top: number;
  onHeightChange: (rowId: string, height: number) => void;
}) {
  const rowRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const element = rowRef.current;
    if (!element) {
      return;
    }

    const reportHeight = () => {
      const height = element.getBoundingClientRect().height;
      if (height > 0) {
        onHeightChange(row.id, height);
      }
    };

    reportHeight();

    const observer = new ResizeObserver(reportHeight);
    observer.observe(element);

    return () => observer.disconnect();
  }, [onHeightChange, row.id]);

  const style: CSSProperties = {
    position: "absolute",
    top,
    left: 0,
    right: 0,
    display: "grid",
    gridTemplateColumns,
    minHeight: 40,
    borderBottom: "1px solid #e5e7eb",
    background: "white",
  };

  return (
    <div
      ref={rowRef}
      role="row"
      aria-rowindex={rowIndex + 2}
      style={style}
    >
      {columns.map((column) => (
        <div
          role="gridcell"
          key={column.key}
          style={{
            minWidth: 0,
            padding: "10px 12px",
            overflowWrap: "anywhere",
          }}
        >
          {column.render(row, rowIndex)}
        </div>
      ))}
    </div>
  );
}

export function VirtualTable<T extends { id: string }>({
  rows,
  columns,
  estimatedRowHeight = 48,
  overscanPx = 320,
}: VirtualTableProps<T>) {
  const scrollRef = useRef<HTMLDivElement>(null);
  const [scrollTop, setScrollTop] = useState(0);
  const [viewportHeight, setViewportHeight] = useState(0);
  const [measuredHeights, setMeasuredHeights] = useState(
    () => new Map<string, number>(),
  );

  const gridTemplateColumns = useMemo(
    () => columns.map((column) => `${column.width}px`).join(" "),
    [columns],
  );

  const viewportSize = useElementSize(scrollRef);

  useEffect(() => {
    setViewportHeight(viewportSize.height);
  }, [viewportSize.height]);

  const onScroll = useCallback(
    (event: React.UIEvent<HTMLDivElement>) => {
      setScrollTop(event.currentTarget.scrollTop);
    },
    [],
  );

  const onHeightChange = useCallback((rowId: string, height: number) => {
    setMeasuredHeights((previous) => {
      const oldHeight = previous.get(rowId);

      // 避免 ResizeObserver 造成无意义的 React 更新。
      if (oldHeight !== undefined && Math.abs(oldHeight - height) < 0.5) {
        return previous;
      }

      const next = new Map(previous);
      next.set(rowId, height);
      return next;
    });
  }, []);

  const prefix = useMemo(() => {
    const result = new Array<number>(rows.length + 1);
    result[0] = 0;

    for (let index = 0; index < rows.length; index += 1) {
      const height =
        measuredHeights.get(rows[index].id) ?? estimatedRowHeight;
      result[index + 1] = result[index] + height;
    }

    return result;
  }, [rows, measuredHeights, estimatedRowHeight]);

  const totalHeight = prefix[prefix.length - 1] ?? 0;

  const windowRange = useMemo(() => {
    if (rows.length === 0 || viewportHeight === 0) {
      return { start: 0, end: 0 };
    }

    const startOffset = Math.max(0, scrollTop - overscanPx);
    const endOffset = Math.min(
      totalHeight,
      scrollTop + viewportHeight + overscanPx,
    );

    const start = firstRowEndingAfter(prefix, startOffset);

    let end = start;
    while (end < rows.length && prefix[end] < endOffset) {
      end += 1;
    }

    return { start, end };
  }, [overscanPx, prefix, rows.length, scrollTop, totalHeight, viewportHeight]);

  const visibleRows = rows.slice(windowRange.start, windowRange.end);

  return (
    <div
      ref={scrollRef}
      onScroll={onScroll}
      role="grid"
      aria-rowcount={rows.length + 1}
      aria-colcount={columns.length}
      style={{
        height: 480,
        overflow: "auto",
        position: "relative",
        border: "1px solid #d1d5db",
      }}
    >
      <div
        style={{
          minWidth: columns.reduce((sum, column) => sum + column.width, 0),
        }}
      >
        <div
          role="row"
          aria-rowindex={1}
          style={{
            display: "grid",
            gridTemplateColumns,
            position: "sticky",
            top: 0,
            zIndex: 1,
            background: "#f3f4f6",
            borderBottom: "1px solid #d1d5db",
            fontWeight: 600,
          }}
        >
          {columns.map((column) => (
            <div
              role="columnheader"
              key={column.key}
              style={{ padding: "10px 12px" }}
            >
              {column.header}
            </div>
          ))}
        </div>

        <div
          style={{
            height: totalHeight,
            position: "relative",
          }}
        >
          {visibleRows.map((row, offset) => {
            const rowIndex = windowRange.start + offset;

            return (
              <VirtualRow
                key={row.id}
                row={row}
                rowIndex={rowIndex}
                columns={columns}
                gridTemplateColumns={gridTemplateColumns}
                top={prefix[rowIndex]}
                onHeightChange={onHeightChange}
              />
            );
          })}
        </div>
      </div>
    </div>
  );
}

使用方式如下:

type UserTableProps = {
  rows: User[];
};

export function UserTable({ rows }: UserTableProps) {
  const columns = useMemo<Column<User>[]>(
    () => [
      {
        key: "name",
        header: "姓名",
        width: 160,
        render: (user) => user.name,
      },
      {
        key: "email",
        header: "邮箱",
        width: 280,
        render: (user) => user.email,
      },
      {
        key: "department",
        header: "部门",
        width: 160,
        render: (user) => user.department,
      },
      {
        key: "description",
        header: "描述",
        width: 360,
        render: (user) => user.description,
      },
    ],
    [],
  );

  return <VirtualTable rows={rows} columns={columns} />;
}

这里的高度变化流程是:

  1. 第一次计算时,未测量行使用 estimatedRowHeight
  2. React 只挂载窗口内的行。
  3. ResizeObserver 得到真实行高。
  4. measuredHeights 更新。
  5. prefix 重新计算。
  6. 行的 top 和总高度更新。
  7. 如果新高度导致更多或更少行进入窗口,React 更新 DOM 行集合。

ResizeObserver 是浏览器 API,因此这部分只能在客户端执行。服务端渲染时不能读取 getBoundingClientRect,也不能直接创建 ResizeObserver


五、为什么这里使用 div,而不是直接使用 <table>

原生表格语义通常是:

<table>
  <thead>...</thead>
  <tbody>
    <tr>...</tr>
  </tbody>
</table>

但虚拟行经常需要:

position: absolute;
top: ...;

原生 <table> 的布局模型并不适合把多个 <tr> 独立地绝对定位到一个总高度容器中。强行混合两种布局模型,容易出现:

  • 行高计算不稳定;
  • 表头无法正确 sticky;
  • 浏览器对 table layout 的重新计算成本较高;
  • 屏幕阅读器看到的行数与 DOM 中实际挂载的行数不一致。

因此,常见做法是使用 CSS Grid 或 Flex,并补充 ARIA 网格语义:

<div role="grid">
  <div role="row">
    <div role="columnheader">姓名</div>
  </div>

  <div role="row">
    <div role="gridcell">张三</div>
  </div>
</div>

这并不等价于浏览器原生表格语义。role="grid" 更适合具有选择、键盘导航、编辑等交互行为的二维控件。若只是静态数据展示,而且数据量不大,原生 <table> 通常更简单、兼容性更好。

使用 ARIA 时还必须提供:

  • aria-rowcount:逻辑行总数;
  • aria-colcount:逻辑列总数;
  • aria-rowindex:行在完整逻辑表格中的位置;
  • aria-colindex:如果列不是全部渲染,单元格需要标明逻辑列位置;
  • 明确的焦点策略和键盘行为。

不能因为添加了 role="grid" 就自动获得完整的无障碍交互。语义和行为需要同时实现。


六、滚动状态与 React 更新节奏

6.1 scrollTop 是外部变化,不是业务数据

滚动容器的 scrollTop 由浏览器维护,React 通过 onScroll 观察它。最直接的实现是:

const [scrollTop, setScrollTop] = useState(0);

const onScroll = (event: React.UIEvent<HTMLDivElement>) => {
  setScrollTop(event.currentTarget.scrollTop);
};

但滚动事件可能非常频繁。每次事件都同步触发完整组件树更新,在复杂单元格中可能产生掉帧。

常见的优化是只在动画帧中提交最新位置:

const frameRef = useRef<number | null>(null);
const pendingScrollTopRef = useRef(0);

const onScroll = useCallback(
  (event: React.UIEvent<HTMLDivElement>) => {
    pendingScrollTopRef.current = event.currentTarget.scrollTop;

    if (frameRef.current !== null) {
      return;
    }

    frameRef.current = requestAnimationFrame(() => {
      frameRef.current = null;
      setScrollTop(pendingScrollTopRef.current);
    });
  },
  [],
);

useEffect(() => {
  return () => {
    if (frameRef.current !== null) {
      cancelAnimationFrame(frameRef.current);
    }
  };
}, []);

这里的因果关系是:

  1. 浏览器可以在一帧内产生多次滚动事件;
  2. 这些事件对应的中间位置不一定需要全部渲染;
  3. requestAnimationFrame 只提交下一帧要使用的最新位置;
  4. React 计算一次窗口并提交一次 DOM 变化。

滚动位置通常不应放进 startTransition。滚动窗口是用户正在观察的即时反馈,延迟渲染可能导致空白区域。startTransition 更适合用于过滤条件变化、排序或切换大块数据集:

import { startTransition } from "react";

function onFilterChange(value: string) {
  setFilter(value);

  startTransition(() => {
    setFilteredRows(applyFilter(allRows, value));
  });
}

这表示“过滤结果的渲染可以让位给更紧急的交互”,不表示所有更新都应该放进 transition。

6.2 useMemouseCallback 不等于缓存协议

示例中的 useMemo 用于避免在依赖不变时重复计算前缀和、列模板。React 文档将其定义为性能优化,而不是语义保证。组件不能依赖“React 永远不会丢弃这个缓存”来维持正确性。

同样,useCallback 的主要价值是让回调身份在依赖不变时稳定,减少子组件 effect 或 memo 组件的无效更新。它不能解决错误的状态建模,也不能替代稳定的行 key


七、行身份:虚拟化中最容易被忽略的正确性条件

错误示例:

{visibleRows.map((row, index) => (
  <Row key={index} row={row} />
))}

当数据排序、过滤或插入时,窗口中的第 index 个位置可能对应另一条数据。React 会复用原来的组件实例,造成:

  • 输入框保留了错误的值;
  • 选中状态显示到错误行;
  • 行内 effect 继续使用旧数据;
  • 测量高度和实际数据错配。

正确做法是使用数据的稳定身份:

{visibleRows.map((row) => (
  <Row key={row.id} row={row} />
))}

这里还存在一个更深的问题:如果高度按索引保存,key 虽然正确,布局缓存仍然可能错误。因此高度缓存也应使用 row.id,除非数据集合的顺序和成员严格不会变化。


八、交互:DOM 可以短暂存在,业务状态不能依赖 DOM

虚拟化会卸载离开窗口的行。因此以下状态不能只存在于行组件内部:

function Row() {
  const [selected, setSelected] = useState(false);
  // 行卸载后 selected 丢失
}

选择状态应放在表格或更高层:

const [selectedIds, setSelectedIds] = useState<Set<string>>(
  () => new Set(),
);

function toggleRow(id: string) {
  setSelectedIds((previous) => {
    const next = new Set(previous);

    if (next.has(id)) {
      next.delete(id);
    } else {
      next.add(id);
    }

    return next;
  });
}

行组件只根据 selectedIds.has(row.id) 渲染状态。这样行重新进入窗口时,状态可以恢复。

8.1 点击、复选框和事件冒泡

如果行点击表示“选中”,单元格中又有按钮或复选框,就必须明确事件边界:

<div
  role="row"
  onClick={() => toggleRow(row.id)}
>
  <button
    type="button"
    onClick={(event) => {
      event.stopPropagation();
      openDetails(row.id);
    }}
  >
    查看
  </button>
</div>

否则点击按钮会同时触发行选择和按钮行为。虚拟化并不会改变 DOM 事件冒泡规则,但由于行会反复挂载,更容易暴露清理不完整的事件监听器问题。

如果使用原生 addEventListener,必须在 effect 清理中移除:

useEffect(() => {
  const element = elementRef.current;
  if (!element) return;

  const onKeyDown = (event: KeyboardEvent) => {
    // ...
  };

  element.addEventListener("keydown", onKeyDown);
  return () => {
    element.removeEventListener("keydown", onKeyDown);
  };
}, []);

8.2 键盘导航和“可见但未挂载”

表格常见的键盘操作包括:

  • ArrowUp / ArrowDown 移动行;
  • ArrowLeft / ArrowRight 移动单元格;
  • Home / End 移动到行首或行尾;
  • Enter 进入编辑;
  • Space 切换选择;
  • Escape 取消编辑。

问题在于,目标行可能不在当前窗口内。正确流程不是直接调用一个不存在的 DOM 节点的 .focus(),而是:

  1. 根据逻辑行索引更新活动单元格状态;
  2. 判断目标行是否位于当前窗口;
  3. 如果不在窗口内,调用滚动容器的 scrollTo
  4. 等 React 挂载目标行;
  5. 在 effect 或 callback ref 中把焦点交给目标单元格。

例如固定行高时:

function scrollToRow(
  container: HTMLElement,
  rowIndex: number,
  rowHeight: number,
) {
  const top = rowIndex * rowHeight;
  const bottom = top + rowHeight;
  const visibleTop = container.scrollTop;
  const visibleBottom = visibleTop + container.clientHeight;

  if (top < visibleTop) {
    container.scrollTop = top;
  } else if (bottom > visibleBottom) {
    container.scrollTop = bottom - container.clientHeight;
  }
}

动态高度时不能使用简单乘法,需要从前缀和取得目标行的 topbottom

8.3 编辑器的状态和生命周期

行内编辑器有两种常见状态:

  • 临时编辑值:用户正在输入但尚未提交;
  • 业务数据值:服务器或上层状态认可的值。

临时编辑值可以放在编辑器内部,但如果编辑状态必须跨越滚动和卸载,就应放在表格外层:

type EditingCell = {
  rowId: string;
  columnKey: string;
} | null;

const [editingCell, setEditingCell] = useState<EditingCell>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});

编辑中滚出窗口时,有三种明确策略:

  1. 禁止编辑中的行被卸载;
  2. 把编辑器渲染到固定的 overlay 或 portal;
  3. 保存 draft,卸载后重新挂载时恢复。

不能让用户正在输入的内容只存在于一个可能随时被虚拟化卸载的 DOM 输入框里。


九、动态数据:分页、过滤、排序和异步请求

虚拟化只解决 DOM 数量问题,不会自动解决数据请求问题。数据可以有三种形态:

  1. 已经加载到内存的完整数组;
  2. 分页数据;
  3. 根据滚动位置按需加载的数据。

如果窗口接近数据尾部,可以触发下一页加载:

useEffect(() => {
  const lastVisible = windowRange.end;
  const nearEnd = lastVisible >= rows.length - 20;

  if (nearEnd && hasNextPage && !isLoading) {
    loadNextPage();
  }
}, [hasNextPage, isLoading, loadNextPage, rows.length, windowRange.end]);

loadNextPage 必须具备幂等性或请求去重,否则一次滚动可能重复发起多个请求。

过滤或排序时,旧的高度缓存可能不再适用:

useEffect(() => {
  setMeasuredHeights(new Map());
  scrollRef.current?.scrollTo({ top: 0 });
}, [filterKey, sortKey]);

是否清空缓存取决于数据身份:

  • 如果只是同一批行改变了过滤结果,按 row.id 保存的高度可以继续使用;
  • 如果单元格内容、列宽或字体改变,高度可能失效;
  • 如果排序改变,按 row.id 保存的高度仍然有效,但按索引保存的高度无效;
  • 如果服务端返回了新的行版本,最好根据版本号或内容变化使缓存失效。

异步请求还要处理竞态。用户快速修改筛选条件时,旧请求可能晚于新请求返回。如果直接写入状态,旧结果会覆盖新结果。可以用 AbortController

useEffect(() => {
  const controller = new AbortController();

  async function fetchRows() {
    try {
      const response = await fetch(
        `/api/users?query=${encodeURIComponent(query)}`,
        { signal: controller.signal },
      );

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const data: User[] = await response.json();
      setRows(data);
    } catch (error) {
      if (error instanceof DOMException && error.name === "AbortError") {
        return;
      }

      setError(error instanceof Error ? error.message : "加载失败");
    }
  }

  fetchRows();

  return () => controller.abort();
}, [query]);

这个 effect 的生命周期是:

  1. query 变化;
  2. 终止上一次请求;
  3. 创建新的请求;
  4. 成功时更新当前查询的数据;
  5. 组件卸载或查询再次变化时清理请求。

React 不会替应用自动取消 fetch,取消逻辑需要由代码负责。


十、服务端渲染与客户端边界

虚拟列表的核心计算依赖浏览器信息:

  • scrollTop
  • clientHeight
  • ResizeObserver
  • getBoundingClientRect
  • requestAnimationFrame
  • 焦点和滚动 API。

因此,使用 Next.js App Router 等框架时,包含虚拟列表交互的组件通常应放在客户端边界内:

"use client";

服务端可以负责:

  • 查询初始数据;
  • 生成初始页面;
  • 传递列配置和初始行数据。

客户端负责:

  • 创建滚动容器;
  • 测量视口;
  • 计算窗口;
  • 处理滚动和焦点;
  • 监听动态高度。

初始渲染时如果服务端和客户端输出的 DOM 行数不同,可能造成 hydration 不一致。常见处理方式是:

  • 服务端输出固定的非虚拟首屏;
  • 客户端挂载后再启用虚拟化;
  • 服务端和客户端先使用同一个确定的估算高度与初始窗口;
  • 对依赖浏览器尺寸的部分显示稳定的 loading 或占位结构。

不能在组件渲染阶段直接执行:

const height = window.innerHeight;

因为服务端没有 window。即使使用 typeof window !== "undefined",如果两次渲染输出不同,也可能造成水合差异。浏览器 API 应放在 effect、事件处理器或明确的客户端初始化逻辑中。


十一、常见失败表现与诊断路径

11.1 滚动条高度错误

表现:只能滚动到数据的一小部分,或者滚动到底部后仍有内容没有显示。

原因:总高度占位层没有使用完整数据集合的高度。

固定高度时检查:

totalHeight === rows.length * rowHeight

动态高度时检查:

totalHeight === prefix[rows.length]

同时确认滚动容器的直接内容没有额外的 margintransform 或高度约束干扰计算。

11.2 行进入窗口时闪烁或覆盖

表现:快速滚动时行重叠、空白或出现旧行内容。

原因通常包括

  • top 由错误的索引或旧前缀和计算;
  • React key 使用了窗口内索引;
  • 高度变化后没有重新计算总高度;
  • 父容器没有 position: relative
  • 绝对定位行没有明确的 leftright 或宽度。

诊断时可以给每行显示调试信息:

<div>
  index={rowIndex}, top={top}, measured={measuredHeight}
</div>

观察同一个 row.id 是否始终对应正确的 top 和高度。

11.3 高度不断变化,组件持续重渲染

表现:滚动或静止时 CPU 占用高,ResizeObserver 回调不断触发。

常见原因

  • 每次测量都无条件调用 setState
  • 测量结果被四舍五入或 CSS 亚像素值反复改变;
  • 行高度由自身状态控制,而状态又依赖测量结果;
  • 测量时修改了会影响布局的 CSS;
  • 图片没有尺寸约束,加载过程不断改变布局。

示例代码中的差值判断:

if (oldHeight !== undefined && Math.abs(oldHeight - height) < 0.5) {
  return previous;
}

用于阻止微小变化导致的更新,但阈值不是规范要求,应根据布局精度和业务需求调整。

11.4 快速滚动出现白屏

表现:滚动条拖动或快速滚动时,视口暂时没有行。

原因:窗口计算和 React 提交速度跟不上滚动速度,或 overscan 太小。

处理方向包括:

  • 增大 overscanPx
  • 降低行渲染成本;
  • 避免滚动更新进入低优先级 transition;
  • 对复杂单元格做 memo;
  • 减少每行 effect 和观察器数量;
  • 使用成熟的虚拟化库,但验证它对动态高度、固定列和键盘交互的支持。

overscan 越大,白屏概率通常越低,但 DOM 数量和更新成本也越高。它不是越大越好,而是要在设备性能和滚动速度之间取平衡。

11.5 选择状态错位

表现:排序或过滤后,选中的视觉状态出现在另一行。

原因

  • 使用数组索引作为 key
  • 使用数组索引存储选中状态;
  • 行组件内部的局部状态没有和 row.id 绑定;
  • 数据更新后仍复用了旧的索引高度缓存。

检查所有状态是否按稳定身份建模:

selectedIds.has(row.id)
measuredHeights.get(row.id)
drafts[`${row.id}:${column.key}`]

十二、原生表格、普通列表和虚拟表格的取舍

不应把虚拟化当成表格的默认选项。

如果数据只有几十到几百行,原生 <table> 的优势往往更大:

  • 语义天然正确;
  • 浏览器支持表头和单元格关系;
  • 复制、打印和辅助技术兼容性通常更好;
  • 不需要维护窗口、测量和焦点恢复。

当出现以下条件时,虚拟化才更有价值:

  • DOM 行数已经影响滚动或输入响应;
  • 行渲染包含复杂组件;
  • 数据集合足够大;
  • 用户主要浏览视口附近的数据;
  • 团队能够维护测量、交互和无障碍逻辑。

如果行高固定,虚拟化明显更简单。动态高度、二维窗口、固定列、编辑器、树形展开和服务端分页叠加后,复杂度会快速增加。此时应先明确性能瓶颈来自:

  • DOM 数量;
  • 单元格渲染;
  • 数据计算;
  • 网络请求;
  • 布局测量;
  • 频繁状态更新。

只有瓶颈确实是可见范围之外的 DOM,虚拟化才会直接解决问题。


十三、一个完整的数据流模型

可以把虚拟表格的状态和数据流抽象为:

flowchart TD
    A[数据 rows] --> B[列模型 columns]
    A --> C[行身份与业务状态]
    B --> D[列总宽度与 grid 模板]
    C --> E[高度缓存 measuredHeights]
    E --> F[高度前缀和 prefix]
    F --> G[总高度与行坐标]
    H[scrollTop] --> I[窗口计算]
    J[viewportHeight] --> I
    G --> I
    I --> K[可见行索引]
    K --> L[挂载 DOM 行]
    L --> M[ResizeObserver]
    M --> E
    L --> N[点击、键盘、编辑]
    N --> C
    O[过滤、排序、分页] --> A

关键路径是:

  1. 列模型决定每个单元格的水平布局。
  2. 行数据和高度缓存决定每行的垂直坐标。
  3. scrollTop 与视口高度决定当前窗口。
  4. 窗口决定哪些 React 行被挂载。
  5. 挂载后的真实测量又反过来修正高度缓存。
  6. 用户交互更新的是稳定的业务状态,而不是某个暂时存在的 DOM 行。

这个闭环成立的前提是:列身份、行身份和布局坐标都必须稳定。只要其中一个环节使用了窗口索引、临时 DOM 状态或不一致的宽度来源,表格就会在排序、滚动或动态内容变化后出现难以复现的错误。

虚拟列表的核心不是“少渲染一些元素”,而是维护一个与 DOM 解耦的逻辑坐标系:完整数据拥有完整的逻辑位置,DOM 只负责呈现当前窗口,而 React 状态负责保存跨窗口仍然有效的交互和业务事实。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。