React性能优化实战:深入掌握useMemo的正确用法与常见误区

码农园园 框架 阅读 1,885
赞 23 收藏
二维码
手机扫码查看
反馈

useMemo 用错了,组件疯狂重渲染

上周改一个数据看板页面,列表里每行要显示一堆计算后的指标,比如转化率、环比、同比这些。我一开始图省事,直接在组件里写了一堆计算逻辑,结果一滚动就卡得不行。打开 React DevTools 一看,好家伙,随便动一下鼠标,整个列表几十个子组件全在重新渲染,明明 props 都没变。

React性能优化实战:深入掌握useMemo的正确用法与常见误区

我第一反应是:是不是父组件传了新对象下来?查了下,父组件确实用 useState 存了个 filter 对象,每次筛选条件一变就 setFilter({...}),导致子组件的 props 引用变了。那行,我给子组件加个 React.memo 包一下,应该就稳了。

结果没用。子组件还是疯狂 re-render。这时候我才意识到:问题不在 props,而在组件内部的计算逻辑!

折腾了半天,发现 useMemo 没加依赖

我原来的代码大概是这样的:

const DataRow = ({ rawData, filters }) => {
  const processedData = {
    conversionRate: rawData.clicks / rawData.views,
    weekOverWeek: (rawData.current - rawData.lastWeek) / rawData.lastWeek,
    // 还有七八个类似字段...
  };

  return <div>{/* 渲染 processedData */}</div>;
};

每次组件 render,processedData 都会重新算一遍,哪怕 rawDatafilters 根本没变。虽然计算本身不复杂,但列表有 100+ 行,每行都算,加起来就卡了。

我心想:这不就是 useMemo 的典型场景吗?赶紧加上:

const DataRow = ({ rawData, filters }) => {
  const processedData = useMemo(() => {
    return {
      conversionRate: rawData.clicks / rawData.views,
      weekOverWeek: (rawData.current - rawData.lastWeek) / rawData.lastWeek,
    };
  }, []); // 注意!这里依赖数组是空的!

  return <div>{/* 渲染 */}</div>;
};

结果更糟了——数据完全不更新了!筛选条件一换,界面上的数字还是老的。我愣了三秒,突然拍大腿:**依赖数组写成空的,等于告诉 React “这个值永远不用变”**,当然不会重新计算了。

这里我踩了个经典坑:以为 useMemo 只要“包一下”就行,忘了依赖项必须和计算逻辑里用到的所有变量对齐。rawData 变了,但依赖里没写,缓存就失效了。

核心代码就这几行

改起来其实很简单,把依赖补全就行:

const DataRow = ({ rawData, filters }) => {
  const processedData = useMemo(() => {
    // 注意:这里只用了 rawData,没用 filters,所以 filters 不用放依赖
    return {
      conversionRate: rawData.clicks / rawData.views || 0,
      weekOverWeek: rawData.lastWeek ? (rawData.current - rawData.lastWeek) / rawData.lastWeek : 0,
    };
  }, [rawData]); // 关键:依赖 rawData

  return <div>
    转化率: {(processedData.conversionRate * 100).toFixed(2)}%
    环比: {(processedData.weekOverWeek * 100).toFixed(2)}%
  </div>;
};

加上 [rawData] 后,只有当 rawData 引用变化时,才会重新计算 processedData。如果父组件传的是同一个 rawData 对象(比如只是 state 里其他字段变了),子组件就不会触发昂贵的计算。

但等等,这里还有个细节:rawData 是从父组件传下来的,如果父组件每次 setState 都生成新对象(比如用展开运算符 {...oldData}),那 rawData 引用还是会变,useMemo 就白用了。所以还得配合父组件优化,比如用 useCallback 或者确保数据结构不变时复用引用。不过那是另一个故事了,这次先解决当前问题。

踩坑提醒:这三点一定注意

这次折腾让我又复习了一遍 useMemo 的几个关键点,分享出来免得你再踩:

  • 依赖数组不能漏:计算函数里用到的每个外部变量(props、state、其他 hooks 返回值)都必须放进依赖数组。漏一个,缓存就可能失效或过期。
  • 不要为了“性能”滥用 useMemo:如果计算本身很快(比如只是字符串拼接),或者组件本身 render 不频繁,加 useMemo 反而增加内存开销。我之前就有个同事,连 const title = 'Hello ' + name 都包 useMemo,纯属过度优化。
  • useMemo 不是万能防重渲染盾牌:它只缓存值,不阻止组件 render。如果父组件 re-render 导致子组件 props 引用变化,即使子组件内部用 useMemo 缓存了,组件本身还是会执行(只是内部计算跳过了)。要彻底避免子组件 render,还得配合 React.memo + 稳定的 props 引用。

另外,我后来还试了下把整个计算逻辑抽成独立函数,配合 useMemo 使用,代码更清晰:

const computeMetrics = (rawData) => {
  return {
    conversionRate: rawData.clicks / rawData.views || 0,
    weekOverWeek: rawData.lastWeek ? (rawData.current - rawData.lastWeek) / rawData.lastWeek : 0,
  };
};

const DataRow = ({ rawData }) => {
  const processedData = useMemo(() => computeMetrics(rawData), [rawData]);
  // ...render
};

这样单元测试也方便,直接测 computeMetrics 函数就行。

改完后还有一两个小问题,但无大碍

优化完后,列表滚动流畅多了,DevTools 里子组件也不再无故闪烁。不过我发现,当 rawData 里某个字段为 0 时,比如 views: 0conversionRate 会变成 NaN。虽然界面显示 .toFixed(2) 会变成 “NaN”,但业务上这种情况很少(理论上 views 不可能为 0),我就加了个 || 0 简单处理,没做更复杂的边界校验——毕竟优先级不高,先保证主流程顺畅。

另外,如果 rawData 是深层嵌套对象,且只有某个叶子节点变了,但父对象引用没变,useMemo 也不会触发更新。这时候可能需要 useDeepCompareMemo 之类的库,或者用 Immutable.js。不过我们项目数据结构扁平,暂时没这个问题。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有更优雅的方式处理这种大量派生数据的场景?或者你遇到过 useMemo 导致的其他奇怪问题?

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论