跳到主内容

React 重渲染排查实录:用 useMemo 系列把渲染压到最小 ​

问题背景:列表没变化,为什么整棵子树还在渲染? ​

在一次后台管理页性能排查中,我们遇到一个典型现象:用户只在顶部搜索框输入一个字符,页面下方的复杂表格、筛选器和图表全部重新执行 render。输入连续触发时,主线程被占用,输入框明显掉帧。

最初团队的直觉是“给所有组件都加上 React.memo,给所有函数都包上 useCallback,给所有对象都加上 useMemo”。但上线后效果并不稳定,某些地方甚至因为缓存计算和依赖维护变得更慢,也更容易出现闭包旧值问题。

React 的 re-render 并不等于 DOM 一定更新,但如果渲染过程中包含大列表、复杂对象转换、图表实例计算或大量子组件协调,重复 render 仍然会显著占用主线程。useMemo 系列 API 的价值不是“消灭所有 re-render”,而是在明确成本和收益后,阻断不必要的引用变化与子树渲染。

React re-render 的核心触发链路 ​

排查重渲染时,先区分三类原因:状态变化、父组件渲染、上下文变化。很多误判来自只盯着 useState,却忽略了 props 引用和 Context。

状态更新useState / reducer当前组件重渲染重新创建 JSX子组件默认渲染即使 props 看似没变引用型 props 变化对象 / 数组 / 函数Context value 变化所有消费者渲染

关键结论是:React.memo 只能做浅比较。父组件每次渲染时新建的对象、数组和函数,如果直接传给被 memo 包裹的子组件,浅比较仍然会失败。

方案对比:useMemo、useCallback 与 React.memo 分别解决什么 ​

| API | 主要作用 | 适合场景 | 不适合场景 | | --- | --- | --- | --- || | useMemo | 缓存计算结果或对象引用 | 大数组过滤排序、稳定配置对象、派生数据成本高 | 简单字符串、数字、布尔值 | | useCallback | 缓存函数引用 | 传递给 memo 子组件的回调、作为 effect 依赖的函数 | 只在当前组件 JSX 中使用的普通函数 | | React.memo | 跳过 props 浅比较相同的子组件渲染 | 渲染成本高、输入稳定、输出纯粹的组件 | props 经常变化,或组件本身很轻 | | Context 拆分 | 缩小消费者范围 | 高频状态与低频状态混杂 | 把所有全局状态塞进一个 Provider |

优化顺序建议是:先定位真实热点,再减少状态作用域,然后稳定引用,最后用 memo 阻断渲染。不要把 API 顺序反过来使用。

可运行示例:一个搜索页的最小化重渲染改造 ​

下面示例使用 React 19。保存为 demo.jsx 后,可在支持 JSX 的 React 项目中直接运行。示例故意加入 renderCount,便于观察组件何时重新渲染。

javascript
import React, { memo, useCallback, useMemo, useState } from 'react';

// 模拟 5000 条数据
const allUsers = Array.from({ length: 5000 }, (_, index) => ({
  id: index,
  name: `用户 ${index}`,
  role: index % 3 === 0 ? 'admin' : 'member',
}));

// 复杂子组件:渲染成本高,适合 React.memo
const UserTable = memo(function UserTable({ users, onSelect }) {
  console.count('[UserTable] render');

  return (
    <div>
      <h3>用户列表:{users.length} 条</h3>
      <ul>
        {users.slice(0, 50).map((user) => (
          <li key={user.id} onClick={() => onSelect(user.id)}>
            {user.name} - {user.role}
          </li>
        ))}
      </ul>
    </div>
  );
});

function FilterBar({ keyword, onKeywordChange }) {
  console.count('[FilterBar] render');
  return (
    <input
      value={keyword}
      placeholder="输入关键字搜索"
      onChange={(event) => onKeywordChange(event.target.value)}
    />
  );
}

export default function UserSearchPage() {
  const [keyword, setKeyword] = useState('');
  const [selectedId, setSelectedId] = useState(null);

  // useMemo 缓存高成本派生结果
  // 只有 keyword 改变时才重新过滤
  const filteredUsers = useMemo(() => {
    console.time('filter users');
    const result = allUsers.filter((user) => user.name.includes(keyword));
    console.timeEnd('filter users');
    return result;
  }, [keyword]);

  // useCallback 保持传给 memo 子组件的函数引用稳定
  const handleSelect = useCallback((id) => {
    setSelectedId(id);
  }, []);

  return (
    <main>
      <FilterBar keyword={keyword} onKeywordChange={setKeyword} />
      <p>当前选中:{selectedId ?? '无'}</p>
      <UserTable users={filteredUsers} onSelect={handleSelect} />
    </main>
  );
}

这个版本中,输入关键字会重新计算 filteredUsers,因此表格需要刷新;但点击某一行只更新 selectedId 时,filteredUsers 和 handleSelect 的引用都没有变化,UserTable 会被 React.memo 跳过。

