没有银弹!不要滥用任何 React memoize

1,482 阅读10分钟

前置知识:

  1. 这篇文章并不是面向 0 基础新同学的,需要有一些 react / vue 基础
  2. 最好了解过这篇文章:重学 React -- 渲染那些事,有些上下文是联动的

从一段代码说起

先上一段简单的 demo 代码:

  • 父组件 Father 有一个 countstate,有两个子组件 Son 以及一个 div
  • 第一个子组件 Son 接受来自父组件的 props
  • 第二个子组件 Son 只是一个纯文字展示,不涉及任何状态
  • 点击父组件的 divcount 会加一
// 父组件
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 -- 渲染那些事)这时候我们点击父组件的 divcount 加一的同时我们发现:

  1. 第一个 Son 高亮了,意味着该组件的 render 函数被触发
  2. 第二个 Son 也高亮了,它的 render 函数也被触发

1.png

从上一篇文章中,我们知道 props 变更的时候,会触发 render 重新执行,所以第一个 Son 更新了。但为什么第二个 Son 组件也更新了呢?哦是因为父组件 render 的时候子组件也会 render

...

等等,为什么这么理所当然?
第二个 Son 明明没有打入 props,凭什么父组件更新,它也要跟着更新呢?

React.memo

React 也想到了这个问题。所以它提供了 React.memo 来解决这类问题。
用一句话来解释就是:被它包裹的组件,如果 props 没有变更,即使父组件 re-render 它也不会跟着 re-render 我们先看一下这个函数的签名:

2.png

它接收两个参数,第一个参数是函数组件,第二个参数是一个比较函数。官网解释的也比较详细了

  1. 如果你的组件在相同 props 的情况下渲染相同的结果,那么用 React.memo 之后,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果
  2. 这种情况仅限于对 props 做判断,子组件内有 statescontext 变化还是会触发重渲染的
  3. 第二个参数是一个比较函数,你可以手动控制什么时候阻止渲染

我们改造一下上面的 demo,Son 组件用 memo 包裹一下

export default React.memo(Son);

这时候我们发现 highlight 正常了,没有 props 依赖的第二个子组件不高亮了

3.png

哦原来这么简单,加个 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 函数

既然是函数,那就是引用对象,这时候 memoprops 比较就有问题了:它认为新传进来的这个 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

useCallbackuseMemo 这两兄弟就更纯粹了,他们就是用来缓存函数和引用对象罢了。只不过正好 memo 场景下不能很准确的判断 props 里的引用对象,于是常见的用法就是:

  • useCallback 缓存传递给子组件的函数
  • useMemo 缓存传递给子组件的引用对象
  • 然后帮助 React.memo 更准确的做出 props 是否变更的判断

当然,useCallbackuseMemo 两兄弟本质是用来缓存东西,而不是专门服务于 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 的大神是这么回复的

4.png

我知道你双手已经放在键盘上,以祖安狂人的姿态准备就绪了:你提莫谁啊,Dan 你也敢怼?

少年别着急,这哥们 GitHub 账号叫 Christopher Chedeau,如果你还不太熟悉这名字,那我悄悄得告诉你个秘密:他是 react-native、prettier、yoga 等一系列重量级核弹的发起人 or 核心开发者...

如果你键盘已经收起来了,那么我简单解释一下他这几句话,主要表达这两个意思:

  1. 首先网上疯传的 “memo 很浪费内存,因为比较新老状态无疑需要缓存历史状态” 这个观点虽然听上去头头是道,但仔细想想,react diff 不就本来保存了一份状态吗,这个开销无论用不用 memo 都是固然存在的。所以这种说 memo 更耗内存的说法是有待商榷的
  2. 如果这个 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, [])
  1. 首先创建了一个 fn 的函数
  2. 然后创建了一个 fnCallback 的变量,并且执行了 useCallback 把结果缓存在 fnCallback

你应该注意到了,这样的话每次组件渲染(即执行 render 函数)时,都会再消费一次 React.useCallback(fn, []),这当然是需要执行时间的。另外被 useCallback 缓存的东西,也不会被垃圾回收销毁,每一个 callback 都是要占用一定内存空间的

所以说,如果你有事没事都默认带一个 useCallback,不见的是好事。useMemo 也是类似的

那到底什么时候用

那话说回来,什么时候用 React.memouseCallbackuseMemo 呢?

  • 当组件执行 render 函数代价很高(比如每次 render 需要跑一些有的没的运算),那可以考虑用 React.memo 缓存组件的状态
  • 当你使用了 React.memo 包装子组件,且这个子组件 props 中有引用依赖(包括函数和引用对象)那么对这个函数做 useCallback 处理,对引用对象和数组做 useMemo 处理
  • 当你有某段运算逻辑特别复杂,计算成本高,那么可以用 useMemo 缓存计算的结果

当然上面只是我个人浅显的见解,并不是什么 best practice,只是分享一个优化的思路。任何工程最佳实践都需要时间和经验去总结出来,正应了 Linus Torvalds 那句名言:

"Talk is cheap. Show me the code."