为什么说尽量不要使用useMemo

useMemo 可以带来性能优化,但是你的项目中 useMemo 带来过什么性能提升吗?你写的 memo 确实带来了优化效果吗,还是仅仅自我安慰?

你为什么要用 useMemo?

我用 useMemo 是为了减少不必要的重复渲染,这应该是一个很好的优化手段

加了 useMemo 以后我的代码重复渲染的成本变小了,太棒了。

好吧好吧,就是这样吗?希望今天这篇文章看完以后,你可以很有信心地把现在代码中 95% 的 useMemo 删掉,接着你会发现项目可能会跑得更快,维护成本会更低

useMemo 是什么?

从官方文档我们可以看到 useMemo 这个 Hook 的定义:它可以缓存每次渲染期间计算所得的结果。

官方文档定义

很多人对 useMemo 的理解,可能就止步于这句话,利用 useMemo 可以缓存计算结果。

如果你再深入了解 useMemo,你会知道它不能帮助提高组件初次渲染的速度。它只能潜在地提高你重新渲染(前提是你正确使用 useMemo)之后的重新渲染速度。

对于那些已经很熟悉并且长期在使用 useMemo 的人来说,上述信息他们可能已经知道。那么我们继续来看官方文档中对 useMemo 的使用场景的描述:

  1. 跳过昂贵的重新计算
  2. 跳过组件的重复渲染
  3. 记忆另一个 Hook 的依赖
  4. 记忆函数

核心源码

这里仅关注源码的关键部分。重新渲染的时候 useMemo 会逐个比较依赖,这里采用具体的比较参照 Object.is()。尽管这样的比较很快,但是我这里想给大家的概念是,使用 useMemo 并非没有任何代价;它也需要处理和比较。我们会在后面的示例中解释这个。

function areHookInputsEqual(
  nextDeps: Array<mixed>,
  prevDeps: Array<mixed> | null,
): boolean {
  // 省略的部分 
  ...
  // $FlowFixMe[incompatible-use] found when upgrading Flow
  for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
    // $FlowFixMe[incompatible-use] found when upgrading Flow
    if (is(nextDeps[i], prevDeps[i])) {
      continue;
    }
    return false;
  }
  return true;
}
function is(x: any, y: any) {
  return (
    (x === y && (x !== 0 || 1 / x === 1 / y)) || (x !== x && y !== y) // eslint-disable-line no-self-compare
  );
}

为什么组件要重新渲染自己?

众所周知,state 或者 props 变化的时候,组件会重新渲染自己。

那么如果 props 和 state 没变,组件就不会重新渲染了,对吗?

A 是 B 的充分条件,并不意味着 !A!B 的充分条件。

这里还有一种导致组件重新渲染的可能,那就是父组件的重新渲染。我们来看一段代码:

const Page = () => <Item />; 

const App = () => {
  const [state, setState] = useState(1);
  return (
    <div>
    
      <button onClick={() => setState(state + 1)}> 
        点击重新渲染 {state}  
      </button>

      // Page 是一个没有 props 的子组件,里面也没有 state
      <Page />

    </div>
  );  
};

Page 是一个没有 props 也没有内部 state 的组件,但是当我点击按钮的时候,App 重新渲染了(因为 state 改变),这个时候 page 也跟着重新渲染了,里面的 Item 也重新渲染了,整个链路都重新渲染了。我要怎么阻断这种重新渲染呢?—— React.memo

const Page = () => <Item />;
const PageMemoized = React.memo(Page);  

const App = () => {
  const [state, setState] = useState(1);
  return (
   // ... 与之前相同的代码 
    <PageMemoized />
  );
};

当做完这所有的事情之后,此时你再考虑你的 Page 的 props 才有意义。

那么从上面的例子我们可以得出结论,caching props 只有在一种场景下才有意义:当组件的所有 props 以及组件本身都打上缓存的时候。

