你好,我是冴羽。
你写的 React.memo 可能根本没用!
你可能觉得自己已经做了性能优化——给组件包了 React.memo,给回调加了 useCallback,给计算值用了 useMemo。
但如果你在传递 props 时写成这样:
<UserCard
style={{ padding: 16, borderRadius: 8 }}
onSelect={() => handleSelect(user.id)}
config={{ showAvatar: true, compact: false }}
user={user}
/>
恭喜你,你的优化白做了~
1. 为什么 React.memo 会失效?
为什么呢?
因为 React 比较的是引用,不是内容。
当你写 React.memo 包裹一个组件时,React 会在父组件重新渲染时比较新旧 props。
如果所有 props 都“相等”, React 就跳过子组件的渲染,直接复用上次的结果。
问题来了——React 用什么判断“相等”?
答案是 Object.is,也就是引用相等:
Object.is({ padding: 16 }, { padding: 16 }) // false
Object.is(() => {}, () => {}) // false
即使内容完全一样,引用不同就是不同。 React 就会认为 props 变了,然后重新渲染子组件。
这就是为什么内联对象和回调函数是隐藏的性能杀手——它们每次渲染都会创建新引用,让 React.memo 形同虚设。
以前你以为 React.memo 在帮你省性能,实际上,每次都在重新渲染。
2. 什么时候这个问题会要命?
我得先说清楚:不是所有内联 props 都是性能 bug。
如果子组件很轻量,渲染频率很低,也没用 memo,那内联写法完全没问题。
React 官方文档也一直强调:先测量,别瞎优化。
但当这三个条件同时出现时,你就要注意了:
-
父组件频繁重新渲染(比如搜索输入、滚动状态、筛选器)
-
子组件或子树足够大,重新渲染的成本很高
-
你已经加了 memo,期待 React 跳过不必要的渲染
在这种场景下,不稳定的内联引用不是“增加一点开销”——它会直接废掉了你精心设计的优化!
更要命的是,这个问题不会报错,不会警告,UI 照常工作。
它只会悄悄地让你的列表过滤器变卡、输入延迟、火焰图爆炸。
3. 我做了个实验:200 行列表的性能崩溃
为了证明这个问题有多严重,我搭了个测试场景:
-
一个可搜索的商品列表
-
200 个用
React.memo包裹的ProductRow组件 -
每个组件接收相同的逻辑值,但每次父组件渲染都传入新的对象和函数引用
代码长这样:
{filteredProducts.map(p => (
<ProductRow
key={p.id}
product={p}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px 20px',
borderBottom: '1px solid #eee'
}}
onAddToCart={(id) => console.log('Added:', id)}
/>
))}
结果惨不忍睹:
-
输入 6 个字符后,每个可见行显示
Renders: 14 -
React DevTools Profiler 显示:单次按键触发的渲染耗时 243.9ms
-
火焰图里,所有 200 个子组件全部点亮
也就是说,React.memo 完全失效了。
因为 style 对象和 onAddToCart 回调每次都是新创建的,memo 的 props 比较每次都是失败的。
我还用了 why-did-you-render 这个工具来诊断。它直接告诉我:
-
props.style是“内容相同但对象不同” -
props.onAddToCart是“同名但函数不同”
这就是引用不匹配的铁证。
4. 怎么改?很简单
要让 React 的 bailout 机制生效,你需要稳定的引用。
改法 1:把静态对象移到模块作用域
// ✅ 在组件外部定义,只创建一次
const ROW_STYLE = {
display: 'flex',
justifyContent: 'space-between',
padding: '12px 20px',
borderBottom: '1px solid #eee'
};
export default function ProductList() {
// ...
return (
<ProductRow
style={ROW_STYLE}
// ...
/>
);
}
改法 2:用 useCallback 包裹动态回调
export default function ProductList() {
const [searchTerm, setSearchTerm] = useState('');
// ✅ 依赖数组为空,函数引用保持稳定
const handleAddToCart = useCallback((id) => {
console.log('Added:', id);
}, []);
return (
<ProductRow
onAddToCart={handleAddToCart}
// ...
/>
);
}
修复后的效果:
-
ProductList渲染时间从 243.9ms 降到 6ms -
无论怎么输入,渲染计数始终停在
2 -
why-did-you-render不再报警
性能直接提升 40 倍!
5.所以什么时候该优化,什么时候别管?
如果子组件依赖引用相等来跳过渲染,那父组件就必须传稳定的引用。如果子组件根本没 memo,或者渲染成本很低,那你稳定引用也没意义。
我的建议是:
-
静态值优先外提: 如果一个对象永远不变,把它移到组件外部,零成本解决问题
-
动态值按需 memo: 只在子组件真的能从稳定引用中受益时,才用
useCallback和useMemo -
先 Profile 再优化: 别瞎猜,用 React DevTools Profiler 测一下再说
React 官方文档也是这么说的:能外提就外提,需要缓存再用 Hooks。
关于 React Compiler:
你可能听说过 React Compiler 会自动帮你做这些优化。确实,它能在编译时自动 memoize 很多代码,减少手动写 useMemo 和 useCallback 的需求。
但这不意味着引用稳定性就不重要了。React Compiler 的文档也说了:useMemo 和 useCallback 在某些场景下仍然有用,比如你需要精确控制 Effect 的依赖。
所以即使用了 Compiler,理解引用不稳定如何影响重新渲染,依然是必修课。
6. 最后一句话
内联对象和内联回调不是“错误代码”。大部分时候,它们就是普通的 JSX 表达式。
但当它们穿过 memo 边界时,游戏规则就变了。
这个问题值得更多关注,因为它太容易在生产环境里悄悄发生——代码看起来很干净,应用运行正常,但你以为买到的性能优化其实根本没生效。
所以给想写快速 React 应用的团队一个建议:
先 Profile。如果 memo 的子树还在频繁渲染,先检查 props,别急着怪 React。把静态对象移出渲染路径。只在子组件真正受益时才 memoize 回调。用 React DevTools 和 Why Did You Render 确认到底什么变了、为什么变。
坚持这么做,React.memo** 就不再是装饰性的性能代码,而是真正在干活。**
我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。
欢迎围观我的“网页版朋友圈“,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。