React 基础体系 · 第 39/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React 表格与虚拟列表:列模型、窗口、动态高度和交互
表格和列表都可以抽象为“按行组织的数据集合”,但它们面对的问题不同:
- 列表通常只关心一维的行位置。
- 表格还要处理列定义、列宽、表头与行对齐、横向滚动、固定列和单元格交互。
- 虚拟列表则改变了渲染策略:数据可以有上万行,但 DOM 中只保留视口附近的一小部分行。
因此,虚拟表格不是简单地给 <table> 加上 overflow: auto。它至少需要解决四个相互影响的问题:
- 如何用稳定的列模型描述列及其宽度。
- 如何根据滚动位置计算当前的渲染窗口。
- 如何在行高固定或动态变化时保持位置、滚动条和交互正确。
- 如何让被卸载和重新挂载的行仍然支持选择、编辑、键盘操作和无障碍语义。
下文先建立这些机制,再给出一个可运行的 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,不使用虚拟化时大约需要创建:
个行节点。使用虚拟化后,DOM 行数主要由视口和预渲染区域决定:
其中 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 使用像素值时,列的水平位置可以直接累加计算:
其中:
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,
},
];
总宽度是:
上例的总宽度为:
这个总宽度必须同时用于表头和数据行,否则会出现表头与单元格错位。
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 时,需要查找满足下式的列:
并排除完全位于左侧的列:
实际项目中通常还要额外处理:
- 左固定列和右固定列;
- 横向滚动层与固定列层之间的遮挡;
- 表头和数据行共享同一个
scrollLeft; - 键盘移动到未挂载列时先滚动,再聚焦。
如果只有几十列,优先只做行虚拟化。二维虚拟化会显著增加焦点管理、固定列和测量逻辑的复杂度。
三、窗口:只渲染视口附近的行
3.1 固定高度行的窗口计算
先考虑所有行高度相同的情况。
设:
rowHeight = 40;scrollTop = 530;viewportHeight = 240;overscan = 3行。
第一个进入视口的行索引近似为:
视口覆盖的行数为:
不考虑 overscan 时,窗口约为 [13, 19);加入上下各 3 行后:
start = max(0, 13 - 3) = 10
end = min(rowCount, 19 + 3) = 22
因此只渲染第 10 到第 21 行,共 12 行。
行的绝对位置为:
滚动容器内部仍然需要一个总高度为:
的占位元素。否则浏览器认为内容只有当前十几行,滚动条会过短,用户无法滚到数据末尾。
典型结构是:
滚动容器
└── 总高度占位层
├── 可见行 10,top = 400px
├── 可见行 11,top = 440px
└── ...
可见行使用绝对定位,top 由数据索引决定,而不是由当前 DOM 中前一个行的位置决定。这样,卸载中间行不会改变后续行的坐标。
3.2 动态高度行的窗口计算
如果每一行高度不同,就不能用 index * rowHeight。
令第 i 行高度为 h_i,其顶部位置为:
底部位置为:
一行与视口 [scrollTop, scrollTop + viewportHeight) 相交的条件是:
并且:
因此,动态高度虚拟列表需要维护高度前缀和:
heights = [40, 64, 40, 96]
prefix = [0, 40, 104, 144, 240]
第 2 行的顶部是 prefix[2] = 104,总高度是 prefix[4] = 240。
从 scrollTop 查找第一个可见行,不能每次从头扫描全部行,否则每次滚动都可能是 O(n)。常见实现是:
- 用估算高度初始化所有行;
- 用真实测量结果更新部分行高度;
- 维护高度前缀和;
- 用二分查找定位窗口起点;
- 从起点向后扫描,直到超过视口底部和 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作为 Reactkey。
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} />;
}
这里的高度变化流程是:
- 第一次计算时,未测量行使用
estimatedRowHeight。 - React 只挂载窗口内的行。
ResizeObserver得到真实行高。measuredHeights更新。prefix重新计算。- 行的
top和总高度更新。 - 如果新高度导致更多或更少行进入窗口,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);
}
};
}, []);
这里的因果关系是:
- 浏览器可以在一帧内产生多次滚动事件;
- 这些事件对应的中间位置不一定需要全部渲染;
requestAnimationFrame只提交下一帧要使用的最新位置;- React 计算一次窗口并提交一次 DOM 变化。
滚动位置通常不应放进 startTransition。滚动窗口是用户正在观察的即时反馈,延迟渲染可能导致空白区域。startTransition 更适合用于过滤条件变化、排序或切换大块数据集:
import { startTransition } from "react";
function onFilterChange(value: string) {
setFilter(value);
startTransition(() => {
setFilteredRows(applyFilter(allRows, value));
});
}
这表示“过滤结果的渲染可以让位给更紧急的交互”,不表示所有更新都应该放进 transition。
6.2 useMemo 和 useCallback 不等于缓存协议
示例中的 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(),而是:
- 根据逻辑行索引更新活动单元格状态;
- 判断目标行是否位于当前窗口;
- 如果不在窗口内,调用滚动容器的
scrollTo; - 等 React 挂载目标行;
- 在 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;
}
}
动态高度时不能使用简单乘法,需要从前缀和取得目标行的 top 和 bottom。
8.3 编辑器的状态和生命周期
行内编辑器有两种常见状态:
- 临时编辑值:用户正在输入但尚未提交;
- 业务数据值:服务器或上层状态认可的值。
临时编辑值可以放在编辑器内部,但如果编辑状态必须跨越滚动和卸载,就应放在表格外层:
type EditingCell = {
rowId: string;
columnKey: string;
} | null;
const [editingCell, setEditingCell] = useState<EditingCell>(null);
const [drafts, setDrafts] = useState<Record<string, string>>({});
编辑中滚出窗口时,有三种明确策略:
- 禁止编辑中的行被卸载;
- 把编辑器渲染到固定的 overlay 或 portal;
- 保存 draft,卸载后重新挂载时恢复。
不能让用户正在输入的内容只存在于一个可能随时被虚拟化卸载的 DOM 输入框里。
九、动态数据:分页、过滤、排序和异步请求
虚拟化只解决 DOM 数量问题,不会自动解决数据请求问题。数据可以有三种形态:
- 已经加载到内存的完整数组;
- 分页数据;
- 根据滚动位置按需加载的数据。
如果窗口接近数据尾部,可以触发下一页加载:
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 的生命周期是:
query变化;- 终止上一次请求;
- 创建新的请求;
- 成功时更新当前查询的数据;
- 组件卸载或查询再次变化时清理请求。
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]
同时确认滚动容器的直接内容没有额外的 margin、transform 或高度约束干扰计算。
11.2 行进入窗口时闪烁或覆盖
表现:快速滚动时行重叠、空白或出现旧行内容。
原因通常包括:
top由错误的索引或旧前缀和计算;- React
key使用了窗口内索引; - 高度变化后没有重新计算总高度;
- 父容器没有
position: relative; - 绝对定位行没有明确的
left、right或宽度。
诊断时可以给每行显示调试信息:
<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
关键路径是:
- 列模型决定每个单元格的水平布局。
- 行数据和高度缓存决定每行的垂直坐标。
scrollTop与视口高度决定当前窗口。- 窗口决定哪些 React 行被挂载。
- 挂载后的真实测量又反过来修正高度缓存。
- 用户交互更新的是稳定的业务状态,而不是某个暂时存在的 DOM 行。
这个闭环成立的前提是:列身份、行身份和布局坐标都必须稳定。只要其中一个环节使用了窗口索引、临时 DOM 状态或不一致的宽度来源,表格就会在排序、滚动或动态内容变化后出现难以复现的错误。
虚拟列表的核心不是“少渲染一些元素”,而是维护一个与 DOM 解耦的逻辑坐标系:完整数据拥有完整的逻辑位置,DOM 只负责呈现当前窗口,而 React 状态负责保存跨窗口仍然有效的交互和业务事实。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 拖拽交互:排序、跨容器、触摸、键盘和状态一致性
- 下一篇:React 样式工程:CSS Modules、CSS-in-JS、原子 CSS 和主题
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论