如果组件代码中存在以下任意一种情况,我们可以很安心地移除掉 useMemouseCallback,没有任何心理负担:

  • 它们被用作 attr 直接使用或者作为依赖树上层传给未缓存的组件
  • 它们被用作 props 直接使用或者传给未缓存的组件
  • 它们被用作 props 直接使用或者传给至少有一个 props 没有缓存的组件

“那就给每个都打上缓存呗,保证整个链路可以正确 memo ”

如果你还是这么想的话,那么你已经被 useMemo 绑架,还在为它数钱了。如果你真的有性能问题,你一定已经注意到问题出在什么地方然后解决了。既然已经没有性能问题了,就不需要再解决了。移除没有用到的 useMemo 和 useCallback 会稍微简化你的代码,同时初始渲染会稍快一点,对现有的重新渲染性能没有任何负面影响。

总体来说,不要为了用才用 useMemo;等真的有性能问题的时候再使用它。

避免每次渲染的昂贵计算

这里采用本文的数据计算为例:https://www.developerway.com/posts/how-to-use-memo-use-callback

代码片段:https://codesandbox.io/s/measure-without-memo-tnhggk?file=/src/page.tsx

读到这里,读者应该已经知道 useMemo 的作用了,就像小标题写的——useMemo 的主要目标是为了避免每次渲染的昂贵计算。那么什么算是昂贵的计算呢?

我不知道,似乎官网上没有写,或者你没有找到。那么就别在意了,直接用吧。创建一个新日期?数组的过滤、映射或排序?创建一个对象?都用 useMemo 缓存吧!useMemo 终将主宰所有的 React 项目!

好吧,举个例子。比如,我有 250 个国家和地区的数据,你要对它们进行排序并展示。

const Item = ({ country }: { country: Country }) => {
  return <button>{country.name}</button>; 
};


const List = ({ countries }) => {
  // 在这里对国家列表进行排序
  const sortedCountries = orderBy(countries, 'name', sort);

  return (  
    <>
      {sortedCountries.map((country) => (
        <Item country={country} key={country.id} />
      ))}
    </>
  );
};

渲染出来的按钮列表。

不用 memo 的情况下,把整个 CPU 速度降低 6 倍,排序这个 250 条数据的列表只用了不到 2 毫秒。相比之下,渲染整个列表(只是文本按钮)用了 20 多毫秒。日常开发中,我们很少需要处理这么大规模的数据。而且这里我们只是在渲染常规的按钮。所以你需要做的是对 memo 数组进行操作或者渲染并更新 memo 组件。

const List = ({ countries }) => {
  const content = useMemo(() => {
    const sortedCountries = orderBy(countries, 'name', sort);

    return sortedCountries.map((country) => <Item country={country} key={country.id} />);
  }, [countries, sort]);

  return content;
};

当我们对组件打上 memo 之后 ,发现整体渲染这个列表的时间从原先的 20 毫秒 降低到了不到 2 毫秒(大概 18 毫秒)。

实际场景中,数组的规模通常更小,渲染的内容也比这个例子中的复杂得多,所以会更慢一些。因此,一般来说,“计算”和“渲染”之间的时间往往相差 10 倍以上。

那么这里就会冒出另一个问题了:为什么一定要删掉呢? memo 起来不都是好事吗?即使这里只是优化了 2ms 的重新渲染速度,当积少成多的时候也很可观了吧。从另一个角度想,如果一个都不用 memo 的话,应用每这里每那里会慢 2ms,积少成多最后应用会变慢很多,比本来能达到的效果差很多吧。

的确,这种推理听起来很有道理。然而,如果不考虑之前提到的那个点的话,这种推理的确可以得到完全的理由支持。那个点就是:caching 是有一定开销的。如果我们使用 useMemo,React 需要在初次渲染的过程中缓存它的值——这过程当然是要花时间的。没错,这个时间消耗非常小;在我们的应用中,缓存上面提到的排好序的国家列表用不了 1 毫秒。但是!这会产生真正的积少成多效应!在应用初次出现在屏幕上的那个初始渲染过程中,当前页面的每个元素都要经历这个过程。这就导致了不必要的 10-20 毫秒甚至接近 100 毫秒的延时。

