前置知识:
- 这篇文章并不是面向 0 基础新同学的,需要有一些 react / vue 基础
- 最好了解过这篇文章:重学 React -- 渲染那些事,有些上下文是联动的
从一段代码说起
先上一段简单的 demo 代码:
- 父组件
Father
有一个count
的state
,有两个子组件Son
以及一个div
- 第一个子组件
Son
接受来自父组件的props
- 第二个子组件
Son
只是一个纯文字展示,不涉及任何状态 - 点击父组件的
div
,count
会加一
// 父组件
const Father = () => {
const [count, setCount] = useState(0);
return (
<div className="app">
<Son count={count}></Son>
<Son></Son>
<div onClick={() => setCount((count) => count + 1)}>plus</div>
</div>
);
};
// 子组件
const Son: FC<ISon> = (props) => {
return <div>count is: {props.count ?? 0}</div>;
};
安装好 React chrome dev
插件,打开 update highlight
(不了解操作的小伙伴参考:重学 React -- 渲染那些事)这时候我们点击父组件的 div
,count
加一的同时我们发现:
- 第一个
Son
高亮了,意味着该组件的render
函数被触发 - 第二个
Son
也高亮了,它的render
函数也被触发
从上一篇文章中,我们知道 props
变更的时候,会触发 render
重新执行,所以第一个 Son
更新了。但为什么第二个 Son
组件也更新了呢?哦是因为父组件 render
的时候子组件也会 render
...
等等,为什么这么理所当然?
第二个 Son
明明没有打入 props
,凭什么父组件更新,它也要跟着更新呢?
React.memo
React 也想到了这个问题。所以它提供了 React.memo 来解决这类问题。
用一句话来解释就是:被它包裹的组件,如果 props
没有变更,即使父组件 re-render
它也不会跟着 re-render
。 我们先看一下这个函数的签名:
它接收两个参数,第一个参数是函数组件,第二个参数是一个比较函数。官网解释的也比较详细了
- 如果你的组件在相同
props
的情况下渲染相同的结果,那么用React.memo
之后,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果- 这种情况仅限于对
props
做判断,子组件内有states
、context
变化还是会触发重渲染的- 第二个参数是一个比较函数,你可以手动控制什么时候阻止渲染
我们改造一下上面的 demo,Son
组件用 memo
包裹一下
export default React.memo(Son);
这时候我们发现 highlight 正常了,没有 props
依赖的第二个子组件不高亮了
哦原来这么简单,加个 memo
就可以了
...
等等,为什么这么理所当然?
那为什么 React 不默认对所有组件都做 memo
呢?
好问题,这个话题我们放到文末去讲
另外提一个小细节:发现没有,上面 demo 中的 Father
组件是一个 div
元素,如果把它干掉换成 <>
会怎样呢?试试吧,然后观察一下 highlight
useCallback
我们来改一下 demo:总体和上面差不多,只不过把 count
加一的操作放到了子组件中。点击子组件的时候,父组件的 count
会加一
// 父组件
const Father = () => {
const [count, setCount] = useState(0);
const addCount = () => setCount((count) => count + 1);
return (
<div className="app">
<Son addCount={addCount} count={count}></Son>
<Son addCount={addCount}></Son>
</div>
);
};
// 子组件
const Son: FC<ISon> = (props) => {
return <div onClick={props.addCount}>count is: {props.count ?? 0}</div>;
};
export default memo(Son);
这个时候 memo
就不生效了。即使第二个 Son
组件没有用到 count
,但在点击子组件(无论是点第一个还是第二个),它都重新执行了 render
函数
原因很简单,memo
会比较 props
,虽然第二个组件的 props
没接受 count
,但它接受了一个 callback
函数
既然是函数,那就是引用对象,这时候 memo
的 props
比较就有问题了:它认为新传进来的这个 callback
,和上一次渲染时的那个不是同一个家伙
既然知道了问题出在哪,那就想办法告诉 memo
:这两个 callbcak
其实是同一个家伙。为了解决这个问题,React 引入了 useCallback
这个 hook,它会对函数做一次持久化记忆,也就是缓存
// 我们只需要把 addCount 用 useCallback 包一下,其他都不变
// const addCount = () => setCount((count) => count + 1);
const addCount = useCallback(() => setCount((count) => count + 1), []);
大功告成!原理也很简单,无非就是做了一个缓存而已,在程序设计上这并不是什么稀罕事,在一些递归、回溯之类的算法中经常能见到这种设计。感兴趣可以参考维基百科上 memorization 的介绍。
另外 lodash 也有类似的东西:[_.memoize](<https://www.lodashjs.com/docs/lodash.memoize>)
总之,用一句话来说就是:useCallback
是用来缓存函数的。memo 会比较 props,比较函数时会因为引用类型的问题导致判断不准确,所以我们用 useCallback
包一下,判断缓存后的结果
useMemo
我们再改一下 demo:
- 父组件这次只渲染一个子组件
Son
以及一个更新的 div - 父组件有一个
user
对象,把这个对象传递给子组件做渲染 - 点击更新 div,将赋值
user
对象
// 父组件
const Father = () => {
const [user, setUser] = useState({ id: 1, name: "Shark" });
const newUser = { id: 2, name: "Elephant" };
const updateUser = () => setUser(newUser);
return (
<div className="app">
<Son user={user}></Son>
<div onClick={updateUser}>setUser</div>
</div>
);
};
// 子组件
const Son: FC<ISon> = (props) => {
return (
<div>
<div>user id is: {props.user.id}</div>
<div>user name is: {props.user.name}</div>
</div>
);
};
export default memo(Son);
这时候我们点击 div 发现,即使用了 memo
,似乎也无济于事。每次点击更新,React 都会执行 render
函数。如果你把上面的内容都认真看完了,到这里即使我不说,你也应该知道发生了什么。
没错,和 useCallback
那个例子的原理类似,这里传入的 user
对象也是一个引用对象,memo
同样无法准确分辨这个家伙和上一次 render
时那个对象是不是同一个东西
那思路很明确了,那就告诉 React:hey 这家伙就是刚刚那人!但我们不能再用 useCallback
来包一下了,因为 useCallback
是用来持久化函数的,而这里的 user
是一个对象
React 引入了另一个 hook 来解决这种问题:useMemo
,这家伙原理和 useCallback
很类似,只不过 useMemo
是用来缓存值的,useCallback
是用来函数的
// 我们只需要把 newUser 用 useMemo 包一下,其他都不变
// const newUser = { id: 2, name: "Elephant" };
const newUser = useMemo(() => ({ id: 2, name: "Elephant" }), []);
稍微总结一下
其实 render 优化没什么复杂的,核心就是在执行 render 之前,先前置判断一下“需不需要执行”。而这个前置动作就是 React.memo
而 useCallback
、useMemo
这两兄弟就更纯粹了,他们就是用来缓存函数和引用对象罢了。只不过正好 memo 场景下不能很准确的判断 props
里的引用对象,于是常见的用法就是:
- 用
useCallback
缓存传递给子组件的函数 - 用
useMemo
缓存传递给子组件的引用对象 - 然后帮助
React.memo
更准确的做出 props 是否变更的判断
当然,useCallback
和 useMemo
两兄弟本质是用来缓存东西,而不是专门服务于 memo 的。所以如果某段函数运算复杂或者执行成本高,那么用他们缓存一下是有助于提升性能的,因为这才是他俩更应该干的事情
Q1:什么是 memo
A1:render 函数执行前比较一下更新前后的 props,如果没差异就不执行 render 函数
Q2:什么是 useCallback
A2:缓存函数。常用于辅助 memo 判断函数是否相等
Q3:什么是 useMemo
A3:缓存值。常用于辅助 memo 判断引用类型(如对象和数组)是否相等
没有银弹!不要滥用任何 React memoize
关于 React.memo
之前有提到过一个问题:为什么 React 不默认对所有组件都做 memo
呢?
关于这个问题,Redux 作者 Dan(现在也是 React 核心开发者)在 Twitter 上有这么一个话题,然后另一个网名叫 vjeux 的大神是这么回复的
我知道你双手已经放在键盘上,以祖安狂人的姿态准备就绪了:你提莫谁啊,Dan 你也敢怼?
少年别着急,这哥们 GitHub 账号叫 Christopher Chedeau,如果你还不太熟悉这名字,那我悄悄得告诉你个秘密:他是 react-native、prettier、yoga 等一系列重量级核弹的发起人 or 核心开发者...
如果你键盘已经收起来了,那么我简单解释一下他这几句话,主要表达这两个意思:
- 首先网上疯传的 “memo 很浪费内存,因为比较新老状态无疑需要缓存历史状态” 这个观点虽然听上去头头是道,但仔细想想,react diff 不就本来保存了一份状态吗,这个开销无论用不用 memo 都是固然存在的。所以这种说 memo 更耗内存的说法是有待商榷的
- 如果这个 props 贼大,结构贼复杂贼离谱,或者我的组件本身逻辑就很轻,这时候做 memo 可能是适得其反的
第一条观点很好理解,react 的核心本来就是 vdom 和 diff。所以在 react 这个大前提下,所谓的 “memo 缓存 props 耗费性能”,本来就是个伪命题,正如 vjeux 说的 :“so it’s free”
第二条怎么理解呢,我们看一个例子
// 假设这个 props 结构很复杂,也很巨大
const Son: FC<ISon> = (props) => {
return <div>{props}</div>;
};
很简单的一个组件,没有任何逻辑,直接把 props
渲染出来就完事了
但,假设这个 props
结构贼复杂,比如是一个很大的树
这时候试想一下,如果用了 memo
,是不是 “反向优化” 了?
- 如果我们做
memo
,组件在render
前需要对 props 做比较,这 props 复杂的一批,耗费了很多时间,耗费性能 - 如果我们不做
memo
,组件不需要比较 props,直接跑render
函数。但实际上这组件渲染没啥逻辑,所以这个 render 函数几乎没任何负担(这里的 case 就直接 return div 而已)
所以说,组件千千万,开发者水平也参差不齐。反正从框架层不可能优化到所有情况的,做的太多甚至会出现负优化。所以 React 干脆把决定权交给开发者,你自己来决定是否 memo 好了
关于 useCallback 和 useMemo
首先任何缓存都是有代价的,这两兄弟也是一样,他们并不是什么银弹
以 useCallback
举例。试想一下,我们如果对一个函数使用 useCallback
缓存起来,会发生什么
const fn = () => {
// do something
}
const fnCallback = React.useCallback(fn, [])
- 首先创建了一个
fn
的函数 - 然后创建了一个
fnCallback
的变量,并且执行了useCallback
把结果缓存在fnCallback
中
你应该注意到了,这样的话每次组件渲染(即执行 render
函数)时,都会再消费一次 React.useCallback(fn, [])
,这当然是需要执行时间的。另外被 useCallback
缓存的东西,也不会被垃圾回收销毁,每一个 callback
都是要占用一定内存空间的
所以说,如果你有事没事都默认带一个 useCallback
,不见的是好事。useMemo
也是类似的
那到底什么时候用
那话说回来,什么时候用 React.memo
、useCallback
、useMemo
呢?
- 当组件执行 render 函数代价很高(比如每次 render 需要跑一些有的没的运算),那可以考虑用
React.memo
缓存组件的状态 - 当你使用了
React.memo
包装子组件,且这个子组件 props 中有引用依赖(包括函数和引用对象)那么对这个函数做useCallback
处理,对引用对象和数组做useMemo
处理 - 当你有某段运算逻辑特别复杂,计算成本高,那么可以用
useMemo
缓存计算的结果
当然上面只是我个人浅显的见解,并不是什么 best practice,只是分享一个优化的思路。任何工程最佳实践都需要时间和经验去总结出来,正应了 Linus Torvalds 那句名言: