最大的 React 性能杀手不就是你?

0 阅读5分钟

你好,我是冴羽

你写的 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 官方文档也一直强调:先测量,别瞎优化

但当这三个条件同时出现时,你就要注意了:

  1. 父组件频繁重新渲染(比如搜索输入、滚动状态、筛选器)

  2. 子组件或子树足够大,重新渲染的成本很高

  3. 你已经加了 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 比较每次都是失败的。

Browser window showing the ProductRow list with Render count badges.

React DevTools Profiler tab showing a Flamegraph for ProductList re-processing.

我还用了 why-did-you-render 这个工具来诊断。它直接告诉我:

  • props.style 是“内容相同但对象不同”

  • props.onAddToCart 是“同名但函数不同”

这就是引用不匹配的铁证。

Browser Console output from why-did-you-render confirming reference mismatch.

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 倍

React DevTools Profiler after fix showing ProductList at 6ms

App UI showing nonchanging Render count despite active searching

5.所以什么时候该优化,什么时候别管?

如果子组件依赖引用相等来跳过渲染,那父组件就必须传稳定的引用。如果子组件根本没 memo,或者渲染成本很低,那你稳定引用也没意义。

我的建议是:

  • 静态值优先外提: 如果一个对象永远不变,把它移到组件外部,零成本解决问题

  • 动态值按需 memo: 只在子组件真的能从稳定引用中受益时,才用 useCallbackuseMemo

  • 先 Profile 再优化: 别瞎猜,用 React DevTools Profiler 测一下再说

React 官方文档也是这么说的:能外提就外提,需要缓存再用 Hooks。

关于 React Compiler:

你可能听说过 React Compiler 会自动帮你做这些优化。确实,它能在编译时自动 memoize 很多代码,减少手动写 useMemouseCallback 的需求。

但这不意味着引用稳定性就不重要了。React Compiler 的文档也说了:useMemouseCallback 在某些场景下仍然有用,比如你需要精确控制 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 干货。