第 16 章

React.memo 与渲染控制的完整体系

第16章:React.memo 与渲染控制的完整体系

React.memo 是门,useCallback/useMemo 是让这扇门'识别'稳定 props 的工具。缺少后两者,这扇门永远判定'prop 变了'。

本章核心问题:为什么单独使用 React.memo 往往无效?三角形体系的协同关系是什么? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

为什么需要一套体系,而不是一个工具

开发者学会 React.memo 之后,常见的错误是把它当成万能膏药,给每个组件都包一层。结果:性能没有提升,调试难度翻倍,还引入了一堆令人困惑的 bug。

原因在于 React.memouseMemouseCallback 这三者必须协同使用才能发挥作用。它们是一个三角形体系,缺少任何一角,整个优化都会失效。本章系统性地拆解这个体系,从底层实现到工程实践,帮你建立正确的心智模型。

useCallback:稳定函数引用

考虑这个场景:

// ❌ 看起来做了优化,实际完全无效
const MemoList = React.memo(function List({ items, onItemClick }) {
  console.log('List rendered');
  return (
    <ul>
      {items.map(item => (
        <li key={item.id} onClick={() => onItemClick(item.id)}>
          {item.name}
        </li>
      ))}
    </ul>
  );
});

function Parent() {
  const [count, setCount] = useState(0);
  const items = [{ id: 1, name: 'Apple' }, { id: 2, name: 'Banana' }];

  // ❌ 每次 Parent 渲染,handleClick 都是新函数引用
  const handleClick = (id) => {
    console.log('Clicked:', id);
  };

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <MemoList items={items} onItemClick={handleClick} />
    </>
  );
}

每次 count 变化,Parent 重渲,handleClick 是新函数,MemoList 的 props 比较在 onItemClick 这一项上失败,React.memo 的保护形同虚设。

修复方案:useCallback 缓存函数引用:

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

  // ✅ 函数引用稳定,只有依赖项变化才重建
  const handleClick = useCallback((id) => {
    console.log('Clicked:', id);
  }, []); // 空依赖:函数体不依赖任何 state/props

  return (
    <>
      <button onClick={() => setCount(c => c + 1)}>{count}</button>
      <MemoList items={items} onItemClick={handleClick} />
    </>
  );
}

useCallback(fn, deps) 的语义:返回同一个函数引用,直到 deps 中的某个值变化。它不优化函数的执行速度,只优化函数的引用稳定性

useCallback 的依赖项陷阱

// ❌ 错误:handleClick 读取了 userId,但依赖项为空
const handleClick = useCallback((id) => {
  submitOrder(userId, id); // userId 是组件的 state
}, []);
// 问题:userId 更新后,handleClick 仍然闭包在旧的 userId 上
// ✅ 正确:把所有读取的外部变量列入依赖项
const handleClick = useCallback((id) => {
  submitOrder(userId, id);
}, [userId]); // userId 变化时重建函数

使用 ESLint 插件 eslint-plugin-react-hooks 可以自动检测依赖项遗漏,强烈建议在项目中启用。

useMemo:稳定对象引用

同样的问题发生在对象 prop 上:

// ❌ 每次渲染创建新对象,memo 失效
function Parent() {
  const [count, setCount] = useState(0);
  const config = { theme: 'dark', pageSize: 20 }; // 新引用

  return <MemoChild config={config} />;
}

// ✅ useMemo 缓存对象,引用稳定
function Parent() {
  const [count, setCount] = useState(0);
  const config = useMemo(() => ({ theme: 'dark', pageSize: 20 }), []);

  return <MemoChild config={config} />;
}

useMemo(factory, deps) 的语义:缓存工厂函数的返回值,直到 deps 变化时重新计算

useMemo 也用于缓存计算开销大的值

function DataTable({ rawData }) {
  // 每次渲染都排序过滤:O(n log n),数据量大时显著慢
  // const processed = rawData.filter(d => d.active).sort(compareFn);

  // ✅ 只有 rawData 变化时才重新计算
  const processed = useMemo(() => {
    return rawData.filter(d => d.active).sort(compareFn);
  }, [rawData]);

  return <Table data={processed} />;
}

