Skip to content
This page has been auto-translated and may contain errors.View in English

记忆化

渲染这一章是这一章的思想基础:当状态改变时,React 重新运行组件及其下面的所有内容,这通常没问题,因为大多数渲染都很快。有时候就不行了。组件顶部的某个计算可能耗时明显,或者一个组件子树可能昂贵到每次按键都重新渲染它都会让应用感觉卡顿。

对于那些经过测量确认的情况,React 提供了三个工具:useMemomemouseCallback。它们都做同一件事的变体:让 React 记住上次渲染的结果,跳过重复的工作。要好好用它们,你首先需要知道 JavaScript 怎样判断两个东西是否"相同"。

值类型、引用类型和引用相等性

JavaScript 把值分为两类。值类型(也叫原始类型)包括布尔值、数字、字符串,还有你见得较少的:undefinednullsymbolbigint。两个值类型相等当且仅当它们的值和类型都一样:1 === 1 为真。引用类型(也叫复杂类型)包括对象、数组和函数。两个引用类型相等只有当它们在内存中是同一个东西。看起来一样完全不算。

jsx
const a = { name: '小王' }
const b = { name: '小王' }

console.log(Object.is(a, b)) // false: 内存中两个不同的对象

const c = a
console.log(Object.is(a, c)) // true: 都指向同一个对象

Object.is 是 React 用来做这些比较的,在这里的表现几乎和 === 一样。即使是 {} !== {}:两个空对象字面量是两次独立的分配,所以它们都过不了检查。这叫做引用相等性,是这整章的根本概念。

现在把它接到渲染上。组件是个函数,每次渲染都从顶部重新运行它的整个函数体。在那个函数体里创建的任何对象、数组或函数在每次渲染都是全新的:

jsx
function App() {
  const styles = { backgroundColor: 'black' } // 每次渲染都是新对象
  function increment() { /* ... */ }          // 每次渲染都是新函数
  // ...
}

内容可能从一次渲染到下一次完全一样,但引用永远不会。任何通过 Object.is 来问"这个 prop 改了吗?"的比较,答案永远都是肯定的。接下来的一切都是关于控制什么时候这些引用会改变。

useMemo:跳过昂贵的计算

记忆化的第一个用途还没涉及引用。组件顶层的一个计算在每次渲染都运行,即使那次渲染由计算根本不读的状态触发。如果排序一个大产品列表需要显著的时间,同一个组件里别处的计数器按钮就会卡顿,因为每次点击都会白白重新排序。

useMemo 用和 effect 一样的形式修复这个:一个函数加一个依赖数组。React 调用这个函数,记住它返回什么,之后的渲染里只要依赖数组里没有东西改了,React 就直接交回记住的值,不再调用函数。

jsx
import { useMemo, useState } from 'react'

function ProductList({ productsData }) {
  const [count, setCount] = useState(0)

  const sortedProducts = useMemo(() => {
    return [...productsData].sort((a, b) => a.name.localeCompare(b.name))
  }, [productsData])

  // ...
}

依赖数组里是这个计算读的一切:这里只有 productsData。改变 count 会重新渲染组件,但 React 看到 productsData 没变,就完全跳过排序。只有新的 productsData 数组才会触发重新计算。在课程的演示里,这把排序从每次点击都明显卡顿降到了不涉及数据的渲染上零毫秒。

memo:跳过子组件的重新渲染

useMemo 在一个组件里缓存一个值。memo 在更上一层运作:它能跳过对整个组件的重新渲染。它是个高阶组件,一个接收你的组件并返回它的升级版本的函数。在旧代码里你经常会看到 React.memo,现代的导入是不带命名空间的。

jsx
import { memo } from 'react'

function Product({ name, styles }) {
  // 昂贵的组件函数体
}

export default memo(Product)

