记忆化
渲染这一章是这一章的思想基础:当状态改变时,React 重新运行组件及其下面的所有内容,这通常没问题,因为大多数渲染都很快。有时候就不行了。组件顶部的某个计算可能耗时明显,或者一个组件子树可能昂贵到每次按键都重新渲染它都会让应用感觉卡顿。
对于那些经过测量确认的情况,React 提供了三个工具:useMemo、memo 和 useCallback。它们都做同一件事的变体:让 React 记住上次渲染的结果,跳过重复的工作。要好好用它们,你首先需要知道 JavaScript 怎样判断两个东西是否"相同"。
值类型、引用类型和引用相等性
JavaScript 把值分为两类。值类型(也叫原始类型)包括布尔值、数字、字符串,还有你见得较少的:undefined、null、symbol 和 bigint。两个值类型相等当且仅当它们的值和类型都一样:1 === 1 为真。引用类型(也叫复杂类型)包括对象、数组和函数。两个引用类型相等只有当它们在内存中是同一个东西。看起来一样完全不算。
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 用来做这些比较的,在这里的表现几乎和 === 一样。即使是 {} !== {}:两个空对象字面量是两次独立的分配,所以它们都过不了检查。这叫做引用相等性,是这整章的根本概念。
现在把它接到渲染上。组件是个函数,每次渲染都从顶部重新运行它的整个函数体。在那个函数体里创建的任何对象、数组或函数在每次渲染都是全新的:
function App() {
const styles = { backgroundColor: 'black' } // 每次渲染都是新对象
function increment() { /* ... */ } // 每次渲染都是新函数
// ...
}内容可能从一次渲染到下一次完全一样,但引用永远不会。任何通过 Object.is 来问"这个 prop 改了吗?"的比较,答案永远都是肯定的。接下来的一切都是关于控制什么时候这些引用会改变。
useMemo:跳过昂贵的计算
记忆化的第一个用途还没涉及引用。组件顶层的一个计算在每次渲染都运行,即使那次渲染由计算根本不读的状态触发。如果排序一个大产品列表需要显著的时间,同一个组件里别处的计数器按钮就会卡顿,因为每次点击都会白白重新排序。
useMemo 用和 effect 一样的形式修复这个:一个函数加一个依赖数组。React 调用这个函数,记住它返回什么,之后的渲染里只要依赖数组里没有东西改了,React 就直接交回记住的值,不再调用函数。
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,现代的导入是不带命名空间的。
import { memo } from 'react'
function Product({ name, styles }) {
// 昂贵的组件函数体
}
export default memo(Product)升级过的组件会比较它的前一个 props 和新进来的 props。如果每个 prop 都一样,React 就在那个父组件的这次渲染里跳过重新渲染它,整个它会产生的子树都被跳过了(下面讲的例外除外)。在课程的演示应用里,把一个组件包在 memo 里把一个状态改变从三十一次渲染降到了一次,因为整个没被触及的子树在一个决定里就被跳过了。
"一样"的意思是浅相等:每个 prop 都用 Object.is 比较。字符串或数字 prop 按值比较,表现得就像你期望的那样。问题就从这里开始。
为什么 memo 会默默失效
给 memo 包装的组件传一个对象 prop,优化就悄悄死了:
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 的第二个用途:在渲染间保留一个引用,这样比较才能成功。把对象的创建包起来,给它它真正变化所依赖的那一个依赖:
const styles = useMemo(() => ({
backgroundColor: darkMode ? '#222' : '#fff',
color: darkMode ? '#fff' : '#000',
}), [darkMode])现在 styles 在 darkMode 改变前,每次渲染都是同一个对象(按引用)。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 正好是为了这个:它把函数本身记忆化。
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,它自动化了这章涵盖的大部分内容:它在构建时分析组件,自动插入记忆化,所以手写的 useMemo、useCallback 和 memo 在采用它的项目里基本就不必要了。这些概念仍然值得理解,因为现有的代码库里到处都是这些钩子,而且知道编译器为你做了什么会让它的行为可理解而不是神秘。
useMemo 让 React 记住答案并在输入不变时重用它。memo 为整个组件做类似的事:如果它的 props 和上次一样,React 就跳过渲染它。 棘手的部分是两个看起来一样的对象或函数在 JavaScript 里仍然算不同,这就是为什么这些工具有时需要彼此。而且在用任何它们前,确保有什么东西真的慢。
接下来是:代码分割,另一个杠杆,包大小,轮到它了。