与初始渲染相比,重新渲染只发生在某些部分变化的时候。在一个架构良好的应用中,只有这些特定的区域/组件会发生重新渲染,而不是整个应用(页面)。所以普通的重新渲染中所有“计算”的总成本会比上面提到的例子(指拍好序的 250 个元素的列表)高出多少呢?2-3 倍?我们就假设它是原来的 5 倍吧,那么它只会节省大概 10 毫秒的渲染时间。像这样短的时间区间,裸眼很难分辨出来,而且在十倍以上的渲染时间面前,这 10 毫秒显得微不足道。然而,作为一个代价,它确实会拖慢每一次发生的那个初始渲染过程??

常见误用(重点)

初级级别

这里的 useCallback 是无用的。当 Component 重新渲染的时候,无论 props 如何都会重新渲染相关的子组件。这种情况下 clickmemo 无意义。

const Component = () => {
  const onClick = useCallback(() => {
    /* do something */
  }, []);
  return <button onClick={onClick}>Click me</button>
};

此时,你的子组件被 memo 包裹,onClick 也被 useCallback 包裹了,但是这个值没有包裹。这时候当你的 Component 重新渲染,你的 MemoItem 还是会重新渲染。这时候 useCallback 还啥都没做呢。

const Item = () => <div> ... </div>
const MemoItem = React.memo(Item)
const Component = () => {
  const onClick = useCallback(() => {
    /* do something */
  }, []);
  return <MemoItem onClick={onClick} value={[1,2,3]}/> 
};

中级级别

是不是看着很应该没问题?onClick 被“useCallback”包了起来,MemoItem 也 memo 过了。这次就算天塌下来,也不应该重新渲染了吧,不然我学的知识全白学了。

const Item = () => <div> ... </div>
const MemoItem = React.memo(Item)
const Component = () => {
  const onClick = useCallback(() => {
    /* do something */
  }, []);
  return 
  <MemoItem onClick={onClick}>
    <div>something</div>
  </MemoItem>
};

是的,这还是会重新渲染的。上面的代码片段等价于:

// 下面的写法是等价的,意味着传 children 和直接嵌套子元素是一致的  
React.createElement('div',{
  children:'Hello World' 
})

React.createElement('div',null,'Hello World')  

<div>Hello World</div>
const Item = () => <div> ... </div>
const MemoItem = React.memo(Item) // 无用的
const Component = () => {
  const onClick = useCallback(() => { //无用的 
    /* do something */
  }, []);  
  return  
  <MemoItem
    onClick={onClick}
    children={<div>something</div>}  
  />
}; 

有的同学看到这里还不理解:“你说子组件相当于 children,我的 div 明明还是原来的,你怎么说我的 props 改变了?”有这样想法的同学请暂时把它放在一边,我们来看最后一个。

高级级别

好吧好吧,你就是想让我这么写是不是,行,这次我把一切都裹起来。这次就算玉帝他老人家也拦不住我了。这次,留个 memo 我走!

const Item = () => <div> ... </div> 
const Child = () => <div>sth</div>

const MemoItem = React.memo(Item)  
const MemoChild = React.memo(Child)

const Component = () => {
  const onClick = useCallback(() => {
    /* do something */
  }, []);
  return (
    <MemoItem onClick={onClick}>
      <MemoChild /> 
    </MemoItem>
  ) 
};

答案还是没有 memo 住,为什么呢?我们单独拎出 MemoChild 来分析它是怎么执行的:

const child = <MemoChild />; 
const child = React.createElement(MemoChild,props,childen);
const child = {
  type: MemoChild,
  props: {}, // 同样的 props
  ... // 同样的 react 间隔物
}

之前的问题也可以轻松解决了。每次创建的时候创建出来的 child 是一个不同的对象,所以比较的时候触发重新渲染。

终极解决思路

