React 性能优化:别急着给所有组件加 memo
从性能测量出发,理解 memo、useMemo 和 useCallback 的真实作用,优先通过状态下沉与合理组件边界减少无效工作。
React 性能优化最容易走偏的一步,就是在还没有发现瓶颈时,先给所有组件加上 memo,再把所有函数包进 useCallback。
缓存有维护成本,也会增加代码的理解难度。更可靠的顺序是:复现问题、测量瓶颈、调整结构,最后才考虑缓存。
1. 渲染不等于修改整个 DOM
组件函数再次执行,意味着 React 要重新计算这部分界面。React 随后比较结果,再提交必要的 DOM 更新;不是每次渲染都会重建整个页面。
不过,组件执行中的昂贵计算仍然会消耗时间。如果列表很大或交互频繁,就需要关注这些计算是否出现在关键路径上。
使用 React DevTools Profiler 观察具体交互,重点看哪些组件耗时最多、为什么发生更新。开发模式、Strict Mode 和调试工具都会影响结果,必要时用接近生产的构建复核。
2. 先把状态放到真正需要它的位置
假设整个页面保存了一个输入框的文字,每输入一个字符都让包含大型报表的父组件重新渲染。如果报表根本不需要这个文字,最简单的优化通常是把输入状态移到输入组件内部。
这比把每个报表组件逐个包上 memo 更直接。状态提升用于共享数据,状态下沉用于限制更新范围;二者都应该服从数据真正的使用位置。
3. 三个 API 分别缓存什么
| API | 缓存对象 | 主要用途 |
|---|---|---|
memo |
组件上一次渲染结果的复用机会 | props 未变化时跳过部分父级引起的渲染 |
useMemo |
计算结果 | 避免重复的昂贵计算或稳定引用 |
useCallback |
函数引用 | 为需要稳定引用的消费者提供函数 |
memo 默认用 Object.is 逐项比较 props。新建的对象、数组和函数,即使内容相同,引用也不同。
memo 不会阻止组件因自身 state 或消费的 Context 变化而重新渲染,也不是绝对的永不执行保证。
4. 一个有边界的缓存示例
下面把输入筛选、主题切换和列表展示放在一起,便于观察引用变化。在 Next.js App Router 中,应将组件放在客户端组件边界内。
import { memo, useCallback, useMemo, useState } from "react";
type Article = { id: string; title: string };
const initialArticles: Article[] = [
{ id: "state", title: "React 状态更新" },
{ id: "hooks", title: "React Hooks 实践" },
{ id: "testing", title: "组件测试入门" },
];
const ArticleList = memo(function ArticleList({
articles,
onSelect,
}: {
articles: Article[];
onSelect: (articleId: string) => void;
}) {
return (
<ul>
{articles.map(article => (
<li key={article.id}>
<button onClick={() => onSelect(article.id)}>{article.title}</button>
</li>
))}
</ul>
);
});
export function ArticleBrowser() {
const [keyword, setKeyword] = useState("");
const [dark, setDark] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const visibleArticles = useMemo(() => {
const normalizedKeyword = keyword.trim().toLowerCase();
return initialArticles.filter(article =>
article.title.toLowerCase().includes(normalizedKeyword)
);
}, [keyword]);
const handleSelect = useCallback((articleId: string) => {
setSelectedId(articleId);
}, []);
return (
<section data-theme={dark ? "dark" : "light"}>
<label>
关键词
<input value={keyword} onChange={event => setKeyword(event.target.value)} />
</label>
<button onClick={() => setDark(previousDark => !previousDark)}>切换主题</button>
<p>当前选择:{selectedId ?? "暂无"}</p>
<ArticleList articles={visibleArticles} onSelect={handleSelect} />
</section>
);
}
主题变化时,keyword 没变,所以 useMemo 可以复用数组引用;handleSelect 引用也稳定,因此列表具备跳过此次父级更新的条件。
空依赖数组在这里成立,是因为回调只使用参数和稳定的 state setter。如果回调读取了关键词等响应式值,就必须列入依赖,不能为了稳定引用牺牲正确性。
这只是演示缓存如何配合工作。三条文章的列表不值得专门优化;data-theme 也只是主题标记,实际颜色仍需 CSS 实现。
5. 什么时候不值得缓存
- 普通字符串拼接、简单加减法等低成本计算。
- 没有稳定引用需求的普通事件函数。
- props 每次都必然变化的组件。
- 为了解决状态设计问题而勉强叠加的缓存。
缓存不是业务语义保证。代码正确性不应该依赖 useMemo 永远保留某个结果;需要持久的组件状态时,应使用 state 或 ref 等合适机制。
如果项目启用了 React Compiler,编译器可能自动完成部分记忆化优化。先确认项目配置和测量结果,再决定是否添加手动缓存。
小结
优先级可以概括为:先测量,再减少更新范围,再降低计算成本,最后缓存确实昂贵且可复用的工作。 对超大列表,虚拟化可能比缓存更有效;对卡顿输入,延后非紧急渲染可能更合适。