如果不做缓存,代码可能是下面这样:

javascript
// 反例:父组件每次渲染都创建新数组和新函数
const filteredUsers = allUsers.filter((user) => user.name.includes(keyword));
const handleSelect = (id) => setSelectedId(id);

此时即使 UserTable 被 React.memo 包裹,点击选中用户后,users 与 onSelect 都是新引用,浅比较失败,大表格仍会重新渲染。

用 Profiler 量化收益,而不是凭感觉加缓存 ​

可以使用 React DevTools Profiler 录制一次输入和一次点击,重点查看三个指标:每个组件的 render duration、为什么重新渲染、提交后是否发生真实 DOM 更新。也可以在开发环境用 Profiler 组件打印耗时。

javascript
import { Profiler } from 'react';

function RenderProfiler({ children }) {
  const handleRender = (id, phase, actualDuration) => {
    console.log(`[Profiler] ${id} / ${phase} / ${actualDuration.toFixed(2)}ms`);
  };

  return (
    <Profiler id="UserSearchPage" onRender={handleRender}>
      {children}
    </Profiler>
  );
}

使用时将页面包一层:

javascript
<RenderProfiler>
  <UserSearchPage />
</RenderProfiler>

判断标准要具体:如果一次 render 只有 0.2ms,增加 memo 带来的依赖复杂度并不划算;如果某个表格 render 超过 10ms,且点击无关状态时重复出现,就值得用缓存和组件拆分处理。我们的目标是把交互路径上的无效渲染从数十毫秒降到 1ms 量级,而不是追求控制台一次 render 都不出现。

Context 高频更新:只拆 value 还不够 ​

另一个高频坑是 Context。下面的 value 是内联对象,Provider 的 value 每次都是新引用:

javascript
// 反例:任何 App 状态变化都会通知所有消费者
<AppContext.Provider value={{ keyword, setKeyword, theme, setTheme }}>
  {children}
</AppContext.Provider>

即使消费者只使用 theme,keyword 输入时也会重新渲染。仅给 value 套 useMemo 只能保证“状态不变时引用稳定”,不能阻止高频字段影响低频消费者。更合理的做法是按变化频率拆分 Context:

javascript
const ThemeContext = React.createContext(null);
const SearchContext = React.createContext(null);

export function AppProviders({ children }) {
  const [theme, setTheme] = useState('light');
  const [keyword, setKeyword] = useState('');

  const themeValue = useMemo(() => ({ theme, setTheme }), [theme]);
  const searchValue = useMemo(() => ({ keyword, setKeyword }), [keyword]);

  return (
    <ThemeContext.Provider value={themeValue}>
      <SearchContext.Provider value={searchValue}>
        {children}
      </SearchContext.Provider>
    </ThemeContext.Provider>
  );
}

这样主题切换组件不会因为搜索输入而重渲染。对于更高频的鼠标位置、拖拽坐标等状态,还应考虑局部状态、外部 store 或选择器订阅,而不是继续扩大 Context。

避坑总结:useMemo 系列最常见的 4 个错误 ​

1. 依赖数组漏项导致旧数据闭包 ​

常见现象是:页面状态已经变化,但 useMemo 或 useCallback 中读到的仍是旧值;有人随后用 JSON.stringify 或关闭 ESLint 规则强行压制。正确做法是把使用到的响应式值完整写入依赖,并将真正稳定的函数下沉到组件外或使用 useCallback。

javascript
// 错误:keyword 被使用却没有进入依赖
const result = useMemo(() => filterUsers(keyword), []);

// 正确
const result = useMemo(() => filterUsers(keyword), [keyword]);

2. 把简单值也全部缓存,增加维护成本 ​

useMemo(() => true, [])、useCallback 包裹只传给原生 DOM 的函数,通常没有收益。缓存本身需要保存依赖、比较依赖并保存结果。组件很轻时,重新执行函数往往比缓存更便宜。

3. React.memo 包了,但 props 引用每次都变 ​

典型现象是 Profiler 显示子组件仍然渲染,原因是父组件传入内联对象或内联函数。修正方法是把稳定对象用 useMemo 保存、把回调使用 useCallback 固定,或者将组件设计为接收原始值而非临时对象。

4. 在 memo 回调中写深比较,反而拖慢渲染 ​

有些项目用 lodash.isEqual 作为 React.memo 第二参数,试图比较大对象。这样每次父组件渲染都要深度遍历,成本可能高于直接 render。更好的方式是规范化数据结构、传入稳定 id、拆分组件,让浅比较能够生效。

结语 ​

最小化重渲染不是“useMemo 全家桶滥用”,而是一条清晰链路:用 Profiler 找热点,判断状态归属,缩小 Context 影响范围,稳定高成本子组件的 props 引用,再用 React.memo 阻断无效渲染。

当你能解释每一次 re-render 是谁触发、是否必要、消耗多少时间时,useMemo、useCallback 和 React.memo 才会从心理安慰变成真正可靠的性能工具。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。