如果你想 memo,你的 memo 目标应该是 Element 本身,而不是 Component。useMemo 会缓存之前的值,如果依赖没有变化就直接返回缓存的数据。

const Child = () => <div>sth</div>

const MemoItem = React.memo(Item)  

const Component = () => {
  const onClick = useCallback(() => { 
    /* do something */
  }, []);
  const child = useMemo(()=> <Child /> ,[])
  return (  
    <MemoItem onClick={onClick}>  
      {child}
    </MemoItem>
  )
};

我们的 memo 组件终于成功了!

如果你之前对这个特性一无所知的话也不要灰心。React-Query 的作者 Dominik 也没有很长时间知道这个特性。这个领域的知识点还有很多,涉及 JSX 的本质和 React 自身的 diff 机制。这里我就不展开讲解了,如果你感兴趣的话可以查看这篇文章:

《一个简单的技巧来优化 React 的重复渲染》 https://kentcdodds.com/blog/optimize-react-re-renders

不管怎么说,成功从来都不容易。现在你还觉得 useMemo 有用吗?你艰辛建立的王国可以通过只传一些 props 就很容易传给下一任。我们又回到了起点。

是否要到处添加 useMemo?

总体来说,对于基础的后端应用,大部分交互相对比较粗暴,通常不需要。如果你的应用类似图形编辑器,大多数交互很细粒度(比如移动图形),这时 useMemo 可以提供非常大的帮助。

useMemo 的优化作用只有在少数场景下才有价值:

  • 你知道计算很昂贵而且它的依赖很少改变
  • 当前的计算结果会作为 props 传给 memo 包裹的组件。通过 useMemo 缓存结果,如果结果没有变化可以跳过重新渲染
  • 当前的计算结果是其他一些 hook 的依赖,比如其他 useMemo/useEffect 的依赖。这几句话可能看着很熟悉,因为它们就是官方文档中如何使用 useMemo 的提到的场景。

在其他情况下,给计算流程套一个 useMemo 并没有什么好处,但是这样做也不会造成明显的伤害,所以有些团队选择不考虑具体情况就尽可能多地使用 useMemo,这降低了代码的可读性。而且不是所有的 useMemo 使用都是有效的:一个“始终新的”单值可以打破整个组件的 memo 化效果。

没有 useMemo 我不知道该怎么做了

示例

这是一个渲染性能很有问题的组件。ExpensiveTree 是一个渲染非常昂贵的组件。

import { useState } from 'react';
 
export default function App() {
  let [color, setColor] = useState('red');  
  return (
    <div>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
      <ExpensiveTree />  
    </div>
  );
}
 
function ExpensiveTree() {
  let now = performance.now();
  while (performance.now() - now < 100) {
    // 人为延时,不做任何事只是消耗 100ms
  }
  return <p>I am a very slow component tree.</p>; 
}

在线试一下:https://codesandbox.io/s/frosty-glade-m33km?file=/src/App.js:23-513

当颜色变化的时候,ExpensiveTree 也会跟着重新渲染,而 ExpensiveTree 的渲染非常耗时。

经过我们前面的学习,我们知道这种情况非常适合用 useMemo 来解决,因为它确实是一个昂贵的计算,而且我确实感受到了卡顿,影响了我项目的正常渲染。

但是我们一定非要用 useMemo 吗?

方案 1:状态迁移

如果你仔细看这段代码的话,会发现返回的结果中只有一部分和 color 有关。

export default function App() {
  let [color, setColor] = useState('red'); 
  return (
    <div>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
      <ExpensiveTree />
    </div>
  );
}

所以我们可以提取这一部分并下移状态到其他组件中:

export default function App() {
  return (
    <>
      <Form />
      <ExpensiveTree />
    </>
  ); 
}
 
function Form() {
  let [color, setColor] = useState('red');
  return (
    <>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p style={{ color }}>Hello, world!</p>
    </>
  );  
}

此时,只有 Form 会随着 color 的改变重新渲染,问题解决!

在线试一下:https://codesandbox.io/s/billowing-wood-1tq2u?file=/src/App.js:64-380

