列表缓存实战:提升前端性能的关键技巧与踩坑经验
列表滚动卡成PPT?缓存没做对
上周改一个商品列表页,用户反馈“滑动像在翻PPT”,一屏一卡。我本地跑起来也确实,手指一划,画面停顿半秒才跟上——典型的滚动性能问题。第一反应:是不是没做虚拟滚动?但仔细一看,列表总共就20条数据,根本用不着那么重的方案。后来折腾了半天才发现,问题出在列表项的缓存策略上。
一开始以为是渲染问题,结果白忙活
我先以为是 React 重新渲染太频繁,加了 React.memo 包裹列表项组件,结果没卵用。又怀疑是 CSS 动画或 transform 干扰,把所有动画关掉,还是卡。甚至试了给列表容器加 will-change: transform,照样卡顿。这时候我已经有点烦躁了,明明数据量不大,怎么这么卡?
打开 Chrome DevTools 的 Performance 面板录了一次滚动,发现每次滚动都会触发大量 DOM 节点的 reflow 和 repaint。奇怪的是,列表项的 key 是唯一的(用的 id),理论上不应该重复创建节点。但仔细看火焰图,发现每次滚动后,整个列表都在重新 mount/unmount——这不对劲!
踩坑:key 写错了位置
翻代码发现,我在 map 里是这么写的:
{list.map(item => (
<div key={item.id}>
<MyListItem data={item} />
</div>
))}
看起来没问题对吧?但问题在于,MyListItem 组件内部其实还有一层逻辑:根据 item.type 渲染不同类型的子组件。而这些子组件没有自己的 key,导致 React 在 diff 时无法正确复用 DOM 节点。更糟的是,某些子组件里还有条件渲染,比如:
// MyListItem 内部
{item.type === 'A' ? <TypeAComponent /> : <TypeBComponent />}
当列表滚动时,即使外层 div 的 key 正确,但内部组件结构变化,React 会认为整个子树都变了,于是直接卸载再重建。这就解释了为什么每次滚动都触发大量 mount/unmount。
这里我踩了个大坑:以为只要外层有 key 就行,其实嵌套组件的 key 也要合理设置,尤其是动态类型组件。
解决方案:稳定 key + 缓存组件实例
改法其实很简单,两步:
- 确保每个列表项的根元素有稳定且唯一的 key(这个我本来就有)
- 给内部动态组件也加上 key,而且 key 要包含类型信息
改成这样:
{list.map(item => (
<div key={item.id}>
{item.type === 'A' ? (
<TypeAComponent key={A-${item.id}} data={item} />
) : (
<TypeBComponent key={B-${item.id}} data={item} />
)}
</div>
))}
加了 key={A-${item.id}} 之后,React 就能区分不同类型的组件实例,即使切换类型也不会误判为同一个节点。滚动卡顿立马消失。
但我觉得还不够稳,因为如果未来加更多类型,容易漏掉 key。所以最后我封装了一个统一的 render 函数:
const renderItem = (item) => {
const ComponentMap = {
A: TypeAComponent,
B: TypeBComponent,
// ...其他类型
};
const Component = ComponentMap[item.type];
if (!Component) return null;
return <Component key={${item.type}-${item.id}} data={item} />;
};
// 使用
{list.map(item => (
<div key={item.id}>
{renderItem(item)}
</div>
))}
这样新增类型时,只要注册到 ComponentMap 里,key 自动带上类型前缀,不容易出错。
额外优化:用 useMemo 缓存列表项 props
虽然卡顿解决了,但滚动时还是有点小抖动。我注意到 MyListItem 的 data prop 每次都传整个 item 对象,而父组件状态更新时(比如筛选条件变化),即使 item 本身没变,引用也会变,导致子组件不必要的 re-render。
于是给每个 item 的 props 做了缓存:
const getItemProps = useCallback((item) => {
return {
id: item.id,
name: item.name,
price: item.price,
// 只取需要的字段,避免传整个对象
};
}, []);
// 渲染时
<TypeAComponent
key={A-${item.id}}
{...getItemProps(item)}
/>
或者更彻底一点,用 useMemo 缓存整个列表的 props 数组:
const memoizedList = useMemo(() => {
return list.map(item => ({
...item,
key: ${item.type}-${item.id}
}));
}, [list]);
不过这个要看具体场景,如果 list 本身变化频繁,useMemo 可能反而增加开销。我这里 list 更新不频繁,加了之后 FPS 从 50+ 提升到稳定 60。
核心代码就这几行
总结下来,解决列表缓存问题的关键就三点:
- key 必须唯一且稳定,尤其在动态组件中要包含类型标识
- 避免传递大对象作为 prop,只传必要字段
- 用 React.memo + useMemo 减少无效渲染
完整示例代码:
import React, { memo, useMemo, useCallback } from 'react';
const TypeAComponent = memo(({ id, name }) => {
return <div>A: {name} (ID: {id})</div>;
});
const TypeBComponent = memo(({ id, price }) => {
return <div>B: ${price} (ID: {id})</div>;
});
const ListItem = ({ item }) => {
const ComponentMap = {
A: TypeAComponent,
B: TypeBComponent,
};
const Component = ComponentMap[item.type];
if (!Component) return null;
// 只传递必要字段
const props = useMemo(() => {
if (item.type === 'A') {
return { id: item.id, name: item.name };
} else {
return { id: item.id, price: item.price };
}
}, [item]);
return <Component key={${item.type}-${item.id}} {...props} />;
};
const List = ({ list }) => {
return (
<div>
{list.map(item => (
<div key={item.id} style={{ padding: '10px', borderBottom: '1px solid #eee' }}>
<ListItem item={item} />
</div>
))}
</div>
);
};
改完后还有个小问题
上线后发现,当列表项高度不一致时(比如 TypeA 高 50px,TypeB 高 100px),快速滚动偶尔还是会轻微卡顿。查了下是因为浏览器 layout 计算成本高。这个问题其实属于另一个范畴了(需要固定高度或使用 ResizeObserver),但当前业务场景下影响不大,就没深究。毕竟列表总共就几十条,用户不会疯狂上下滑动。
所以这个方案不是 100% 完美,但在简单列表场景下,够用、简单、有效。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有更自动化的 key 生成方式?或者在高度不一致时如何低成本优化?这类问题其实挺常见的,后续我打算再写一篇关于“非均匀列表滚动优化”的实战记录。

暂无评论