升级过的组件会比较它的前一个 props 和新进来的 props。如果每个 prop 都一样,React 就在那个父组件的这次渲染里跳过重新渲染它,整个它会产生的子树都被跳过了(下面讲的例外除外)。在课程的演示应用里,把一个组件包在 memo 里把一个状态改变从三十一次渲染降到了一次,因为整个没被触及的子树在一个决定里就被跳过了。

"一样"的意思是浅相等:每个 prop 都用 Object.is 比较。字符串或数字 prop 按值比较,表现得就像你期望的那样。问题就从这里开始。

为什么 memo 会默默失效

memo 包装的组件传一个对象 prop,优化就悄悄死了:

jsx
function App() {
  const [darkMode, setDarkMode] = useState(false)
  const [count, setCount] = useState(0)

  const styles = {
    backgroundColor: darkMode ? '#222' : '#fff',
    color: darkMode ? '#fff' : '#000',
  }

  return <Product styles={styles} /* ... */ />
}

每次 count 改变,App 重新渲染并构建一个新的 styles 对象。里面的值完全相同,但引用相等性不在乎:Object.is(oldStyles, newStyles) 是假,memo 得出结论 props 改了,Product 还是会重新渲染。没有错误,没有警告。记忆化什么都没做。

这是 useMemo 的第二个用途:在渲染间保留一个引用,这样比较才能成功。把对象的创建包起来,给它它真正变化所依赖的那一个依赖:

jsx
const styles = useMemo(() => ({
  backgroundColor: darkMode ? '#222' : '#fff',
  color: darkMode ? '#fff' : '#000',
}), [darkMode])

现在 stylesdarkMode 改变前,每次渲染都是同一个对象(按引用)。memo 的检查通过了,当只有计数器动时 Product 就能被跳过。这两个工具彼此配合:子组件上的 memo 问"props 改了吗?",父组件里的 useMemo 确保答案能是否。

children 就像任何其他 prop,这一点经常坑人。父组件里写的 JSX children 在每次父组件渲染都是一个新的元素对象,所以 <Card><Chart /></Card> 每次都给 Card 一个新的 children 引用,Card 上的 memo 永远成功不了,不管其他 props 有多稳定。结构上的模式是渲染描述的从另一个角度:当元素在拥有状态的组件上面被创建时,那个组件的重新渲染看到的是同一个元素对象,不需要任何记忆化就能跳过这个分支。

memo 不会跳过什么

memo 只管理从父组件来的 props。一个被记忆化的组件在它自己的状态改变时仍会重新渲染,当它读的上下文更新时也会。上下文能穿过被跳过的子树:一个消费了改变的上下文的后代即使上面的被记忆化的包装被跳过了,也会重新渲染。

useCallback:给函数的 useMemo

函数也是引用类型,所以一个回调 prop 用完全相同的方式破坏 memo。在组件函数体里定义 function increment() {...},把它传下去,子组件每次父组件渲染都会收到一个全新的函数引用。useCallback 正好是为了这个:它把函数本身记忆化。

jsx
const [selectedProduct, setSelectedProduct] = useState(null)

const chooseProduct = useCallback((id) => {
  console.log('选中新产品')
  setSelectedProduct(id)
}, [])

chooseProduct 传给一个 memo 包装的子组件,优化就保住了:useCallback(fn, deps) 在依赖改变前保持同一个函数引用。它和 useMemo 的关系很紧密:useMemo 记住的值是你的函数返回的东西,而 useCallback 记住的值就是你传进来的函数。

一个细节要记住:状态 setter 函数比如 setSelectedProduct 已经有稳定的标识,React 保证了。把一个原生 setter 作为 prop 传下去不需要 useCallback;当你把 setter 包在自己的函数里做额外工作时,这个钩子才显出价值。

一个稳定的函数引用还在 memo 之外有用。如果一个函数出现在 useEffect 的依赖数组里,新引用每次渲染意味着 effect 每次都重新运行,就像effects讲的那样。useCallback 也能静止这种情况。

先测量