方案 2:内容增强

如果我们在最外层的 div 中也使用 color 的话,方案 1 就行不通了。

export default function App() {
  let [color, setColor] = useState('red');
  return (
    <div style={{ color }}> 
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      <p>Hello, world!</p>
      <ExpensiveTree />
    </div>
  ); 
}

完了,这次怎么提取呢?最外层的父级

还是需要用到 color 的,难道只能用
memo 了吗?

export default function App() {
  return (
    <ColorPicker>
      <p>Hello, world!</p>
      <ExpensiveTree />
    </ColorPicker>
  );
}
 
function ColorPicker({ children }) {
  let [color, setColor] = useState("red");  
  return (
    <div style={{ color }}>
      <input value={color} onChange={(e) => setColor(e.target.value)} />
      {children}
    </div>
  );
}

在线试一下:https://codesandbox.io/s/wonderful-banach-tyfr1?file=/src/App.js:58-423

我们把程序分割成两部分,依赖 color 的部分和 color 这个变量本身都放到了 ColorPicker 中。不依赖 color 的部分留在 App 中作为 ColorPickerchildren。当 color 改变的时候,ColorPicker 会重新渲染,但是它的 childrenprops 没有改变。因此,React 会复用之前的 childrenExpensiveTree 不会重新渲染,问题得到解决!

总结

在使用像 useMemomemo 这样的优化解决方案之前,考虑下是否可以分割变化部分和不受影响部分,这可能更有意义。使用分割方法的有趣之处在于,我们不依赖任何性能工具,分割本身与性能无关。利用 children 也遵循了自上而下的数据流,减少了树中需要搜索的属性数量。在这个例子中,提高性能只是额外的收益而不是最终目标,真正做到了意想不到的双赢。

为什么一定要移除?

有人可能会说,我就是喜欢用 useMemouseCallback,为什么要删掉它们呢?只要我理解之前提到的逻辑,确保我的 useMemo 真正有用就行了!

技术上来说,的确可以。

但是如果你至今没有发现 useMemouseCallback 的使用中有任何问题的话,那说明你目前编写的程序没有性能问题。

如果你坚持使用它们,很好。你完美地理解了使用规则,并把你的程序严丝合缝地 memo 起来没有任何漏洞。你时刻提醒自己未来开发或者添加需求的时候要当心,不要打破整个 memo 链。你能保证和你一起工作的同事在开发中也会注意这一点吗?你能确保在项目交接给下一任的时候,他/她也会坚持你的维护方法吗?

React 团队的看法

原视频链接:https://www.youtube.com/watch?v=lGEMwh32soc&t=620s

React 团队也发现了不使用 memo 可能会导致一些性能问题。但是如果我们要使用 memo 的话,会有非常大的心智负担,因为我们需要考虑多个依赖关系是否被正确使用和包裹。

色彩选择器优化

如果有什么东西可以帮我们正确地 memo 起所有需要 memo 的东西,不是很美好吗?

自动记忆

代码:React Forget 目前还在研究中。它是一个可以帮助你自动 memo 组件的编译器。他们也在解决自动 memo 的问题。

React Forget

文末

最后,我们来看下之前提到的几个想法,你会如何考虑这些情况:

  • “我不确定是否可行,但我觉得这里需要用 useMemo。即使不起作用,它也有可能优化性能。”
  • “这里似乎有大量的数据处理,而且数据变化不大,很适合用 memo。”
  • “数据处理很麻烦,我不想写方法。用 memo 可以帮我用方法式的写法包装数据返回,很顺手。”

我的看法是,如果你发现项目没有明显的卡顿或拖慢行为,请不要使用 memo;也不要指望你当前编写的 memo 能为项目带来长期收益,因为它实在太容易被打乱。一旦有不熟悉 memo 的同事加入维护新的项目,他们很容易打破整个 memo 链。然而,如果确实存在卡慢的表现,请合理使用 memo 的缓存特性(参考常见误用)来帮助优化性能问题或延迟。