Skip to content

引用身份与缓存:为什么加了 memo 仍在渲染 ​

问题:切换页面配色,商品筛选为何也重新执行 ​

父组件的一项 state 改变,React 会重新调用父组件,并通常继续渲染它返回的子组件。即使筛选条件没变,写在函数里的 filter 也会再次执行。对于小列表,这往往足够快;但要理解后面的并发渲染,必须先能区分“哪些输入变了”和“哪些工作可以复用”。

本篇用小数据观察执行路径,不用人为阻塞主线程的大循环制造性能问题。先保证去掉所有缓存后页面依然正确,再研究哪些重复工作可以省掉。

内容相同,不等于同一个引用 ​

先在 JavaScript 控制台运行:

js
const original = { id: 'tea' }
const same = original
const copy = { id: 'tea' }
console.log(Object.is(original, same)) // true
console.log(Object.is(original, copy)) // false
console.log(Object.is(['tea'], ['tea'])) // false
console.log(Object.is(() => 1, () => 1)) // false
console.log(Object.is('tea', 'tea')) // true

新对象、新数组和新函数,即使内容或源码相同,也拥有不同身份。组件每次执行 const options = { query },都会创建新对象;每次执行 const choose = id => ...,都会创建新函数。这与第二章的不可变更新是一条规则:引用比较不检查整个对象内部,正确的引用复用需要数据所有者有意保证。

三个工具分别缓存什么 ​

  • memo(Component) 返回一个可跳过重复渲染的组件。默认逐个 prop 使用 Object.is 比较,全部相同时,通常可跳过由父组件更新带来的渲染。
  • useMemo(calculate, dependencies) 缓存纯计算的返回值。首次运行计算;之后依赖逐项比较相同,可复用上一次结果。
  • useCallback(callback, dependencies) 缓存函数身份,不会调用这个函数。以后事件触发时,调用者才执行它。

useMemo 和 useCallback 都在组件或自定义 Hook 顶层调用,依赖列表长度和顺序固定。依赖不是“我想何时运行”的任意开关,而是计算或闭包读取的、可能随渲染变化的输入。

完整实验:配色、筛选与选择分别影响谁 ​

替换 src/App.jsx。本实验先使用未配置 React Compiler 的普通 Vite React 模板;开启编译器的项目可能自动复用部分值,使手动删除缓存后的观察不同。

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

const products = [
  { id: 'tea', name: '乌龙茶' },
  { id: 'cup', name: '玻璃杯' },
  { id: 'bag', name: '帆布袋' },
]

const Results = memo(function Results({ items, onChoose }) {
  const [expanded, setExpanded] = useState(true)
  console.log('[Results] 执行组件')

  return (
    <section aria-label="商品结果">
      <button onClick={() => setExpanded(current => !current)}>
        {expanded ? '收起结果' : '展开结果'}
      </button>
      {expanded && (
        items.length === 0 ? <p>无匹配商品</p> : (
          <ul>
            {items.map(item => (
              <li key={item.id}>
                <button onClick={() => onChoose(item.id)}>{item.name}</button>
              </li>
            ))}
          </ul>
        )
      )}
    </section>
  )
})

export default function App() {
  const [query, setQuery] = useState('')
  const [dark, setDark] = useState(false)
  const [selectedId, setSelectedId] = useState(null)
  console.log('[App] 执行组件')

  const visible = useMemo(() => {
    console.log('[filter] 重新计算')
    return products.filter(product => product.name.includes(query.trim()))
  }, [query])

  const choose = useCallback(id => {
    setSelectedId(id)
  }, [])

  return (
    <main style={{ color: dark ? '#fff' : '#111', background: dark ? '#222' : '#fff' }}>
      <h1>选择商品</h1>
      <button onClick={() => setDark(current => !current)}>切换配色</button>
      <label>
        搜索
        <input value={query} onChange={event => setQuery(event.target.value)} />
      </label>
      <p>已选:{selectedId ?? '无'}</p>
      <Results items={visible} onChoose={choose} />
    </main>
  )
}

打开控制台,在首次显示完成后清空日志,再按顺序操作:

  1. 点“切换配色”:App 执行;query 未变,筛选结果复用;两个 Results prop 身份都没变,Results 可跳过本次父级更新。页面配色仍会通过外层样式改变。
  2. 输入“茶”:query 变了,筛选重新计算,产生新数组;Results 收到新 items,显示一件商品。
  3. 点“乌龙茶”:selectedId 变化,App 显示 tea;query 和两个 Results prop 都没变,所以没有必要重新计算筛选或结果组件。
  4. 点“收起结果”:Results 自己的 state 变了,它仍会执行。memo 没有冻结组件的局部交互。

这些日志仅用于检查教学实验的执行路径。Strict Mode 在开发时会额外调用组件和纯计算;热更新也可能重建缓存。观察“某一步涉及哪些组件和计算”,不要把日志行数承诺为固定的一次或两次,更不要用日志次数推断 DOM 一定更新了几次。

