列表是 React 应用中最常见的 UI 形态之一:商品列表、聊天记录、表格数据、通知消息……当列表项达到成百上千条时,如果不做任何优化,页面就会出现明显的性能问题:首次渲染白屏时间长,滚动时掉帧卡顿。本节介绍两种最核心的列表性能优化手段:虚拟列表和key 属性的正确使用。
21.2.1 虚拟列表:只渲染看得见的内容
问题根源
假设一个列表有 10000 条数据,每条数据渲染为一个包含头像、文字、按钮的复杂组件。React 会创建 10000 个 DOM 节点,执行 10000 次组件渲染。这不仅导致初始渲染极其缓慢,滚动时浏览器还需要不断处理大量不可见 DOM 的布局和绘制,极易出现卡顿。
解决方案:窗口化渲染
虚拟列表(Virtual List,也称窗口化 Windowing)的思路很简单:只渲染当前可视区域及其周边少量缓冲区内的列表项,其余部分用占位元素撑开高度。当用户滚动时,动态替换可视区域的项,始终保持实际渲染的 DOM 数量在一个极小的范围内(通常 10~50 个),滚动性能与列表总长度无关。
推荐库:react-window
react-window 是目前最轻量、高性能的虚拟列表库(仅 2KB gzip),由 react-virtualized 的作者重写,API 更简洁。绝大多数场景下应该优先选择它。
安装
npm install react-window
固定高度列表
import { FixedSizeList as List } from 'react-window';
function Row({ index, style, data }) {
const item = data[index];
return (
<div style={style}>
{item.name} - {item.email}
</div>
);
}
function VirtualizedList({ items }) {
return (
<List
height={400} // 列表可视区域高度
itemCount={items.length}
itemSize={50} // 每项高度(固定)
width="100%"
itemData={items} // 传递给每行的数据
>
{Row}
</List>
);
}
FixedSizeList 要求每项高度一致。style 参数必须应用到根元素上,它包含了绝对定位和变换属性,用于将行放置在正确的位置。
动态高度列表
当列表项高度不固定(如包含不同长度文本)时,使用 VariableSizeList,需要提供估算高度和实际测量高度的方法。
import { VariableSizeList as List } from 'react-window';
function getItemSize(index) {
// 根据数据估算每项高度,接近真实值可减少抖动
return index % 2 === 0 ? 60 : 80;
}
function Row({ index, style, data }) {
const item = data[index];
return (
<div style={style}>
<strong>{item.title}</strong>
<p>{item.content}</p>
</div>
);
}
function DynamicList({ items }) {
const listRef = useRef();
return (
<List
ref={listRef}
height={400}
itemCount={items.length}
itemSize={getItemSize} // 函数形式
width="100%"
itemData={items}
>
{Row}
</List>
);
}
对于高度完全无法预知的场景,可以使用 react-window 搭配 AutoSizer 自动获取容器宽度,以及 useRef + resetAfterIndex 进行动态高度重置。但建议尽可能让列表项高度可预测,性能才最稳定。
虚拟列表的配套设施
- React.memo:列表项组件务必包裹
React.memo,避免父组件状态变化导致已渲染项的无意义重渲染。
const Row = React.memo(({ index, style, data }) => {
// ...
});
- useCallback 稳定回调:如果列表项内部需要处理点击事件,父组件中使用
useCallback包裹回调函数,避免每次渲染生成新函数引用导致子组件重渲染。
- 加载更多:虚拟列表通常配合滚动到底部加载更多数据,
react-window提供了onItemsRendered回调来判断当前渲染的范围,实现触底加载:
<List
onItemsRendered={({ overscanStartIndex, overscanStopIndex }) => {
if (overscanStopIndex >= items.length - 1) {
loadMore();
}
}}
/>
何时使用虚拟列表
- 数据量 > 200 条,且列表项包含图片、复杂节点时,虚拟列表效果立竿见影。
- 移动端或性能敏感场景,数据量 > 100 条就应启用。
- 简单文本列表在 500 条以内可能影响不大,但仍推荐养成习惯。
21.2.2 key 属性的正确使用
key 的作用
React 在协调(Reconciliation)阶段,通过比较新旧虚拟 DOM 树来决定如何更新真实 DOM。对于列表,React 依赖每一项的 key 来判断元素的身份:相同 key 认为是同一个元素,触发移动或更新;不同 key 则认为是新增或删除。
如果 key 缺失或选择不当,React 可能做出错误的更新决策,导致性能低效甚至状态错乱。
错误用法
1. 使用索引作为 key
{items.map((item, index) => (
<ListItem key={index} data={item} />
))}
问题:当列表顺序改变(排序、插入、删除)时,索引发生变化。原本第一项的数据变了,但 key 仍然是 0,React 会认为这个元素只是内容更新了,而不会重新创建,这会导致:
- 状态错乱:如果
ListItem内部有非受控状态(如输入框内容),数据错位,输入框内容“粘”在位置上。 - 性能浪费:无法利用元素的移动复用,每次都要更新节点。
2. 使用随机数作为 key
{items.map(item => (
<ListItem key={Math.random()} data={item} />
))}
每次渲染都会生成新的 key,React 会认为所有元素都是新创建的,完全销毁旧 DOM 并创建新 DOM,性能极差,且丢失组件内部状态。
3. key 不唯一
{items.map(item => (
<ListItem key={item.type} data={item} /> // 如果有多个相同 type 会冲突
))}
React 要求兄弟节点间 key 必须唯一,重复 key 会导致渲染错误。
正确用法
始终使用稳定、唯一的数据 ID 作为 key
{products.map(product => (
<ProductCard key={product.id} product={product} />
))}
如果后端数据没有唯一 ID,可以在获取数据时生成(但必须保证每次数据相同项生成的 ID 一致),或使用合适的业务字段组合(如 ${item.name}_${item.timestamp})。
key 必须绑定在数组的直接元素上
key 应该写在 map 返回的最外层元素上,而不是该元素内部的某个子元素上。
真实案例:输入框错乱
一个常见的踩坑场景:待办事项列表,每一项包含一个 input 用于修改标题。使用索引作为 key 时,在头部插入新项目,会导致所有已有项的输入框内容全部错位——第一个输入框显示的内容变成了第二项的。改用唯一 ID 后立即恢复正确。
// ❌ 使用 index 作为 key,插入新项后输入框内容错乱
{items.map((item, index) => (
<div key={index}>
<input defaultValue={item.title} />
</div>
))}
// ✅ 使用唯一 id
{items.map(item => (
<div key={item.id}>
<input defaultValue={item.title} />
</div>
))}
总结:key 选择清单
- 有唯一 ID 就用 ID。
- 没有 ID 可以组合多个字段确保唯一性。
- 万不得已使用索引时,必须确保列表不会发生重排序、插入或删除操作(静态列表)。
- 绝对不要使用随机数、时间戳等不稳定值。
列表性能优化是前台应用中提升用户体验最直接的途径。掌握虚拟列表和正确的 key 用法,能让你轻松驾驭万级数据的流畅滚动,同时避免难以排查的状态错乱问题。