渲染优化三件套:React.memo、useMemo、useCallback 的使用边界

0 阅读9分钟

在前几篇关于 Hooks 的文章里,React.memouseMemouseCallback 已经零散出现了好几次。每次提到它们,都是作为某个具体问题的解法——"用 useCallback 稳定函数引用"、"用 React.memo 跳过子树 reconciliation"。

但放在一起审视,我发现自己其实没有想清楚几个问题:三者到底各自解决什么?它们是怎么协作的?什么时候该用,什么时候加了反而是负担?

这篇文章想把散落在之前内容里的碎片收拢起来,同时回答一个更实用的问题:面对一个有渲染性能问题的页面,优化的思路和顺序应该是什么?


一、三者分别解决什么问题

先把概念辨析清楚,再谈协作关系。

React.memo:跳过子组件的重渲染

React.memo 是一个高阶组件,它的作用是:当父组件重渲染时,如果子组件的 props 没有变化,跳过这个子组件的渲染。

// 环境:React
// 场景:用 React.memo 阻断父组件重渲染的"传染"

const ChildComponent = React.memo(({ title, count }) => {
  console.log('ChildComponent rendered');
  return <div>{title}: {count}</div>;
});

function ParentComponent() {
  const [tick, setTick] = useState(0);

  return (
    <div>
      <button onClick={() => setTick(t => t + 1)}>Tick: {tick}</button>
      {/* tick 变化,Parent 重渲染,但 Child 的 props 没变,会被跳过 */}
      <ChildComponent title="Score" count={42} />
    </div>
  );
}

React.memo 的默认比较行为是浅比较(shallow comparison):对每一个 prop 用 Object.is 做比较。基本类型(字符串、数字、布尔值)按值比较,引用类型(对象、数组、函数)按引用比较。

如果默认的浅比较不够用,可以传入第二个参数——一个自定义比较函数:

// 环境:React
// 场景:自定义比较逻辑,只关心 id 字段是否变化

const UserCard = React.memo(
  ({ user }) => <div>{user.name}</div>,
  (prevProps, nextProps) => {
    // 返回 true 表示"props 没变,跳过渲染"
    // 返回 false 表示"props 变了,需要重渲染"
    return prevProps.user.id === nextProps.user.id;
  }
);

注意自定义比较函数的语义和 shouldComponentUpdate 相反:返回 true 是"不需要更新",返回 false 是"需要更新"。这个方向容易搞反。

useMemo:缓存计算结果

useMemo 缓存的是一个计算值,只有依赖项变化时才重新计算:

// 环境:React
// 场景:缓存过滤列表的计算结果

function FilteredList({ items, filter }) {
  // ❌ 每次渲染都重新过滤,items 很大时有性能开销
  const filtered = items.filter(item => item.type === filter);

  // ✅ 只有 items 或 filter 变化时才重新计算
  const filteredMemo = useMemo(
    () => items.filter(item => item.type === filter),
    [items, filter]
  );

  return filteredMemo.map(item => <Item key={item.id} data={item} />);
}

useMemo 还有另一个常见用途:稳定对象引用,为 React.memo 创造条件:

// 场景:稳定传给子组件的配置对象引用

function Parent({ theme, lang }) {
  // ❌ 每次 Parent 渲染都产生新对象,即使 theme 和 lang 没变
  const config = { theme, lang };

  // ✅ theme 和 lang 都没变时,config 引用稳定
  const configMemo = useMemo(() => ({ theme, lang }), [theme, lang]);

  return <Child config={configMemo} />;
}

useCallback:缓存函数引用

useCallback 缓存的是一个函数,本质上等价于 useMemo(() => fn, deps),只是语义更明确:

// 环境:React
// 场景:稳定事件处理函数的引用,配合 React.memo 使用

function TodoList({ todos }) {
  const [filter, setFilter] = useState('all');

  // ❌ 每次渲染都创建新函数,TodoItem 的 memo 会失效
  const handleDelete = (id) => {
    // ...
  };

  // ✅ filter 没变时,handleDelete 引用稳定
  const handleDeleteMemo = useCallback((id) => {
    // ...
  }, [filter]); // 如果删除逻辑依赖 filter,需要放进依赖

  return todos.map(todo => (
    <TodoItem key={todo.id} todo={todo} onDelete={handleDeleteMemo} />
  ));
}