一次只拆掉一个条件 ​

先把 visible 改成普通计算,其他部分不动:

js
const visible = products.filter(product => product.name.includes(query.trim()))

再切换配色,filter 返回新数组,Object.is(旧 visible, 新 visible) 为 false,Results 的 memo 因此无法跳过。稳定 key 仍保留行的身份;这里的问题是重复渲染,不是 state 丢失。

恢复 useMemo,再把 choose 改成 const choose = id => setSelectedId(id)。每次 App 执行都会得到新函数,另一个 prop 变了,Results 仍会执行。恢复 useCallback 后,依赖不变时它返回缓存的函数引用。

最后保留两个 Hook,只移除 Results 外面的 memo 包装。切换配色时筛选仍可复用,但父组件通常会继续调用 Results。由此可以分清:缓存某个值不会自动缓存整个组件;memo 也不会自动把你新建的对象变成旧引用。

依赖必须包含闭包实际读取的值 ​

主实验 choose 只调用 setter;React 保证同一组件的 setter 身份稳定,所以这里可以是空依赖。如果要记录“选择时的搜索词”,修改为:

js
const choose = useCallback(id => {
  setSelectedId(`${id},当时搜索:${query}`)
}, [query])

输入“茶”再选商品,记录应该带“茶”。若误写 [],保留的是最初捕获空 query 的函数,不会因为 App 重新渲染就自动读到最新 query。此时为了函数引用稳定而漏依赖,会用错误数据换取一次渲染跳过。

若回调只是基于旧 state 计算下一 state,可以用函数式更新消除那项读取。例如 setCount(current => current + 1) 不需要从闭包读取 count;这与把仍在使用的 query 强行从依赖里删掉不同。

useMemo 也有相同问题。const options = { query } 然后以 [options] 为依赖,会因为 options 每次都是新对象而持续失效。若 options 只服务于这次计算,把创建过程移进 useMemo,并直接依赖 [query],比再加一层缓存更容易核对。

身份、状态与缓存的边界 ​

第一章的 key 和组件类型决定旧 state 是否继续属于这棵子树;本篇的 prop 引用与依赖比较决定哪些计算可能复用。更改 key 会重新挂载,缓存也随之重新建立;给组件加 memo 不会把已经卸载的草稿找回来。

memo 只检查 props。组件自己的 state 改变,或它读取的 Context value 改变,都仍需更新。若用第二篇的主题 Context 包住 Results,并在 Results 内读取 theme,外层主题变化便不能靠相同 items/onChoose 跳过。Context 值的身份同样值得核对,但先缩小它的职责与提供范围。

缓存是性能优化,React 可以在特定情况下丢弃缓存;业务逻辑不能要求某次计算“全生命周期绝对只执行一次”。useMemo 内不要发请求、修改输入或登记订阅;需要保存用户编辑结果时用 state,而不是把它塞进缓存。

自定义 memo 比较函数可以覆盖默认行为,但必须检查所有影响输出和行为的 prop,包括函数。只比较 items 而忽略新的 onChoose,会让子组件继续调用旧闭包。深比较还可能比重新渲染更贵,本章不把它作为默认修复。

什么时候值得保留这些缓存 ​

这个三项列表的普通 filter 很便宜,生产代码完全可以保持普通计算。只有在真实交互出现可测的重复成本,或者稳定引用确实能让昂贵子树跳过工作时,缓存才有明确收益。useMemo 不能加速第一次计算,还增加了依赖比较和缓存维护。

测量时,在 React DevTools Profiler 中录制同一次交互,比较修改前后的组件耗时;必要时用生产构建核对,开发模式日志只能解释路径。先检查是否把 state 放得过高、传了不需要的整个对象,或把派生值存成额外 state。React Compiler 需要构建配置启用,安装 React 19 本身不意味着自动缓存已经生效;即使启用,纯渲染和正确的数据依赖仍然成立。

练习:可筛选的任务清单 ​

独立实现 App.jsx,数据至少包含一项完成任务和两项未完成任务。父组件保存 tasks、onlyOpen 和一个只改变外层配色的开关;子组件 TaskList 接收 items 与 onToggle,并使用 memo。

  • onlyOpen 为 true 时只显示未完成任务;无结果显示“全部完成”。筛选结果由 tasks 和 onlyOpen 派生,不另存 state。
  • 切换任务必须用不可变更新。onToggle 使用函数式更新和 useCallback,不能捕获过期 tasks;快速依次完成两项,先前修改仍在。
  • useMemo 缓存筛选结果。无关配色改变时,items 和 onToggle 都保持身份;任务或筛选条件改变时,列表必须正确更新。
  • 给筛选计算和 TaskList 添加临时日志,观察配色、筛选和任务操作分别影响谁;再分别移除两个缓存,解释 UI 为什么仍正确而执行路径变化。

完成后对照答案 04。

下一章 ​

副作用与外部系统

用心记录每一步成长