实际测量:对 10,000 条数据排序约需 12ms。如果父组件每秒更新 60 次(如动画),每次都排序意味着 720ms/s 的额外 CPU 负担,useMemo 可以将其降到接近 0。

memo + useMemo + useCallback 三角形体系

三者的关系可以这样理解:

React.memo        → 组件级别的"是否需要渲染"守卫
useCallback       → 为函数类型的 prop 提供稳定引用
useMemo           → 为对象类型的 prop 或昂贵计算提供稳定引用/缓存值

它们构成一个完整体系:React.memo 是门,useCallback/useMemo 是让这扇门"识别"稳定 props 的工具。缺少后两者,React.memo 这扇门永远判定"prop 变了,重渲"。

// ✅ 完整体系:三者协同
const ProductCard = React.memo(function ProductCard({ product, onAddToCart }) {
  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={() => onAddToCart(product.id)}>Add to Cart</button>
    </div>
  );
});

function Catalog({ products }) {
  const [cartItems, setCartItems] = useState([]);

  // 稳定的函数引用
  const handleAddToCart = useCallback((id) => {
    setCartItems(prev => [...prev, id]);
  }, []);

  // 稳定的对象引用(products 是从父级传来的引用,已经稳定)
  // 如果需要派生数据,用 useMemo
  const featuredProducts = useMemo(
    () => products.filter(p => p.featured),
    [products]
  );

  return (
    <div>
      {featuredProducts.map(product => (
        <ProductCard
          key={product.id}
          product={product}        // 稳定引用(从 useMemo 派生)
          onAddToCart={handleAddToCart}  // 稳定引用(useCallback)
        />
      ))}
    </div>
  );
}

React.memo 的第二个参数:自定义比较函数

React.memo 接受可选的第二个参数:自定义 areEqual 函数。

const MemoChart = React.memo(
  function Chart({ data, config }) {
    // 复杂图表组件
    return <canvas>{/* ... */}</canvas>;
  },
  // 自定义比较:只有 data 的长度变化或 config.type 变化才重渲
  (prevProps, nextProps) => {
    if (prevProps.data.length !== nextProps.data.length) return false;
    if (prevProps.config.type !== nextProps.config.type) return false;
    return true; // 返回 true = props "相等" = 跳过渲染
  }
);

注意:areEqual 的语义与 shouldComponentUpdate 相反——返回 true 表示"相等,跳过渲染",返回 false 表示"不同,需要渲染"。搞反了会造成 UI 不更新的 bug。

自定义比较函数适用于:

但要警惕:复杂的比较函数本身也有开销,如果比较逻辑复杂度超过组件渲染复杂度,反而得不偿失。

React Compiler:自动 memoization 的未来

React 19 随附了 React Compiler(前身是 React Forget),这是一个编译时工具,自动分析你的组件代码并插入 memoization,无需手动编写 React.memouseCallbackuseMemo

// 你写的代码
function ProductCard({ product, onAddToCart }) {
  return (
    <div>
      <h3>{product.name}</h3>
      <button onClick={() => onAddToCart(product.id)}>Add</button>
    </div>
  );
}

// React Compiler 编译后(示意)
function ProductCard({ product, onAddToCart }) {
  const $ = useMemoCache(4);
  let t0;
  if ($[0] !== product.name || $[1] !== product.id || $[2] !== onAddToCart) {
    t0 = (
      <div>
        <h3>{product.name}</h3>
        <button onClick={() => onAddToCart(product.id)}>Add</button>
      </div>
    );
    $[0] = product.name;
    $[1] = product.id;
    $[2] = onAddToCart;
    $[3] = t0;
  } else {
    t0 = $[3];
  }
  return t0;
}

React Compiler 把 memoization 的粒度细化到每个表达式级别,比手工 useMemo 更精确,也不容易出错。

但 React Compiler 有前提条件:你的代码必须遵守 React 的规则(不可变 state、纯组件函数等)。如果代码里有直接状态突变或其他不规范写法,编译器会跳过这部分代码,仍然需要手动 memo。

在 React Compiler 广泛普及之前,理解手动 memoization 体系依然必要——它帮助你写出编译器友好的代码,也是调试 Compiler 优化失效时的基础知识。


Level 2 · 它是怎么运行的(3-5年经验)

React.memo 的底层实现

