引用身份与缓存:为什么加了 memo 仍在渲染
问题:切换页面配色,商品筛选为何也重新执行
父组件的一项 state 改变,React 会重新调用父组件,并通常继续渲染它返回的子组件。即使筛选条件没变,写在函数里的 filter 也会再次执行。对于小列表,这往往足够快;但要理解后面的并发渲染,必须先能区分“哪些输入变了”和“哪些工作可以复用”。
本篇用小数据观察执行路径,不用人为阻塞主线程的大循环制造性能问题。先保证去掉所有缓存后页面依然正确,再研究哪些重复工作可以省掉。
内容相同,不等于同一个引用
先在 JavaScript 控制台运行:
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 模板;开启编译器的项目可能自动复用部分值,使手动删除缓存后的观察不同。
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>
)
}打开控制台,在首次显示完成后清空日志,再按顺序操作:
- 点“切换配色”:
App执行;query 未变,筛选结果复用;两个 Results prop 身份都没变,Results 可跳过本次父级更新。页面配色仍会通过外层样式改变。 - 输入“茶”:query 变了,筛选重新计算,产生新数组;Results 收到新 items,显示一件商品。
- 点“乌龙茶”:selectedId 变化,App 显示 tea;query 和两个 Results prop 都没变,所以没有必要重新计算筛选或结果组件。
- 点“收起结果”:Results 自己的 state 变了,它仍会执行。
memo没有冻结组件的局部交互。
这些日志仅用于检查教学实验的执行路径。Strict Mode 在开发时会额外调用组件和纯计算;热更新也可能重建缓存。观察“某一步涉及哪些组件和计算”,不要把日志行数承诺为固定的一次或两次,更不要用日志次数推断 DOM 一定更新了几次。
一次只拆掉一个条件
先把 visible 改成普通计算,其他部分不动:
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 身份稳定,所以这里可以是空依赖。如果要记录“选择时的搜索词”,修改为:
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。