
一句话:长列表的性能瓶颈有三层——React 渲染(几千个组件函数)、DOM 数量(几千个节点)、布局与绘制;对应手段是「稳定
key+ 行组件memo+ 稳定处理函数 + 虚拟滚动 + 避免渲染中重算」。
一、先定位瓶颈在哪一层
三层成本:
① React 层
· 几千行 → 几千次组件函数调用 + 元素创建 + diff
· 每次更新都重跑一遍 → 主线程被占满
② DOM 层
· 几千个 DOM 节点的内存与维护成本
· 浏览器的「样式计算 + 布局 + 绘制」都要遍历这些节点
· 这是「滚动掉帧」最常见的原因 ⚠️
③ 布局与绘制层
· 复杂的行内布局(表格、嵌套 flex)
· 阴影、渐变、滤镜等绘制成本
· 图片未设置尺寸 → 布局抖动
判断方法:
·Profiler 显示「组件渲染耗时高」→ ①
· Performance 面板显示「Recalculate Style / Layout / Paint 耗时高」→ ②③
· 滚动时掉帧但没有 JS 执行(Performance 里 Frames 有红条、Main 空闲)→ ②③
二、key 的选择
// ❌ 用 index(列表增删/排序时状态错位,见 4.5)
{items.map((item, i) => <Row key={i} item={item} />)}
// ❌ 用随机值(每次渲染整列重建)
{items.map((item) => <Row key={Math.random()} item={item} />)}
// ✅ 稳定唯一 ID
{items.map((item) => <Row key={item.id} item={item} />)}
为什么 key 对性能如此关键?
① key 正确 → React 能「复用」DOM 与组件实例 → 只更新变化的属性
② key 错误(如随机值)→ React 认为「全是新节点」→ 卸载全部 + 创建全部
→ DOM 操作数量从「几个」变成「几千个」
⚠️ 这是「列表性能」中收益最大、成本最低的一项。
三、行组件 memo + 稳定 props
// ❌ 每个 Row 都收到一个「新建的箭头函数」→ memo 失效
{items.map((item) => (
<Row key={item.id} item={item} onDelete={() => handleDelete(item.id)} />
))}
// ✅ 方案一:稳定处理函数 + 稳定 props
const Row = React.memo(function Row({ item, onDelete }: RowProps) {
console.log('Row 渲染', item.id);
return (
<li>
{item.name}
<button onClick={() => onDelete(item.id)}>删除</button>
{/* ⭐ 这里的箭头函数是 Row 内部的,不影响 Row 自身的 memo */}
</li>
);
});
function List({ items, onDelete }: { items: Item[]; onDelete: (id: string) => void }) {
const handleDelete = useCallback((id: string) => onDelete(id), [onDelete]);
return (
<ul>
{items.map((item) => (
<Row key={item.id} item={item} onDelete={handleDelete} />
))}
</ul>
);
}
// ✅ 方案二(更彻底):data-* + 事件委托 → 处理函数引用永久稳定
const RowWithoutCallback = React.memo(function RowWithoutCallback({ item }: { item: Item }) {
return (
<li data-id={item.id}>
{item.name}
<button>删除</button>
</li>
);
});
function List2({ items, onDelete }: { items: Item[]; onDelete: (id: string) => void }) {
const onDeleteRef = useRef(onDelete);
useEffect(() => { onDeleteRef.current = onDelete; });
// ⭐ 依赖为空 → 引用永久稳定
const handleClick = useCallback((e: React.MouseEvent<HTMLUListElement>) => {
const target = (e.target as HTMLElement).closest('[data-id]');
if (!target) return;
onDeleteRef.current(target.getAttribute('data-id')!);
}, []);
return (
<ul onClick={handleClick}>
{items.map((item) => (
<RowWithoutCallback key={item.id} item={item} />
// ↑ props 只有 item → memo 永久生效
))}
</ul>
);
}
// 优点:只有「一个」监听器(事件委托)+ 行组件永远不因回调变化重渲染
// 缺点:模板里看不到「点击处理函数」(可读性略降)
| 方案 | 处理函数引用 | 监听器数量 | 可读性 |
|---|---|---|---|
| 内联箭头函数 | 每次渲染新函数 | 每行 1 个 | ✅ 直观 |
useCallback + 每行传参 |
稳定 | 每行 1 个 | ✅ |
data-* + 事件委托 |
永久稳定 | 整列 1 个 | ⚠️ 略降 |
四、避免「渲染中重算」
// ❌ 每次渲染都重新 filter + sort
function List({ items, keyword, sortBy }: Props) {
const visible = items
.filter((it) => it.name.includes(keyword))
.sort((a, b) => (sortBy === 'name' ? a.name.localeCompare(b.name) : b.score - a.score));
return <ul>{visible.map((it) => <Row key={it.id} item={it} />)}</ul>;
}
// ✅ 用 useMemo 缓存
function List2({ items, keyword, sortBy }: Props) {
const visible = useMemo(() => {
return items
.filter((it) => it.name.includes(keyword))
.sort((a, b) => (sortBy === 'name' ? a.name.localeCompare(b.name) : b.score - a.score));
}, [items, keyword, sortBy]);
return <ul>{visible.map((it) => <Row key={it.id} item={it} />)}</ul>;
}
⚠️ 一个常见错误:每次渲染创建「新的数组」
const sorted = [...items].sort(); // ❌ 每次都是新引用
<MemoRow items={sorted} /> // → memo 失效
✅ 放到 useMemo 里
const sorted = useMemo(() => [...items].sort(), [items]);
五、虚拟滚动(长列表的核心解法)
核心思路:
只渲染「可视区域内」的几十个元素,
用一个「占位元素」撑起总高度(保证滚动条长度正确)。
关键计算:
① 可视区高度:containerHeight = container.clientHeight
② 行高(固定时):rowHeight
③ 总高度:totalHeight = rowHeight × itemCount
④ 起始索引:startIndex = Math.floor(scrollTop / rowHeight)
⑤ 渲染数量:visibleCount = Math.ceil(containerHeight / rowHeight)
⑥ 结束索引:endIndex = Math.min(startIndex + visibleCount + overscan, itemCount - 1)
⑦ 偏移:offsetY = startIndex × rowHeight
// 固定行高的虚拟列表(简化版,见 11.6 的完整实现)
function VirtualList({ items, rowHeight = 40, overscan = 3 }: VirtualListProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);
// ① 监听容器高度(响应式)
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setContainerHeight(el.clientHeight));
ro.observe(el);
setContainerHeight(el.clientHeight);
return () => ro.disconnect();
}, []);
// ② 滚动监听(用 rAF 节流,避免高频 setState)
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const top = e.currentTarget.scrollTop;
requestAnimationFrame(() => setScrollTop(top));
}, []);
// ③ 计算可视范围
const startIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan
);
const visible = items.slice(startIndex, endIndex + 1);
return (
<div
ref={containerRef}
onScroll={handleScroll}
style={{ height: '100%', overflow: 'auto', position: 'relative' }}
>
{/* 占位元素:撑起总高度,保证滚动条正确 */}
<div style={{ height: items.length * rowHeight, position: 'relative' }}>
{/* 渲染层:整体偏移到正确位置 */}
<div style={{ transform: `translateY(${startIndex * rowHeight}px)` }}>
{visible.map((item, i) => (
<Row key={item.id} item={item} style={{ height: rowHeight }} />
))}
</div>
</div>
</div>
);
}
虚拟滚动的三个关键点:
① 用「占位元素」撑高度(否则滚动条只有几十行的高度)
② 用「transform: translateY」偏移渲染层(比 top/absolute 更省布局计算)
③ overscan(多渲染几行)避免「快速滚动时白屏」
⚠️ 不等高的行怎么办?
需要用「高度缓存 + 前缀和 + 二分查找 + 滚动锚定」,
复杂度明显上升 → 生产上建议用成熟库(react-window / react-virtuoso / TanStack Virtual)。
| 库 | 特点 |
|---|---|
| react-window | 轻量、API 简单、固定/变量行高 |
| react-virtualized | 功能全(表格、网格),体积大、维护缓慢 |
| react-virtuoso | 支持不等高、反向滚动、分组,体验好 |
| @tanstack/react-virtual | headless(只管逻辑,样式自定),灵活 |
| antd 的 Table/VirtualList | 项目已用 antd 时可直接用 |
// react-window 的用法(生产环境推荐)
import { FixedSizeList } from 'react-window';
function List({ items }: { items: Item[] }) {
return (
<FixedSizeList
height={600}
width="100%"
itemCount={items.length}
itemSize={40}
itemData={items}
>
{({ index, style, data }) => (
<div style={style}>
{data[index].name}
{/* ⚠️ 注意:必须把 style 应用到最外层元素 */}
</div>
)}
</FixedSizeList>
);
}
六、分页与分批渲染
// ① 分页(最传统、最省资源)
function PaginatedList() {
const [page, setPage] = useState(1);
const { data, isLoading } = useQuery({
queryKey: ['items', page],
queryFn: () => api.getItems({ page, size: 20 }),
placeholderData: (prev) => prev, // 翻页时保留上一页,避免闪白
});
// ...
}
// ② 分批渲染(先渲染前 N 条,滚动/按钮触发更多)
function IncrementalList({ items }: { items: Item[] }) {
const [visibleCount, setVisibleCount] = useState(50);
const visible = useMemo(() => items.slice(0, visibleCount), [items, visibleCount]);
const loadMore = useCallback(() => {
setVisibleCount((c) => Math.min(c + 50, items.length));
}, [items.length]);
return (
<>
<ul>{visible.map((it) => <Row key={it.id} item={it} />)}</ul>
{visibleCount < items.length && <button onClick={loadMore}>加载更多</button>}
</>
);
}
三种策略的取舍:
· 分页 → 资源最省,但用户需要「翻页」
· 分批 → 简单,但「一次性 DOM 仍然会增长到全量」(不解决根本问题)
· 虚拟滚动 → 最彻底(DOM 始终只有几十个),但实现复杂、对「行高不定」支持难
七、其它优化技巧
/* ① content-visibility:让浏览器跳过「屏幕外元素」的渲染工作 */
.list-item {
content-visibility: auto;
contain-intrinsic-size: 0 40px; /* 声明一个预估高度,避免滚动条跳动 */
}
/* ⚠️ 兼容性与滚动条准确性需要验证(不适合「必须精确」的场景) */
/* ② contain:隔离布局与样式计算的影响范围 */
.list-item {
contain: layout style paint;
}
/* ③ 避免行内「昂贵样式」 */
.item { box-shadow: 0 2px 8px rgba(0,0,0,.1); } /* 大量阴影 → 绘制成本高 */
.item { will-change: transform; } /* ⚠️ 滥用会导致内存暴涨 */
/* ④ 图片设置宽高(避免布局抖动) */
.item img { width: 40px; height: 40px; object-fit: cover; }
// ⑤ 只给「可视区域」的行绑定复杂逻辑(如 IntersectionObserver 懒加载图片)
function LazyImage({ src, alt }: { src: string; alt: string }) {
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLImageElement>(null);
useEffect(() => {
const io = new IntersectionObserver(([entry]) => {
if (entry?.isIntersecting) {
setVisible(true);
io.disconnect();
}
});
if (ref.current) io.observe(ref.current);
return () => io.disconnect();
}, []);
return <img ref={ref} src={visible ? src : PLACEHOLDER} alt={alt} loading="lazy" />;
}
// ⑥ 滚动监听要「节流」(否则每次滚动都触发 setState)
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const top = e.currentTarget.scrollTop;
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
setScrollTop(top);
rafRef.current = 0;
});
}, []);
八、常见坑
// ① 用 index 作 key(增删/排序时全量重建或状态错位)
// ② 用随机值作 key(灾难级)
// ③ 每行传内联箭头函数(memo 失效)
{items.map((it) => <MemoRow key={it.id} onDelete={() => del(it.id)} />)} // ❌
// ④ 渲染中做 filter/sort/map(每次渲染都重算)
// ⑤ 每次渲染创建新数组(让下游 memo 失效)
const visible = items.filter(...); // ❌ 新数组
<MemoList items={visible} /> // → memo 失效
// ⑥ 滚动监听没有节流(每次滚动都 setState → 每秒几十次渲染)
// ⑦ 虚拟滚动忘写「占位元素」(滚动条高度错误)
// ⑧ 虚拟滚动忘了「overscan」(快速滚动白屏)
// ⑨ 用「分批渲染」以为解决了性能问题(DOM 仍然会增长到全量)
// ⑩ 行内容太重(复杂 SVG、阴影、渐变)→ 绘制成本高
// ⑪ 图片没有宽高 → 加载后布局抖动
// ⑫ 在列表里用「深层嵌套的 flex/grid」→ 布局计算成本上升
// ⑬ 列表容器在「滚动时同时改样式」(读取尺寸 + 修改样式 → 布局抖动)
const h = el.offsetHeight; // 读
el.style.height = `${h}px`; // 写 → 触发 layout
// ⑭ 给所有元素加 will-change(内存暴涨、合成层过多)
// ⑮ 认为「虚拟滚动是万能的」(它不解决「单元格内容复杂」的问题)
九、面试延伸
- 「长列表卡顿怎么优化?」
分三层:① React 层——用「稳定唯一key」(让 React 正确复用节点)、给行组件加React.memo并保证 props 引用稳定(处理函数用useCallback或改用「data-*+ 事件委托」)、不要在渲染中做filter/sort(用useMemo);② DOM 层——虚拟滚动(只渲染可视区域的几十个节点,用占位元素撑高度),把 DOM 从「一万」降到「几十」;③ 布局/绘制层——简化行内样式(少用阴影/滤镜)、图片设定宽高、必要时用content-visibility: auto。先判断瓶颈在哪一层(Profiler 看渲染耗时,Performance 看 Layout/Paint)。 -
「虚拟滚动的原理是什么?」
核心是「只渲染可视区域的元素,用占位元素撑起总高度」。计算过程:startIndex = floor(scrollTop / rowHeight)、visibleCount = ceil(containerHeight / rowHeight)、offsetY = startIndex × rowHeight。渲染时用「一个高度为itemCount × rowHeight的占位 div」保证滚动条长度正确,再用transform: translateY(offsetY)把实际渲染的几十行偏移到正确位置。再加 overscan(多渲染几行)避免快速滚动时白屏。 -
「虚拟滚动有什么难点?」
三个:① 不等高的行——必须「测量并缓存每行高度 → 维护高度前缀和 → 二分查找定位 startIndex → 处理滚动锚定」,复杂度显著上升,生产上建议用成熟库(react-virtuoso、@tanstack/react-virtual);② 滚动性能——onScroll触发极频繁,必须用requestAnimationFrame节流,避免每次滚动都setState;③ 可访问性与搜索——Ctrl+F找不到未渲染的内容,屏幕阅读器也可能读不到,需要额外处理(如aria-*、提供「全部加载」的途径)。 -
「为什么
key对列表性能这么重要?」
因为key决定 React「复用还是重建」节点。key正确时,列表更新只需少量 DOM 操作(只更新变化的属性);key错误时(如随机值),React 会认为「所有元素都是新的」→ 卸载全部旧节点 + 创建全部新节点 → DOM 操作从「几个」变成「几千个」。对于长列表,这是最严重的性能问题;同时它还会导致「组件状态与输入内容错位」(见 4.5)。 -
「为什么说『每行传内联箭头函数』会毁掉列表性能?」
因为它让React.memo失效——每次父组件渲染都会创建新的函数对象,行组件的 props 浅比较失败,于是每一行都会重新渲染。对 1000 行的列表就是 1000 次无意义的渲染。解法有三种:① 用useCallback稳定处理函数并「只传 id」(行内自己调用);② 用「data-*+ 事件委托」(整列只有一个监听器、处理函数永久稳定);③ 把处理函数通过 Context 提供(如果层级深)。方案②通常是长列表的最优解。 -
「排序/过滤的结果每次都重建数组有问题吗?」
有。因为「新数组 = 新引用」→ 传给memo子组件时浅比较失败 → 子组件全部重渲染。此外「每次渲染都执行filter/sort」本身是浪费(尤其数据量大时)。所以要:① 用useMemo缓存「排序/过滤的结果」(依赖items与「排序/过滤条件」);② 如果结果是给memo列表用的,缓存后引用稳定,memo 才能生效;③ 数据量极大时(10 万+),考虑把排序放到 Web Worker 或服务端。 -
「分页、分批、虚拟滚动怎么选?」
按需求与成本:① 分页——资源最省,适合「数据本身适合分页浏览」(表格、后台列表),缺点是用户要翻页;② 分批渲染(先渲染 50 条、滚动加载更多)——实现简单,但DOM 仍会增长到全量,只适合「几千条以内」;③ 虚拟滚动——最彻底(DOM 始终只有几十个),适合「上万条」与「无限滚动」,但实现复杂、对不等高支持难。经验法则:< 200 条直接渲染;200~2000 条用分批或简单优化;> 2000 条用虚拟滚动。 -
「滚动监听为什么必须节流?」
因为scroll事件触发极其频繁(一次滚动可能触发上百次)。如果每次都在回调里setState,就会「每帧多次渲染」,直接拖垮性能。正确做法:① 用requestAnimationFrame节流(保证「每帧最多更新一次」);② 或改用IntersectionObserver(不监听滚动事件,由浏览器在「元素进入视口」时通知——这是无限滚动的更优方案);③ 在onScroll里不要读布局属性(offsetHeight等),否则会触发强制同步布局。
一句话速记
列表优化三层:React 层(稳定唯一
key、行组件memo+ 稳定处理函数(useCallback或data-*+ 事件委托)、渲染中不filter/sort(用useMemo,避免新数组让 memo 失效));DOM 层(虚拟滚动——占位元素撑高度 +translateY偏移 +overscan,不等高用成熟库);布局层(简化行内样式、图片定宽高、content-visibility);滚动监听必须用rAF节流(更优是IntersectionObserver);策略选择——< 200 条直接渲染、几千条分批、上万条虚拟滚动。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!