在拿出这些东西之前,先确认真的有性能问题。渲染做了论证并铺垫了工作流:大多数渲染都很便宜,不改变任何东西的渲染永远不会到达 DOM,用节流 CPU 的 Profiler 是怎样找到那些真的有成本的渲染。记忆化一个 data.length 这样的琐碎计算的成本比它的收益还大,因为缓存和比较本身就是工作。只修复你能测量到的东西,在花力气减少渲染数量前先修复慢的渲染。

版本说明

React Compiler 在 2025 年 10 月达到了 1.0,它自动化了这章涵盖的大部分内容:它在构建时分析组件,自动插入记忆化,所以手写的 useMemouseCallbackmemo 在采用它的项目里基本就不必要了。这些概念仍然值得理解,因为现有的代码库里到处都是这些钩子,而且知道编译器为你做了什么会让它的行为可理解而不是神秘。

机制上,useMemo 在组件在 React 内部树里的槽里存储返回值和依赖数组。下次渲染时它逐个把新的依赖数组和存储的比,用 Object.is 比较每个元素;任何不匹配都重新运行函数并替换两者。这让依赖数组本身也受引用相等性制约:一个依赖得到新引用的对象依赖会从内部击败记忆化,这就是为什么稳定化往往倾向于在组件间向上级联。

memo 在 props 对象的每个键上运行同样的 Object.is 比较。它接受一个可选的第二个参数,一个收到旧 props 和新 props 的函数,这样你能自己定义相等性,而 React 文档是对的,你几乎永远不会需要它。

更锋利的边缘是这个比较在每个组件上是全有或全无的:十个稳定 prop 里有一个不稳定的,子组件就每次都重新渲染,所以一个 memo 包装只如同它接收的最不稳定的 prop 那样好,一个永远不触发的包装在 Profiler 里会表现为一个一直在重新渲染的子树。

记住两个边界。useCallback(fn, deps) 从字面上就是 useMemo(() => fn, deps),所以对其中一个为真的任何东西对另一个也为真。而 useMemo 是个性能提示而不是语义保证:React 有权扔掉缓存的值,比如当一个组件在初始挂载时悬停时,或在开发时每当你编辑文件时,所以代码必须在函数重新运行时仍然正确。把必须在重新渲染时无例外存活的东西存在状态或 ref 里。同样的稳定身份推理适用于上下文提供者值,那章的深入潜水已经涵盖了。

Juno稳定引用跳过工作 React 在每次渲染都重新运行一个组件的整个函数体,所以里面任何慢的计算都会一遍遍运行。useMemo 让 React 记住答案并在输入不变时重用它。memo 为整个组件做类似的事:如果它的 props 和上次一样,React 就跳过渲染它。

棘手的部分是两个看起来一样的对象或函数在 JavaScript 里仍然算不同,这就是为什么这些工具有时需要彼此。而且在用任何它们前,确保有什么东西真的慢。

Juno稳定引用跳过工作 把昂贵的计算包在 useMemo 里,把它真正的输入作为依赖,把昂贵的子组件包在 memo 里,在 props 没变时跳过它。

然后记住在组件函数体里创建的对象和函数在每次渲染都是新引用,所以一个 memo 子组件收到它们还是会重新渲染:用 useMemo 稳定对象,用 useCallback 稳定函数。状态 setter 已经稳定。

先用节流 CPU 测量;大多数重新渲染成本低到不值得优化。

Juno稳定引用跳过工作 这里的一切都归结为 Object.is:依赖数组被逐元素比较,memo 浅比较 props,任何地方的一个不稳定引用都会破坏整个链。useCallback(fn, deps) 就是 useMemo(() => fn, deps),而 useMemo 是个 React 可能丢弃的提示,所以保持正确性独立于缓存。

React Compiler 现在在构建时自动化这个分析;手写的钩子对现存代码和理解编译器代你做什么很重要。

接下来是:代码分割,另一个杠杆,包大小,轮到它了。