useMemo 最容易被误用的地方,不是语法写错,而是把“避免重渲染”误解成“尽可能缓存一切”。组件重新执行时,创建一个小数组、拼接一段字符串、做一次简单筛选,通常比维护缓存本身更便宜。缓存还会增加依赖管理成本,一旦依赖不完整,得到的往往不是性能提升,而是难以定位的旧数据问题。
function UserPanel({ user }) {
const displayName = useMemo(() => {
return `${user.firstName} ${user.lastName}`;
}, [user.firstName, user.lastName]);
return <span>{displayName}</span>;
}
这段代码没有明显错误,但 useMemo 几乎没有价值。字符串拼接成本极低,React 仍然需要在每次渲染时检查依赖数组、保存缓存结果。直接计算更清楚:
function UserPanel({ user }) {
const displayName = `${user.firstName} ${user.lastName}`;
return <span>{displayName}</span>;
}
判断是否使用 useMemo,重点不在于“这个值会不会在渲染中重复创建”,而在于重新计算是否真的昂贵,以及这个值的引用稳定性是否会影响后续渲染。只有当计算本身较重、依赖在多数渲染中保持稳定,或者结果需要作为稳定引用传给依赖引用比较的子组件或 Hook 时,缓存才有实际意义。
不要把 useMemo 当成默认优化
常见的滥用是为所有派生值加缓存,包括简单布尔判断、少量元素的映射,以及只在当前组件内部立刻使用的对象。
function SearchStatus({ keyword }) {
const hasKeyword = useMemo(() => keyword.trim().length > 0, [keyword]);
return <p>{hasKeyword ? "正在筛选" : "请输入关键词"}</p>;
}
这里的计算没有性能压力,缓存反而让读者需要额外确认依赖是否完整。直接写在渲染逻辑中,意图更明确:
function SearchStatus({ keyword }) {
const hasKeyword = keyword.trim().length > 0;
return <p>{hasKeyword ? "正在筛选" : "请输入关键词"}</p>;
}
另一个误区是为了“固定对象引用”而缓存对象,却没有任何消费者依赖这个稳定引用。
function Profile({ user }) {
const profile = useMemo(
() => ({
id: user.id,
name: user.name,
}),
[user.id, user.name]
);
return <ProfileHeader name={profile.name} />;
}
ProfileHeader 只接收 name,并不关心 profile 是否是同一个对象。此时直接传递所需字段更合适。优化应从真实的渲染链路开始,而不是从“对象每次都会新建”开始。
useMemo、useCallback 与 React.memo 各自解决什么问题
这三个工具经常一起出现,但它们缓存的对象不同,解决的问题也不同。
useMemo 缓存的是一次计算的结果。它适合昂贵计算,或需要保持对象、数组等结果引用稳定的场景。
const visibleItems = useMemo(() => {
return items.filter((item) => item.name.includes(keyword));
}, [items, keyword]);
useCallback 缓存的是函数引用。它通常用于把回调传给经过 React.memo 包装的子组件,或作为其他 Hook 的依赖,避免函数引用每次变化。
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);
React.memo 则作用于组件边界。它会在父组件重新渲染时,根据 props 的浅比较决定子组件是否可以跳过本次渲染。没有 React.memo 的子组件,即使父组件通过 useMemo 稳定了对象引用,子组件通常仍会随父组件重新执行。
const ItemList = React.memo(function ItemList({ items, onSelect }) {
return items.map((item) => (
<button key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</button>
));
});
function ProductPage({ products, keyword }) {
const [selectedId, setSelectedId] = useState(null);
const [theme, setTheme] = useState("light");
const visibleProducts = useMemo(() => {
return products.filter((product) => product.name.includes(keyword));
}, [products, keyword]);
const handleSelect = useCallback((id) => {
setSelectedId(id);
}, []);
return (
<section className={theme}>
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
切换主题
</button>
<ItemList items={visibleProducts} onSelect={handleSelect} />
</section>
);
}
在这个例子中,主题切换会导致 ProductPage 重新渲染。若 products 和 keyword 未变化,visibleProducts 保持同一引用;handleSelect 也保持稳定。配合 React.memo,ItemList 才有机会跳过不必要的重新渲染。
缺少其中任意一环,效果都可能不同:没有 React.memo,子组件通常仍会重渲染;没有 useMemo,过滤结果每次都是新数组,会让 React.memo 的 props 比较失效;没有 useCallback,新的函数引用同样会让子组件继续渲染。
不过,这不代表三者必须打包使用。若子组件渲染很轻,或者父组件本身不常因无关状态变化而重新渲染,增加这些优化的收益可能很有限。
先确认计算成本,而不是猜测
真正适合 useMemo 的通常是数据量较大时的筛选、排序、分组、聚合,或包含多轮转换的派生计算。即便如此,也要看它是否位于频繁重渲染的路径上。
function Report({ records, filters, selectedTab }) {
const summary = useMemo(() => {
const filtered = records.filter((record) => matchesFilters(record, filters));
const grouped = groupRecords(filtered);
return buildSummary(grouped);
}, [records, filters]);
return (
<>
<TabBar selectedTab={selectedTab} />
<SummaryPanel summary={summary} />
</>
);
}
这里 selectedTab 改变时,报表数据不需要重新处理,因此缓存存在合理性。前提是 records 与 filters 在无关渲染中足够稳定。如果调用方每次渲染都会创建新的 filters 对象,useMemo 仍会失效:
<Report records={records} filters={{ status, ownerId }} selectedTab={selectedTab} />
每次父组件渲染都会生成新对象,filters 的引用变化,Report 内的缓存必然重新计算。应当优先从数据流上保持 props 稳定,或者只传递必要的原始值:
<Report
records={records}
status={status}
ownerId={ownerId}
selectedTab={selectedTab}
/>
随后在组件内以实际依赖建立缓存:
const summary = useMemo(() => {
const filters = { status, ownerId };
const filtered = records.filter((record) => matchesFilters(record, filters));
return buildSummary(groupRecords(filtered));
}, [records, status, ownerId]);
这比为上游临时对象强行套一层缓存更容易维护。
依赖数组不是性能开关
依赖数组决定缓存何时失效,也决定计算结果是否仍然正确。遗漏依赖看似能减少重算,实际上是在让派生数据脱离真实输入。
function ProductList({ products, keyword }) {
const visibleProducts = useMemo(() => {
return products.filter((product) => product.name.includes(keyword));
}, [products]);
return <List items={visibleProducts} />;
}
当 keyword 改变时,列表仍然保留旧筛选结果。问题不在 useMemo,而在于依赖数组没有表达完整的数据关系。正确写法应包含计算中读取的所有外部值:
const visibleProducts = useMemo(() => {
return products.filter((product) => product.name.includes(keyword));
}, [products, keyword]);
冗余依赖同样会削弱缓存价值。若把每次渲染都会变化的对象、函数或与计算无关的状态放进依赖数组,缓存会频繁失效。
const visibleProducts = useMemo(() => {
return products.filter((product) => product.name.includes(keyword));
}, [products, keyword, uiState]);
若 uiState 没有参与筛选,它不应成为依赖。否则任何界面状态变化都会触发重新过滤。依赖数组不是“列出组件里所有变量”的地方,而是计算结果真正依赖的输入集合。
闭包过期通常从错误缓存开始
useCallback 的依赖问题与 useMemo 完全同源:回调函数也会捕获当前渲染中的变量。如果为了稳定函数引用而刻意省略依赖,函数可能一直使用旧状态。
function Cart() {
const [count, setCount] = useState(0);
const addItem = useCallback(() => {
setCount(count + 1);
}, []);
return <button onClick={addItem}>数量:{count}</button>;
}
addItem 捕获的是首次渲染时的 count。点击后它会持续尝试把数量设置为同一个结果。这里不应为了空依赖而牺牲正确性,可以把状态更新写成函数形式:
function Cart() {
const [count, setCount] = useState(0);
const addItem = useCallback(() => {
setCount((currentCount) => currentCount + 1);
}, []);
return <button onClick={addItem}>数量:{count}</button>;
}
函数式更新让回调不再读取闭包中的 count,空依赖才有成立的条件。若回调确实需要读取某个外部值,就应把它放入依赖数组,接受函数引用随该值变化。这通常比保留一个稳定但错误的函数更重要。
把优化放在真实瓶颈上
使用 useMemo 前,可以先问三个问题:这段计算在当前数据规模下是否明显昂贵?无关状态变化时,它的依赖是否大多保持稳定?计算结果的引用稳定性是否会让经过优化的子组件或其他 Hook 少做工作?
三个答案中只要缺少关键一项,就不必急着缓存。简单计算直接写在渲染中,往往更清晰,也更不容易出现依赖错误。等到确认存在频繁重算或无效子组件渲染,再用 useMemo、useCallback 和 React.memo 针对实际链路组合优化,性能代码才不会变成新的维护负担。
发布者:jacky,转转请注明出处:https://kubiyun.com/archives/4510
评论列表(8条)
依赖数组漏写 keyword 这个例子挺典型的
简单计算直接写渲染里确实更清爽,少点缓存反而不容易出错
@幻影行者:对,先把逻辑写清楚,真有性能瓶颈再针对性优化会省心很多。
感觉真正难的是理清哪些状态才值得稳引用
@Thorne荆棘:对,最难的是判断有没有真实消费者。没人依赖引用稳定,就别为了“稳”而稳。
这三个优化工具真不是非得打包用
@孤岛旅人:对,关键还是看具体瓶颈:useMemo 稳定值、useCallback 稳定函数、React.memo 控制组件边界,按需组合就好。
上游每次新建对象,缓存基本等于白加