const TodoItem = React.memo(({ todo, onDelete }) => {
  // 只有 todo 或 onDelete 引用变化时才重渲染
  return <li onClick={() => onDelete(todo.id)}>{todo.text}</li>;
});

三者的关联

三者之间有一条清晰的协作逻辑:

image.png

React.memo 是"门卫",决定子组件要不要渲染;useMemouseCallback 是"稳定 props 引用"的工具,让门卫的比较真正有意义。三者需要配合使用才能发挥作用,单独使用任何一个往往效果有限。


二、React.memo 最常见的失效场景

React.memo 包裹了组件,但发现子组件依然每次都在重渲染——这是实际开发中很常见的困惑。失效的原因几乎都指向同一个根本:props 的引用在每次父组件渲染时都变了。

// 环境:React
// 场景:四种让 React.memo 失效的常见写法

const Child = React.memo(({ style, data, onClick, children }) => {
  console.log('Child rendered');
  return <div style={style} onClick={onClick}>{children}</div>;
});

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>

      <Child
        // ❌ 1. 每次渲染产生新对象字面量
        style={{ color: 'red' }}

        // ❌ 2. 每次渲染产生新数组
        data={[1, 2, 3]}

        // ❌ 3. 每次渲染产生新函数
        onClick={() => console.log('clicked')}
      >
        {/* ❌ 4. JSX 作为 children 传入,每次渲染产生新的 React element */}
        <span>Hello</span>
      </Child>
    </div>
  );
}

前三种情况可以用 useMemouseCallback 修复。第四种情况(children 是 JSX)相对少被注意——JSX 本质上是 React.createElement 的调用,每次渲染都会产生新的 React element 对象,引用自然不同。

对于 children 的情况,一种解法是把子内容提升到父组件外部,或者用 useMemo 包裹:

// 稳定 children 引用
const stableChildren = useMemo(() => <span>Hello</span>, []);
<Child>{stableChildren}</Child>

不过在我看来,如果需要到这种程度,往往是组件结构本身值得重新审视——这是后面"先问结构"原则的前置。


三、useCallback 和闭包陷阱的微妙关系

在第一篇 Hooks 文章里详细讨论过闭包陷阱。useCallback 在这里有一个容易被忽视的矛盾:

useCallback 缓存了函数引用,但函数体里捕获的变量值,依然是创建那一刻的快照。

// 环境:React
// 场景:useCallback 缓存引用,但内部值可能是旧的

