原文地址:https://hiimvikash.medium.com/4-usememo-usecallback-react-memo-when-it-works-when-it-breaks-and-why-8b9fe4fae536
原文作者: Vikash Gupta
我想聊一个几乎总是和“优化 React”绑定在一起的东西——但老实说,它至少有一半时间并没有按照我们想象的方式工作:memoization。没错,就是那几个老熟人:useMemo、useCallback 和 React.memo。
而且我一点也没有夸张地说“有一半时间”。正确地使用 memoization 其实非常棘手——比表面看起来要难得多。等你读完这篇文章,大概率会一边点头一边感叹“确实如此”。我们将会拆解以下内容:
- memoization 真正要解决的问题是什么(提示:不是单纯的性能)
- useMemo 和 useCallback 在底层到底做了什么,以及它们之间的区别
- 为什么盲目地 memoize props 实际上是一种反模式
- React.memo 真正的作用,以及你必须遵守的使用规则
- memoization 如何与 “elements as children” 模式相互影响
- useMemo 在什么情况下才真的能帮到昂贵计算
- 真正的核心问题:值的比较
真正的问题:值是如何被比较的
一切都要从 JavaScript 如何比较值开始。原始类型——数字、字符串、布尔值——是按值比较的:
const a = 1;
const b = 1;
a === b; // true
很简单,对吧?
但 对象(包括数组和函数)就完全不同了。它们是按引用比较的,而不是按内容。当你这样写时:
const a = { id: 1 };
const b = { id: 1 };
即使这两个对象看起来一模一样,它们在内存中是两个不同的引用。因此:
a === b; // false
只有当两个变量指向完全相同的引用时,比较才会为 true:
const a = { id: 1 };
const b = a;
a === b; // true
而这,正是 React 开始“头疼”的地方。
重新渲染 = 新的引用
每当组件 re-render,React 都会重新执行组件函数。这意味着组件内部的所有局部变量——包括函数——都会被重新创建。
一次新的 render = 一个新的函数引用。
当 React 在 hooks(比如 useEffect)中检查 dependency 时,它做的是引用比较:
const Component = () => {
const submit = () => {};
useEffect(() => {
submit();
}, [submit]); // submit 每次 render 都是新引用
};
因为 submit 每次都会被重新创建,React 总是认为它“变了”,于是 useEffect 在每一次 render时都会执行——即使代码本身完全一样。
useMemo 和 useCallback:它们真正的工作方式
既然我们理解了引用变化的问题,那接下来就该聊聊如何解决它了。我们需要一种方式,让某个值在 re-render 之间保持同一个引用,这样 React 才不会误以为它发生了变化。
这正是 useMemo 和 useCallback 的用武之地。
这两个 Hook 看起来很像,本质目标也完全一致:
只要依赖没有变化,就保持引用不变。
例如,用 useCallback 包裹 submit:
const submit = useCallback(() => {
// no dependencies, reference won‘t change
}, []);
现在,submit 在多次 re-render 之间会保持相同的引用。React 的比较结果为 true,依赖它的 useEffect 也就不会在每次 render 时都触发了:
const Component = () => {
const submit = useCallback(() => {
// submit something here
}, []);
useEffect(() => {
submit();
// submit 是 memoized 的,不会在每次 render 触发
}, [submit]);
return ...;
};
useMemo 也是同样的思路。区别在于:useMemo memoize 的是函数的返回值:
const submit = useMemo(() => {
return () => {
// submit 函数是 useMemo 返回的结果
};
}, []);
真实实现当然要复杂一些,但核心思想就是这样。
一个很小但很重要的区别
它们的 API 有一个关键差异:
- useCallback → 你直接传入要 memoize 的函数
- useMemo → 你传入一个函数,memoize 的是这个函数的返回值
因为这两个 Hook 的第一个参数都是函数,并且它们是在组件内部调用的,所以有一点非常重要:
你传入的那个函数本身,每次 render 都会被重新创建。 这是 JavaScript 的行为,不是 React 的问题。
例如:
const func = (callback) => {
// do something
};
func(() => {}); // 第一次调用
func(() => {}); // 第二次调用 —— 新函数
useMemo 和 useCallback 也是一样的。它们只是普通函数,只不过 React 会把它们和组件生命周期绑定起来。
React 在底层到底做了什么
React 本质上是在缓存结果。可以用一个极简的伪实现来理解:
useCallback 的伪实现:
let cachedCallback;
const func = (callback) => {
if (dependenciesEqual()) {
return cachedCallback;
}
cachedCallback = callback;
return callback;
};
- 依赖没变 → 返回缓存的函数
- 依赖变了 → 更新缓存
useMemo 的伪实现:
let cachedResult;
const func = (callback) => {
if (dependenciesEqual()) {
return cachedResult;
}
cachedResult = callback();
return cachedResult;
};
思路完全一致,只是 useMemo 缓存的是 callback 的返回值,而不是 callback 本身。
反模式:为了 memoize 而 memoize props
这是最常见、也最容易犯的错误之一:“顺手”给 props 加 useCallback / useMemo。
你大概率见过(或者写过)这样的代码:
const Component = () => {
const onClick = useCallback(() => {
// do something on click
}, []);
return <button onClick={onClick}>click me</button>;
};
看起来很干净,对吧?但现实是——这个 useCallback 什么也没优化。
有一种非常流行的误解(甚至 ChatGPT 以前也这么说过):memoize props 可以防止组件 re-render。
这是错的。
如果组件 re-render,那么组件内部的一切都会重新执行。给 onClick 包一层 useCallback 并不会改变这一点。
在这个例子中:
- 没有性能收益
- React 反而做了更多工作
- 代码可读性还变差了
而且现实中,useCallback 从来不会只出现一个。一个接一个,依赖相互交织,最后代码变成一片 useMemo / useCallback 的迷宫,没人敢动。
那什么时候才真的需要 memoize props?
实际上,只有 两种 情况。
1. 当这个 prop 会被 child 用作依赖
例如:
const Parent = () => {
// 必须 memoize
// Child 在 useEffect 中使用它
const fetch = () => {};
return <Child onMount={fetch} />;
};
const Child = ({ onMount }) => {
useEffect(() => {
onMount();
}, [onMount]);
};
很直接:
如果一个 prop 会进入 dependency array,它必须是稳定引用。
否则 hook 每次 render 都会执行。这条规则适用于整条 props 传递链。
2. 当组件被 React.memo 包裹时
React.memo 用来 memoize 组件本身。它告诉 React:
“如果 parent re-render,但 props 没变,就不要 re-render 我。”
就是这么简单。
但前提是:props 必须是稳定的。
下面是一个经典“看起来对,其实错”的例子:
const Child = ({ data, onChange }) => {};
const ChildMemo = React.memo(Child);
const Component = () => {
return (
<ChildMemo
data={{ ...some_object }}
onChange={() => { /* ... */ }}
/>
);
};
data 和 onChange 都是 inline 创建的,每次 render 都是新引用。
结果就是:
- React 检查 props
- 发现变了
- ChildMemo 依然 re-render
React.memo 直接失效。
useMemo / useCallback 真正该出场的地方
const Child = ({ data, onChange }) => {};
const ChildMemo = React.memo(Child);
const Component = () => {
const data = useMemo(() => ({ ... }), []);
const onChange = useCallback(() => {}, []);
return <ChildMemo data={data} onChange={onChange} />;
};
现在 data 和 onChange 在 render 之间保持同一个引用。React 对比 props,发现没变,于是跳过 re-render。
👉 这才是 memoization 正确且有意义的用法。
但问题在于……
确保传入 memoized component 的所有 props 都是稳定的,远比想象中困难。
只要有一个 prop 不稳定,整条链就会崩塌:
- React.memo 失效
- useCallback 变得毫无意义
- useMemo 也白写了
React.memo 和“从 props 传下来的 props”
这是 memoization 最容易被无意破坏的场景之一,尤其是使用 props spread 时:
const Child = () => {};
const ChildMemo = React.memo(Child);
const Component = (props) => {
return <ChildMemo {...props} />;
};
const ComponentInBetween = (props) => {
return <Component {...props} />;
};
const InitialComponent = (props) => {
return (
<ComponentInBetween {...props} data={{id: '1' }} />
);
};
请诚实回答:有多大概率,有人会在 InitialComponent 里加一个 data prop 后,去检查整条组件链里有没有 React.memo?
基本为零。
但问题是:
- data 是 inline 对象
- 引用每次都变
- ChildMemo 的 memoization 被悄悄破坏
使用 React.memo 的几条规则
Rule 1: Never spread props from other components
避免这样写:
const Component = (props) => {
return <ChildMemo {...props} />;
};
props spread 是黑盒。只要有一个不稳定值,就会破坏 memoization。
改成显式传递:
const Component = (props) => {
return <ChildMemo some={props.some} other={props.other} />;
};
Rule 2: Avoid non-primitive props from other components
只要是对象、数组、函数,而且没 memoize,React.memo 就会失效。
一个非原始值就够了。
Rule 3: Avoid non-primitive values from custom hooks
这是最容易踩坑的一条。
const Component = () => {
const { submit } = useForm();
return <ChildMemo onChange={submit} />;
};
你根本不知道 submit 是否稳定。
如果 hook 内部是这样:
const useForm = () => {
const submit = () => {};
return { submit };
};
submit 每次 render 都是新函数。ChildMemo 的 memoization 直接失效。
React.memo 和 children
const ChildMemo = React.memo(Child);
const Component = () => {
return (
<ChildMemo>
<div>Some text here</div>
</ChildMemo>
);
};
看起来没问题,对吧?但 memoization 又坏了。
原因是:
<ChildMemo>
<div>Some text here</div>
</ChildMemo>
// 等价于
<ChildMemo children={<div>Some text here</div>} />
JSX element 本身就是对象。每次 render,children 都是一个新对象。
解决方案:
const Component = () => {
const content = useMemo(() => <div>Some text here</div>, []);
return <ChildMemo>{content}</ChildMemo>;
};
同样适用于 render props。
React.memo + memoized children(差一点)
const ChildMemo = React.memo(Child);
const ParentMemo = React.memo(Parent);
const Component = () => {
return (
<ParentMemo>
<ChildMemo />
</ParentMemo>
);
};
这依然是错的。
因为 <ChildMemo /> 依然是一个新的 element 对象。
修复方式:
const Component = () => {
const child = useMemo(() => <ChildMemo />, []);
return <ParentMemo>{child}</ParentMemo>;
};
甚至你会发现:
const Component = () => {
const child = useMemo(() => <Child />, []);
return <ParentMemo>{child}</ParentMemo>;
};
Child 是否 memoized 已经不重要了。
useMemo 和“昂贵计算”
最后聊一个最常被滥用的场景。
什么算“昂贵”?
- 拼接字符串?
- 排序 300 条数据?
- 对 5000 字文本跑正则?
答案是:不知道,除非你真的去测量。
你必须在:
中去测。
很多时候:
- JS 计算只要 2ms
- UI re-render 却要 20ms
该优化哪个?答案显而易见。
最终规则
- 先测量,不要假设
- 先优化 re-render,再考虑 useMemo
- useMemo 只在 re-render 时才有意义
- 滥用 useMemo 会拖慢首屏渲染
一个 useMemo 不会有问题。但上百个?那就是 death by a thousand cuts。
希望本文能帮助你更清晰地理解 React 中的性能优化手段。更多关于前端框架和工程化的深度讨论,欢迎访问云栈社区的前端板块进行交流。记住,正确的工具用在正确的地方,才能发挥最大的价值。