React 基础体系 · 第 28/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。
React Memo、useMemo 与 useCallback:引用、成本和正确边界
React.memo、useMemo 和 useCallback 都与“跳过不必要的工作”有关,但它们优化的对象不同:
React.memo作用于组件是否需要再次执行渲染函数。useMemo作用于某次组件渲染期间计算出的值。useCallback作用于函数引用,本质上是函数形式的useMemo。
它们共同依赖一个前提:React 必须能够判断“这次输入是否仍然等价”。这个判断通常不是深度比较,而是引用比较。因此,理解引用身份、依赖数组、组件边界和比较成本,比记住几个 API 的调用形式更重要。
一、先建立核心模型:渲染不是 DOM 更新
React 组件可以抽象为一个函数:
其中:
- 是 props;
- 是组件自身的 state;
- 是组件读取到的 context;
- 是组件函数;
- 是组件返回的 React 元素树。
组件函数再次执行,称为一次渲染。渲染之后,React 还会比较新旧结果,并决定是否需要更新真实 DOM。因而:
组件函数执行了,不代表 DOM 一定发生了变化;
DOM 没有变化,也不代表组件函数没有执行。
例如:
function Greeting({ name }: { name: string }) {
console.log("Greeting render");
return <h1>Hello, {name}</h1>;
}
如果父组件重新渲染,普通情况下 Greeting 也会再次执行,即使 name 仍然是同一个字符串。React 之后可能发现生成的 <h1> 内容没有变化,因此不修改 DOM,但 Greeting 的函数体已经运行过。
性能优化通常针对两个不同阶段:
- 减少组件渲染函数执行:
React.memo; - 减少渲染函数内部昂贵计算:
useMemo; - 保持传给子组件的函数引用稳定:
useCallback。
这三个目标不能互相替代。
二、引用相等:三个 API 共享的基础
JavaScript 中,原始值和对象的相等行为不同:
console.log(Object.is(1, 1)); // true
console.log(Object.is("a", "a")); // true
console.log(Object.is(null, null)); // true
console.log(Object.is({ id: 1 }, { id: 1 })); // false
console.log(Object.is([], [])); // false
console.log(Object.is(() => {}, () => {})); // false
两个对象即使内容相同,只要不是同一个对象,就不相等。函数也是对象,因此每次创建函数表达式通常都会产生新的引用:
const first = () => 1;
const second = () => 1;
console.log(Object.is(first, second)); // false
React 的默认 props 比较使用 Object.is 逐项比较,而不是递归深比较。形式化地说,对于 props:
其中 是 props 的键集合。只有每个 prop 都通过比较,React 才认为 props 没有变化。
这解释了下面的结果:
const sameText = "hello";
<Child label={sameText} />;
<Child label={sameText} />;
label 是同一个字符串值,可以通过比较;而下面的对象每次都是新引用:
<Child options={{ theme: "dark" }} />;
<Child options={{ theme: "dark" }} />;
两次 options 在内容上相同,但引用不同。
需要特别注意 Object.is 的边界:
Object.is(NaN, NaN); // true
Object.is(0, -0); // false
这不是工程中最常见的问题,但说明 React 的默认判断是明确的引用和值语义,而不是“看起来相同”。
三、React.memo:缓存组件的渲染结果是否需要重新计算
3.1 基本用法
import { memo, useState } from "react";
type UserCardProps = {
name: string;
};
const UserCard = memo(function UserCard({ name }: UserCardProps) {
console.log("UserCard render");
return <div>{name}</div>;
});
export default function App() {
const [count, setCount] = useState(0);
return (
<>
<button onClick={() => setCount((value) => value + 1)}>
count: {count}
</button>
<UserCard name="Ada" />
</>
);
}
点击按钮时,App 会重新渲染。由于传给 UserCard 的 name 仍然是同一个字符串,memo 可以跳过 UserCard 的再次执行。
memo(Component) 返回一个经过记忆化处理的组件。对由父组件传入的 props,React 默认进行逐项浅比较:
父组件重新渲染
│
▼
比较 memo 子组件的新旧 props
│
┌────┴────┐
│ │
相等 不相等
│ │
跳过子组件 执行子组件
这里的“跳过”指跳过该组件因父组件更新而产生的渲染,不代表它永远不会渲染。
3.2 memo 不会阻止所有重新渲染
以下情况仍然可能使 memo 组件重新渲染:
- 它自己的 state 发生变化;
- 它读取的 context 发生变化;
- 父组件传入的 props 引用发生变化;
- 组件被卸载后重新挂载;
- 开发环境下某些检查机制重新调用渲染逻辑。
例如:
import { memo, useContext, useState } from "react";
const ThemeContext = /* 某个 React context */ null as never;
const Panel = memo(function Panel() {
const theme = useContext(ThemeContext);
const [open, setOpen] = useState(false);
return (
<button onClick={() => setOpen((value) => !value)}>
{theme} / {open ? "open" : "closed"}
</button>
);
});
即使 Panel 被 memo 包裹,open 的变化仍然需要重新渲染,context 的变化也不能靠 memo 阻止。
因此,memo 的准确含义不是“组件只渲染一次”,而是:
当父组件重新渲染时,如果外部 props 没有变化,React 可以跳过该组件由这次父级更新引起的渲染。
3.3 memo 对 JSX children 也适用引用比较
下面的代码中,<Icon /> 每次父组件渲染都会创建新的 React 元素对象:
const Box = memo(function Box({
children,
}: {
children: React.ReactNode;
}) {
console.log("Box render");
return <section>{children}</section>;
});
function Page() {
return (
<Box>
<span>内容</span>
</Box>
);
}
children 是一个新的 React 元素引用,因此 Box 的 props 可能被判断为变化。若 children 来自稳定引用,结果则不同:
const stableChildren = <span>内容</span>;
function Page() {
return <Box>{stableChildren}</Box>;
}
这不是说应该到处缓存 JSX,而是说明 memo 比较的是 props 引用,不会理解“两个 React 元素描述的内容看起来一样”。
3.4 自定义比较函数:必须保持判断的正确性
memo 可以接收自定义比较函数:
const UserCard = memo(
function UserCard({
id,
name,
}: {
id: string;
name: string;
}) {
return <div>{id}: {name}</div>;
},
(previous, next) => {
return previous.id === next.id && previous.name === next.name;
},
);
比较函数返回:
true:认为 props 等价,可以跳过;false:认为 props 不等价,需要重新渲染。
这里最危险的错误是只比较部分会影响输出的 props:
const BadCard = memo(
function BadCard({ name }: { name: string }) {
return <div>{name}</div>;
},
() => true,
);
如果父组件后来把 name 从 "Ada" 改成 "Grace",比较函数仍返回 true,React 就可能继续显示旧内容。
自定义比较函数必须满足一个正确性条件:
也就是说,只有当所有影响组件输出和行为的 props 都等价时,才能返回 true。否则优化会变成数据过期或界面错误。
此外,深比较也不是免费操作。假设:
- 子组件重新渲染成本为 ;
- props 比较成本为 ;
- 父组件触发更新次数为 ;
- 其中有 次 props 实际变化。
不使用 memo 的近似成本是:
使用 memo 后的近似成本是:
只有在:
时,memo 才有机会带来收益。轻量组件的 很小,比较本身的 可能足以抵消收益。
四、useMemo:缓存一次渲染中的计算结果
4.1 基本语义
import { useMemo } from "react";
const visibleItems = useMemo(
() => filterItems(items, query),
[items, query],
);
useMemo 接收:
- 一个计算函数;
- 一个依赖数组。
在依赖没有变化时,React 可以复用上一次计算得到的值;依赖变化时,重新执行计算函数。
依赖数组中的比较也基于 Object.is。可以把它抽象成:
如果依赖相同,返回缓存值;否则执行计算函数并保存新值。
4.2 完整示例:过滤列表、稳定函数和 memo 子组件
下面的例子展示三个机制如何配合:
"use client";
import { memo, useCallback, useMemo, useState } from "react";
type Product = {
id: number;
name: string;
category: "book" | "tool";
};
const products: Product[] = [
{ id: 1, name: "TypeScript Handbook", category: "book" },
{ id: 2, name: "Keyboard", category: "tool" },
{ id: 3, name: "React Guide", category: "book" },
];
type ProductRowProps = {
product: Product;
onSelect: (id: number) => void;
};
const ProductRow = memo(function ProductRow({
product,
onSelect,
}: ProductRowProps) {
console.log("render row", product.id);
return (
<li>
<span>{product.name}</span>
<button onClick={() => onSelect(product.id)}>选择</button>
</li>
);
});
export default function ProductList() {
const [query, setQuery] = useState("");
const [category, setCategory] = useState<"all" | Product["category"]>("all");
const [selectedId, setSelectedId] = useState<number | null>(null);
const visibleProducts = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return products.filter((product) => {
const matchesQuery =
normalizedQuery === "" ||
product.name.toLowerCase().includes(normalizedQuery);
const matchesCategory =
category === "all" || product.category === category;
return matchesQuery && matchesCategory;
});
}, [query, category]);
const handleSelect = useCallback((id: number) => {
setSelectedId(id);
}, []);
return (
<section>
<label>
搜索:
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
<label>
分类:
<select
value={category}
onChange={(event) =>
setCategory(event.target.value as "all" | Product["category"])
}
>
<option value="all">全部</option>
<option value="book">书籍</option>
<option value="tool">工具</option>
</select>
</label>
<p>已选择:{selectedId ?? "无"}</p>
<ul>
{visibleProducts.map((product) => (
<ProductRow
key={product.id}
product={product}
onSelect={handleSelect}
/>
))}
</ul>
</section>
);
}
这个例子中的因果链是:
query或category变化;visibleProducts的依赖变化;useMemo重新执行过滤;- 过滤后的数组被传给
map; handleSelect的依赖为空,因此在组件生命周期内保持同一个返回引用;- 对于仍然存在且 props 未变化的
ProductRow,memo有机会跳过渲染。
但 useMemo 并不是为了让“每次过滤都变快”。这里的过滤数据只有三个元素,计算成本极低,缓存本身可能没有实际收益。这个例子的主要价值是展示引用关系;真实项目中是否值得保留,应由计算成本和 Profiler 数据决定。
4.3 useMemo 缓存的是引用,不是“内容相等性”
const options = useMemo(
() => ({ matchMode: "whole-word", text: query }),
[query],
);
当 query 不变时,options 通常保持同一个对象引用:
const options1 = /* 第一次渲染得到的对象 */;
const options2 = /* 依赖未变时得到的对象 */;
Object.is(options1, options2); // 通常为 true
这可能用于稳定传给 memo 子组件的 props,或者稳定另一个 Hook 的依赖。
但是,useMemo 不会把对象变成不可变对象,也不会自动阻止代码修改它:
const config = useMemo(() => ({ enabled: true }), []);
config.enabled = false; // TypeScript 可能允许,运行时也可能发生
React 只负责缓存引用,不负责维护对象内部的业务不变性。要避免这类问题,应通过类型、封装和不可变更新约束数据,而不是把 useMemo 当作冻结机制。
4.4 依赖数组不完整会产生旧值
错误示例:
const visibleItems = useMemo(
() => filterItems(items, query),
[items], // 忘记 query
);
当 query 改变而 items 不变时,React 会认为依赖没有变化,继续返回旧的过滤结果。表现可能是输入框已经显示新关键词,列表却没有同步更新。
依赖数组的正确条件是:
“外部值”包括 props、state,以及在组件函数中定义并被计算函数读取的变量。并不意味着所有值都必须机械加入数组;例如模块级常量通常不会在渲染期间变化。但对组件作用域内的响应式值,应让依赖分析工具帮助检查。
4.5 useMemo 不是语义保证
React 官方将 useMemo 定义为性能优化工具,而不是程序正确性所必需的缓存协议。React 在某些情况下可能丢弃缓存并重新计算,例如:
- 开发环境的检查行为;
- 组件初次挂载过程中发生中断或挂起;
- React 未来的调度策略;
- 组件被卸载后重新挂载。
因此,计算函数必须是可重复执行的,不应依赖“它只会执行一次”:
const value = useMemo(() => {
externalList.push("side effect"); // 不应这样做
return expensiveCalculation();
}, []);
useMemo 的计算函数应尽量是纯计算:给定相同输入,得到相同结果,并且不依赖执行次数。
不要使用下面的方式保存必须长期存在的业务数据:
const session = useMemo(() => createSession(), []);
如果 session 的生命周期和组件状态相关,应考虑 useState;如果需要稳定引用但不触发渲染,可考虑 useRef;如果它是跨请求、跨组件或服务端缓存,则应使用对应的数据缓存机制,而不是把 useMemo 当作通用缓存。
五、useCallback:缓存函数引用
5.1 它解决的是“引用变化”,不是“函数执行变快”
基本写法:
const handleClick = useCallback(() => {
setCount((value) => value + 1);
}, []);
useCallback(fn, dependencies) 可以近似理解为:
useMemo(() => fn, dependencies);
它返回一个函数引用。在依赖没有变化时,返回上一次的函数;依赖变化时,返回新的函数。
但要准确理解“缓存函数”:
const handleClick = useCallback(() => {
setCount((value) => value + 1);
}, []);
组件每次执行时,函数表达式本身仍会被求值并产生函数对象;React 决定是否把新函数作为结果返回。useCallback 的主要收益不是消灭 JavaScript 中函数表达式的所有创建成本,而是让下游看到的返回引用保持稳定。
因此它通常只有在以下场景中有意义:
- 函数被传给
React.memo子组件; - 函数作为另一个 Hook 的依赖;
- 某个外部订阅或系统 API 需要稳定的回调身份。
如果函数只在当前组件内部使用,并没有作为依赖或 props 传递,稳定它通常没有可观察收益:
function SearchBox() {
const [query, setQuery] = useState("");
const normalized = query.trim().toLowerCase();
return (
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
);
}
这里的内联事件函数没有传给 memo 子组件,也没有作为 Hook 依赖,通常不需要为了形式统一而包裹 useCallback。
5.2 闭包决定依赖数组
函数会捕获定义它时可见的变量:
function UserPanel({ userId }: { userId: string }) {
const handleLoad = useCallback(() => {
console.log(userId);
}, [userId]);
return <button onClick={handleLoad}>加载</button>;
}
如果遗漏 userId:
const handleLoad = useCallback(() => {
console.log(userId);
}, []); // 错误:会捕获初次渲染的 userId
当 userId 改变时,handleLoad 仍然可能引用旧值。这就是典型的 stale closure(过期闭包)。
状态更新函数的函数式写法可以减少对旧 state 的直接依赖:
const increment = useCallback(() => {
setCount((current) => current + 1);
}, []);
因为回调没有读取组件作用域中的 count,而是把更新逻辑交给 React 提供的当前值,所以依赖数组可以为空。
对比下面两种写法:
const increment = useCallback(() => {
setCount(count + 1);
}, [count]);
const increment = useCallback(() => {
setCount((current) => current + 1);
}, []);
第一种回调依赖 count,每次 count 改变都会产生新函数;第二种使用函数式更新,回调不需要捕获具体的 count,引用可以更稳定。二者都可能正确,区别在于闭包和依赖关系,而不是某个写法永远更快。
5.3 useCallback 和 memo 必须形成引用链
下面的 memo 不一定有用:
const Button = memo(function Button({
onClick,
}: {
onClick: () => void;
}) {
return <button onClick={onClick}>保存</button>;
});
function Page() {
const [count, setCount] = useState(0);
const handleSave = () => {
console.log("save");
};
return (
<>
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
<Button onClick={handleSave} />
</>
);
}
每次 Page 渲染,handleSave 都是新函数。因此 Button 的 onClick prop 变化,memo 可能无法跳过。
配合 useCallback 后:
function Page() {
const [count, setCount] = useState(0);
const handleSave = useCallback(() => {
console.log("save");
}, []);
return (
<>
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
<Button onClick={handleSave} />
</>
);
}
当 Page 只因 count 改变而重新渲染时,Button 收到的 onClick 引用仍然相同,memo 才有机会发挥作用。
但是,如果 Button 还收到其他每次都变化的 props,单独稳定 onClick 仍不足以阻止渲染:
<Button
onClick={handleSave}
style={{ color: "blue" }} // 每次都是新对象
/>
优化是否成立,要观察完整的 props 引用图,而不是只看某一个回调。
六、三个 API 的边界对比
| API | 缓存对象 | 主要判断依据 | 典型用途 | 不解决的问题 |
|---|---|---|---|---|
React.memo |
组件因父级更新产生的渲染 | props 比较 | 跳过昂贵子组件渲染 | state、context、变化的 props |
useMemo |
一个计算结果的引用 | 依赖数组 | 避免昂贵计算或稳定对象/数组 | 数据持久化、深度相等、业务缓存 |
useCallback |
一个函数引用 | 依赖数组 | 稳定传给子组件或作为依赖的回调 | 让函数执行本身更快、自动修复闭包 |
可以用一个更完整的成本模型理解它们。
设:
- :子组件渲染成本;
- :父组件中某个计算成本;
- :
memo的 props 比较成本; - :Hook 依赖比较和缓存管理成本;
- :发生的渲染次数;
- :依赖或输入实际变化的次数。
使用 useMemo 的近似成本:
不使用时:
使用 useMemo 可能有收益的条件是:
同理,useCallback 带来的收益通常不是直接减少回调执行,而是减少因为回调引用变化导致的下游成本:
如果回调没有传给任何敏感的下游边界,那么“保持函数稳定”没有可跳过的成本,收益接近于零。
七、一个完整反例:缓存了错误的依赖
下面的代码看起来像是在优化过滤:
function TodoList({
todos,
query,
}: {
todos: { id: number; title: string }[];
query: string;
}) {
const filteredTodos = useMemo(() => {
return todos.filter((todo) =>
todo.title.toLowerCase().includes(query.toLowerCase()),
);
}, [todos]);
return (
<ul>
{filteredTodos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
状态变化过程如下:
- 初始输入:
todos = [...],query = ""; useMemo计算全部任务;- 用户输入
"react"; query变化,但todos引用不变;- React 判断依赖数组没有变化;
useMemo返回旧数组;- 输入框可能已经显示
"react",列表仍显示全部任务。
这里的问题不是 useMemo 失效,而是代码错误地声明了依赖关系。修正为:
const filteredTodos = useMemo(() => {
const normalizedQuery = query.toLowerCase();
return todos.filter((todo) =>
todo.title.toLowerCase().includes(normalizedQuery),
);
}, [todos, query]);
如果不需要缓存,也可以直接计算:
const filteredTodos = todos.filter((todo) =>
todo.title.toLowerCase().includes(query.toLowerCase()),
);
后者更简单,且在列表很小或父组件渲染次数不高时可能更合适。
八、一个完整反例:自定义比较函数造成旧界面
type ProfileProps = {
user: {
id: string;
name: string;
avatarUrl: string;
};
};
const Profile = memo(
function Profile({ user }: ProfileProps) {
return (
<article>
<img src={user.avatarUrl} alt="" />
<strong>{user.name}</strong>
</article>
);
},
(previous, next) => previous.user.id === next.user.id,
);
假设服务端返回了同一个用户的新头像:
previous.user = {
id: "1",
name: "Ada",
avatarUrl: "/ada-old.png",
};
next.user = {
id: "1",
name: "Ada",
avatarUrl: "/ada-new.png",
};
比较函数只看 id,返回 true,于是组件可能继续使用旧的头像。虽然用户身份没有变,但组件输出依赖 avatarUrl,所以两个 props 并不等价。
更可靠的选择通常是:
const Profile = memo(function Profile({ user }: ProfileProps) {
return (
<article>
<img src={user.avatarUrl} alt="" />
<strong>{user.name}</strong>
</article>
);
});
让数据层保持不可变引用,当用户对象内容更新时产生新引用,默认浅比较即可工作。只有在确实测量到默认比较不足时,才应考虑自定义比较,并列出所有影响渲染的字段。
九、对象、数组和函数的引用稳定策略
9.1 不要为了 memo 而无条件制造缓存
const style = useMemo(() => ({ color: "red" }), []);
这能稳定 style 引用,但如果子组件很轻量,或者子组件本身没有使用 memo,这个缓存通常没有实际价值。
可以先采用更直接的写法:
<Label style={{ color: "red" }} />
只有当以下因果链真实存在时,稳定引用才值得考虑:
父组件频繁更新
│
▼
新建对象/数组/函数
│
▼
memo 子组件 props 被判定为变化
│
▼
子组件执行昂贵渲染
9.2 useMemo 只能稳定当前组件生命周期内的引用
const columns = useMemo<ColumnDef<Row>[]>(
() => [
{ key: "name", title: "名称" },
{ key: "status", title: "状态" },
],
[],
);
这类写法常用于表格列定义、配置对象或派生数组。但空依赖数组表达的是:
只要这个组件实例仍然存在,就不依赖组件渲染期间的动态值。
如果列定义读取了 props,却使用空依赖:
const columns = useMemo(
() => [{ title: `${userName} 的数据` }],
[],
);
那么 userName 后续变化时标题可能保持旧值。应改为:
const columns = useMemo(
() => [{ title: `${userName} 的数据` }],
[userName],
);
或者如果计算很简单,直接写成普通表达式,避免引入缓存语义。
十、开发环境行为与并发渲染边界
React 现代渲染模型允许渲染工作被开始、暂停、继续或放弃。组件渲染函数和 useMemo 计算函数都不应承担必须只执行一次的副作用。
开发环境下启用 Strict Mode 时,React 还可能故意重复调用某些逻辑,以帮助发现不纯函数。例如:
const value = useMemo(() => {
console.log("calculate");
return expensiveCalculation(data);
}, [data]);
开发环境中看到计算函数打印多次,并不能直接证明 useMemo 在生产环境失效。应区分:
- 规范保证:依赖未变化时,React 可以复用缓存;
- 实现和开发检查行为:开发环境可能额外调用;
- 优化假设:不能把缓存当作永久存储或副作用执行点。
在并发渲染中,一次渲染也可能没有最终提交到屏幕。因而以下行为是不安全的:
const result = useMemo(() => {
analytics.track("calculated"); // 不应在这里上报
return calculate();
}, [input]);
副作用应放在适合的生命周期机制中,例如 useEffect,并准确处理依赖和清理;纯计算则留在 useMemo 或普通表达式中。
十一、客户端与服务端边界
11.1 Hook 代码必须位于客户端组件边界内
在使用 React Server Components 的框架中,服务端组件和客户端组件职责不同。useState、useEffect、useMemo、useCallback 等客户端 Hook 应放在客户端组件中,通常需要在文件顶部声明:
"use client";
import { useCallback, useMemo, useState } from "react";
这里的 "use client" 是模块边界声明,不是普通运行时函数调用。它表示该模块及其客户端依赖需要进入客户端组件图。
服务端组件不能简单地把一个客户端回调传给浏览器:
// 服务端组件中不能把普通函数作为客户端 props 直接传递
<ClientButton onClick={() => console.log("click")} />
函数通常不可序列化,服务端生成的组件树需要通过框架规定的协议传递到客户端。正确方式是让客户端组件在客户端定义交互逻辑:
// ClientButton.tsx
"use client";
import { useCallback } from "react";
export function ClientButton() {
const handleClick = useCallback(() => {
console.log("click");
}, []);
return <button onClick={handleClick}>点击</button>;
}
不过,即使在客户端组件中,useCallback 也不是因为“事件处理函数必须缓存”才存在。只有当这个回调需要稳定地传给 memo 子组件、Effect、订阅系统等,缓存才可能有意义。
11.2 服务端渲染不等于服务端缓存
组件在服务端渲染时可能执行函数体,但 useMemo 的引用缓存不应被理解为跨请求缓存:
- 它不是数据库缓存;
- 不是 HTTP 缓存;
- 不是跨请求共享的计算结果;
- 也不能保证客户端水合后仍然拥有同一个 JavaScript 引用。
服务端数据缓存应使用框架提供的请求缓存、数据缓存或服务端专用 API。useMemo 的边界是组件渲染过程中的性能优化,而不是服务器级缓存策略。
十二、React 19 与自动记忆化
React 生态正在提供 React Compiler 等自动优化能力。在启用并正确配置编译器的项目中,部分值和函数可能由编译器自动进行记忆化,从而减少手写 useMemo 和 useCallback 的需要。
但这不改变几个基础事实:
- 自动优化是否生效取决于项目的编译配置、代码是否满足分析条件以及框架集成方式;
React.memo、useMemo和useCallback的语义仍然需要理解,因为它们可能仍出现在现有代码和第三方库中;- 自动记忆化不会修复错误的状态建模、错误的依赖、可变对象或不正确的自定义比较函数;
- 不能因为使用 React 19 就假设所有组件都会自动跳过渲染。
如果项目启用了 React Compiler,应以项目实际构建配置和官方当前文档为准。手写缓存不应作为“看起来更专业”的装饰;它应当服务于明确的数据流和测量结果。
十三、如何诊断优化是否真的成立
看到组件频繁渲染时,先确认问题属于哪一类:
13.1 组件执行成本高
使用 React DevTools Profiler 观察:
- 哪个组件重新渲染;
- 是什么更新触发了它;
- 渲染耗时集中在哪里;
- 子树是否因为某个父级状态而整体重新执行。
如果组件本身昂贵,并且父级更新频繁,可以评估 React.memo。
13.2 计算成本高
把计算拆成可测量的函数:
function buildRows(
items: Item[],
query: string,
): Item[] {
return items.filter((item) =>
item.name.toLowerCase().includes(query.toLowerCase()),
);
}
在开发诊断阶段可以测量:
const start = performance.now();
const rows = buildRows(items, query);
const elapsed = performance.now() - start;
console.log(`buildRows: ${elapsed.toFixed(2)}ms`);
不要把开发环境日志的次数直接当成生产性能结论;应结合生产构建、真实数据规模和用户交互路径判断。
13.3 props 引用不稳定
检查传给 memo 子组件的 props:
<Child
data={data}
options={{ dense: true }}
onSelect={(id) => select(id)}
/>
这里 options 和 onSelect 每次都会产生新引用。可以先确认它们是否真的导致了昂贵子组件重新渲染,再决定是否改成:
const options = useMemo(() => ({ dense: true }), []);
const onSelect = useCallback((id: number) => {
select(id);
}, [select]);
如果 select 本身每次变化,onSelect 也会变化;稳定回调的依赖关系必须真实反映闭包读取的数据。
十四、正确边界:什么时候使用,什么时候不使用
适合考虑 React.memo 的情况:
- 子组件渲染成本明显较高;
- 父组件更新频繁;
- 子组件 props 大多数时候保持引用稳定;
- Profiler 能观察到重复渲染确实构成瓶颈。
适合考虑 useMemo 的情况:
- 计算本身明显昂贵;
- 计算结果作为
memo子组件的 prop,且需要稳定引用; - 计算结果作为另一个 Hook 的依赖;
- 依赖关系能够被准确表达。
适合考虑 useCallback 的情况:
- 回调传给了
React.memo子组件; - 回调作为 Effect、订阅或其他 Hook 的依赖;
- 外部系统明确要求回调身份稳定。
不应仅因为以下理由使用它们:
- “所有函数都应该用
useCallback”; - “所有对象都应该用
useMemo”; - “包上
memo就不会再渲染”; - “空依赖数组等于只执行一次”;
- “深比较一定比重新渲染便宜”。
最终可以用一个问题判断边界:
这次引用变化是否会导致一个可测量、可避免且正确地可跳过的工作?
如果没有明确的下游成本,缓存只是增加依赖维护、比较和认知负担。真正可靠的优化不是尽可能多地使用 Memo API,而是让状态边界、数据引用和组件职责清晰,然后只在成本模型支持时缓存。
系列导航与关联阅读
- 系列入口:React 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React 自定义 Hook:契约、组合、副作用、稳定性和测试
- 下一篇:React useLayoutEffect 与 useInsertionEffect:时机、测量和闪烁
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论