function SearchBox({ onSearch }) {
  const [query, setQuery] = useState('');

  // query 在依赖数组里:query 变化时重新创建函数,引用会变
  const handleSearch = useCallback(() => {
    onSearch(query); // 能读到最新的 query
  }, [query, onSearch]);

  // ❌ 如果依赖数组写错了,缓存了旧的 query
  const handleSearchStale = useCallback(() => {
    onSearch(query); // query 永远是初始值 ''
  }, []); // 空依赖:函数引用稳定,但 query 是旧的

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

这里存在一个真实的张力:依赖数组越完整,函数引用越不稳定(缓存失效更频繁);依赖数组越精简,函数引用越稳定,但越容易读到旧值。

解决这个矛盾的方式在 Hooks 篇里提到过——用 useRef 保存最新值,在回调里读取 ref.current,同时保持函数引用稳定:

// 场景:引用稳定 + 始终读取最新值

function SearchBox({ onSearch }) {
  const [query, setQuery] = useState('');
  const queryRef = useRef(query);

  useEffect(() => {
    queryRef.current = query; // 每次 query 更新,同步到 ref
  }, [query]);

  // 依赖数组为空,函数引用永远稳定
  // 同时通过 ref 读取最新的 query
  const handleSearch = useCallback(() => {
    onSearch(queryRef.current);
  }, []);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

这个模式有一定复杂度,不是所有场景都需要。大多数时候,把依赖项补全是更直接的选择。


四、缓存的代价:什么时候加了反而更差?

这是实际开发中最容易被忽视的一点:useMemouseCallback 本身是有成本的。

每次渲染,React 需要:

  • 取出上一次的依赖数组
  • Object.is 逐个比较新旧依赖
  • 如果没变,返回缓存的值;如果变了,重新执行并缓存

这些操作不是免费的。对于轻量计算,这个开销可能比直接重新计算更大。

// 环境:React
// 场景:没必要的 useMemo,增加了开销却没有收益

function Component({ a, b }) {
  // ❌ 加法运算极其轻量,useMemo 的开销反而更大
  const sum = useMemo(() => a + b, [a, b]);

  // ✅ 直接计算
  const sumDirect = a + b;

  // ✅ 值得用 useMemo 的场景:复杂计算 + 结果被 memo 子组件使用
  const processedData = useMemo(
    () => largeArray.filter(Boolean).map(transform).sort(comparator),
    [largeArray]
  );
}

一个我觉得比较实用的判断框架,需要同时满足两个条件,才值得考虑 useMemouseCallback

条件一:计算本身有明显开销(复杂过滤/排序/转换),或者需要稳定引用(传给 memo 组件、放入 useEffect 依赖)。

条件二:这个组件/计算确实存在不必要的重复执行(最好通过 Profiler 确认,而不是靠猜)。

两个条件缺一,加缓存的收益就存疑了。


五、系统思路:有渲染性能问题,应该怎么排查?

这是把三件套放回真实场景的问题。我的理解是,优化不应该从"加 memo"开始,而应该有一个更有层次的排查顺序。

第一步:先用 Profiler 定位,而不是靠感觉

Profiler 能区分两类不同性质的问题:单次渲染太慢(Flamegraph),和渲染次数太多(Ranked Chart)。两类问题的解法方向不同,混淆了会走弯路。

第二步:先问组件结构,再问缓存 API

这是 Dan Abramov 在 Before You memo() 里强调的思路,我觉得非常值得内化。

很多时候,渲染问题的根源不是"缺少缓存",而是"组件结构导致不相关的状态变化触发了不必要的重渲染":

// 环境:React
// 场景:通过拆分组件解决重渲染,而不是加 memo

// ❌ 原始结构:SlowComponent 和高频更新的状态在同一个组件里
function App() {
  const [color, setColor] = useState('red'); // 高频变化

  return (
    <div style={{ color }}>
      <input onChange={e => setColor(e.target.value)} />
      <SlowComponent /> {/* 每次 color 变化都会重渲染 */}
    </div>
  );
}

// ✅ 方案一:状态下移,把高频状态隔离到小组件里
function ColorInput({ children }) {
  const [color, setColor] = useState('red');
  return (
    <div style={{ color }}>
      <input onChange={e => setColor(e.target.value)} />
      {children} {/* children 从外部传入,不受 color 影响 */}
    </div>
  );
}

function App() {
  return (
    <ColorInput>
      <SlowComponent /> {/* 不再因为 color 变化而重渲染 */}
    </ColorInput>
  );
}

这个重构不需要任何缓存 API,但彻底解决了问题。状态下移内容提升(lifting content up)是两种常见的结构调整手段,值得在加 memo 之前先考虑。

第三步:确认 memo 失效的根因,再决定是否用 useMemo / useCallback

只有在结构调整之后问题依然存在,且通过 Profiler 确认了具体的重渲染路径,才到三件套登场的时候。此时的顺序是:

  1. React.memo 包裹需要保护的子组件
  2. 排查是哪个 prop 引用不稳定,导致 memo 失效
  3. useMemouseCallback 稳定对应的 prop

这个顺序很重要——不要在没有 React.memo 的情况下先加 useCallback。子组件没有 memo 保护,函数引用稳不稳定根本无所谓,因为父组件重渲染时子组件无论如何都会跟着渲染。

// 这样做没有意义:子组件没有 memo,useCallback 不产生任何收益
function Parent() {
  const handleClick = useCallback(() => {}, []); // 引用稳定了,但没人用这个稳定性
  return <Child onClick={handleClick} />; // Child 没有 memo,照样每次都渲染
}

六、小结

把三件套的关系用一句话概括:React.memo 是优化的实施点useMemouseCallback 是让 React.memo 真正有效的配套工具

但更重要的,可能是这三件套在整个优化流程里的位置:它们应该是"结构调整之后、数据确认有问题之后"的手段,而不是开发时的默认配置。

回顾这个系列写到现在,有一条隐约的主线:React 的很多设计——Hooks 的调用顺序、Context 的广播机制 —— 都是在某种约束下做的取舍。三件套的存在,本质上也是在"函数组件每次渲染都重新执行"这个约束下,给开发者提供的逃生舱。

理解了约束,才能更好地判断什么时候需要逃生,什么时候其实可以不逃。


参考资料