讲解

React 的默认行为是「父组件渲染,子组件全部跟着渲染」。大部分时候这没问题(diff 很快),但当子树很大或渲染昂贵时就要介入。三件套各司其职:

memo 包住组件——props 浅比较没变就跳过重渲染:const ExpensiveList = memo(function ExpensiveList({ items }) {...})。注意它比较的是 props 引用,父组件每次渲染新建的对象/函数 props 会让 memo 失效,于是需要后两件套配合。

useMemo 缓存计算结果:const sorted = useMemo(() => [...items].sort(...), [items]),依赖没变就不重算,返回同一个引用。两个用途:避免昂贵计算每次渲染都跑;给 memo 子组件提供稳定的对象 props。

useCallback 缓存函数:const handle = useCallback((id) => ..., [deps]),等价于 useMemo 返回函数。用途单一:给 memo 子组件传稳定回调,或放进 effect 依赖数组。

最重要的告诫:先测量,后优化,默认不加。memo 三件套本身有成本(比较、缓存、心智负担),乱加会让代码复杂而性能毫无变化。正确的触发点是:Profiler 面板显示某子树重渲染耗时明显,或大数据列表肉眼掉帧。还有一个结构性替代方案常常更好用——「状态下沉 + children 组合」:把变化的状态挪到小组件里,或把不变的部分作为 children 传下来(children 是 props,引用不变就不会重渲染),很多时候不用 memo 就解决了问题。

示例

量化「memo 跳过渲染」的收益,并验证稳定引用的必要性(在本教程构建时被真实执行):

import assert from 'node:assert/strict';

// 模拟 memo 的浅比较:props 全部引用相同 → 跳过渲染
function shallowEqual(a, b) {
  const ka = Object.keys(a);
  const kb = Object.keys(b);
  return ka.length === kb.length && ka.every((k) => Object.is(a[k], b[k]));
}

let childRenders = 0;
function renderChild(props, prevProps) {
  if (prevProps && shallowEqual(props, prevProps)) return '跳过(memo 命中)';
  childRenders++;
  return '渲染';
}

// 父组件渲染 3 次:内联对象/函数 → memo 失效
let prev = null;
for (let i = 0; i < 3; i++) {
  const props = { items: [1, 2, 3], onSelect: () => {} }; // 每次新建引用
  renderChild(props, prev);
  prev = props;
}
assert.strictEqual(childRenders, 3); // 每次都渲染

// useMemo/useCallback 稳定引用后 → 只渲染一次
childRenders = 0;
const stableItems = [1, 2, 3]; // useMemo 缓存
const stableFn = () => {}; // useCallback 缓存
prev = null;
for (let i = 0; i < 3; i++) {
  const props = { items: stableItems, onSelect: stableFn };
  renderChild(props, prev);
  prev = props;
}
assert.strictEqual(childRenders, 1);

// useMemo 的缓存语义:依赖没变不重算
let computeCount = 0;
function useMemoSim(compute, deps, prevDeps, prevValue) {
  const same = prevDeps && deps.every((d, i) => Object.is(d, prevDeps[i]));
  if (same) return prevValue;
  computeCount++;
  return compute();
}
const big = [5, 3, 8, 1];
let sorted = useMemoSim(() => [...big].sort((a, b) => a - b), [big], null, null);
sorted = useMemoSim(() => [...big].sort((a, b) => a - b), [big], [big], sorted);
assert.strictEqual(computeCount, 1); // 第二次直接命中缓存
assert.deepStrictEqual(sorted, [1, 3, 5, 8]);

console.log('内联 props:子组件渲染 3 次;稳定引用:渲染 1 次');
console.log('useMemo 依赖未变,排序只算了一次:', sorted.join(','));

真实代码:

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

const Row = memo(function Row({ item, onSelect }) {
  console.log('渲染行', item.id);
  return <li onClick={() => onSelect(item.id)}>{item.name}</li>;
});

function List({ items }) {
  const [selected, setSelected] = useState(null);
  // 稳定引用:memo 子组件才能真正跳过渲染
  const handleSelect = useCallback((id) => setSelected(id), []);
  const sortedItems = useMemo(() => [...items].sort((a, b) => a.name.localeCompare(b.name)), [items]);

  return (
    <ul>
      {sortedItems.map((i) => (
        <Row key={i.id} item={i} onSelect={handleSelect} />
      ))}
    </ul>
  );
}

常见坑

  • memo 配内联 propsdata={{...}}onClick={() => ...} 每次新引用,memo 形同虚设——三件套要配套用。
  • 给所有组件包 memo:比较也有成本,浅层小组件 memo 纯属浪费;Profiler 测过再动手。
  • useMemo 依赖乱写:依赖漏了拿到旧缓存,多写了缓存失效;听 ESLint。
  • 忽略 children 方案:很多「memo 优化」用状态下沉/children 组合就能结构性解决,代码还更简单。
  • 在渲染期间做昂贵计算不缓存:大列表排序过滤每次渲染重跑,要么 useMemo 要么源头预处理。

小结

memo 拦子树重渲染,useMemo 缓存值,useCallback 缓存函数;配合稳定引用才生效;先测量再优化,优先考虑 children/状态下沉。下一章把逻辑抽出组件:自定义 Hooks。