React.memo 是一个高阶组件(HOC),它把你的组件包裹在一个特殊的 React element type 中,携带了浅比较逻辑。当 React 即将渲染一个 memo 包裹的组件时,会先执行 props 比较。

浅比较的实现等价于:

function shallowEqual(prevProps, nextProps) {
  // 先检查引用相等(快速路径)
  if (Object.is(prevProps, nextProps)) return true;

  // 检查是否都是对象
  if (typeof prevProps !== 'object' || typeof nextProps !== 'object') return false;
  if (prevProps === null || nextProps === null) return false;

  const prevKeys = Object.keys(prevProps);
  const nextKeys = Object.keys(nextProps);

  // key 数量不同
  if (prevKeys.length !== nextKeys.length) return false;

  // 每个 key 的值做 Object.is 比较
  for (const key of prevKeys) {
    if (!Object.is(prevProps[key], nextProps[key])) return false;
  }

  return true;
}

关键点:每个 prop 值用 Object.is 比较,而不是深比较。这意味着:

这个"每次渲染创建新函数"的问题,直接引出了 useCallback 的必要性。


Level 3 · 规范怎么定义的(资深)

本章涉及的 API 规范和设计决策已融入各层级的讨论中。


Level 4 · 边界与陷阱(所有人)

React.memo 失效的三个场景

场景一:Context 消费者

React.memo 不能阻止 Context 触发的重渲:

const MemoChild = React.memo(function Child() {
  const theme = useContext(ThemeContext);
  return <div style={{ color: theme.color }}>Hello</div>;
});

// 即使 MemoChild 的 props 没变,ThemeContext 变化时它仍会重渲
// React.memo 只检查 props,不检查 context

对策:拆分 Context,只订阅需要的字段;或用 useMemo 在消费端缓存派生值。

场景二:children prop

children 是一个 prop,但它通常是 JSX 表达式——每次父组件渲染都创建新 Element 对象:

const MemoWrapper = React.memo(function Wrapper({ children }) {
  return <div className="wrapper">{children}</div>;
});

function Parent() {
  const [count, setCount] = useState(0);
  return (
    // ❌ <p>Static text</p> 每次都是新 React Element,memo 失效
    <MemoWrapper>
      <p>Static text</p>
    </MemoWrapper>
  );
}

children 的引用每次都不同,memo 的浅比较在 children prop 上失败,组件每次都重渲。

场景三:内联对象 props

// ❌ 最常见的 memo 失效场景
<MemoCard
  style={{ padding: 16 }}      // 新对象
  config={{ rounded: true }}   // 新对象
/>

这是最常见的错误。即使 MemoCardReact.memo 包裹,每次父组件渲染,styleconfig 都是新对象引用,memo 的比较都会失败。

实战:从一次错误 memo 到完整修复

// ❌ 初始版本:memo 了,但完全没用
const UserRow = React.memo(function UserRow({ userId }) {
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    fetchUser(userId).then(setUserData);
  }, [userId]);

  return <tr><td>{userData?.name}</td></tr>;
});

function UserTable() {
  const [page, setPage] = useState(1);
  const [filters, setFilters] = useState({});

  const userIds = useFetchUserIds(page, filters); // 每次都返回新数组引用

  return (
    <table>
      {userIds.map(id => (
        // ❌ userIds 每次是新数组,虽然 id 没变,但 map 每次生成新 JSX
        // UserRow 的 userId prop 是基本类型,但父组件高频重渲也会导致问题
        <UserRow key={id} userId={id} />
      ))}
    </table>
  );
}

// ✅ 修复版本
function UserTable() {
  const [page, setPage] = useState(1);
  const [filters, setFilters] = useState({});

  // useMemo 稳定 userIds 数组引用
  const userIds = useMemo(
    () => computeUserIds(page, filters),
    [page, filters]
  );

  // useCallback 稳定任何传给子组件的函数
  const handleRowClick = useCallback((id) => {
    navigateTo(`/users/${id}`);
  }, []);

  return (
    <table>
      {userIds.map(id => (
        <UserRow key={id} userId={id} onRowClick={handleRowClick} />
      ))}
    </table>
  );
}
本章评分
4.5  / 5  (15 评分)

💬 留言讨论