为什么我们在所有的东西上使用Memo

eslint-plugin-react-memo的作者为了阐述Memo的重要性而写的博文,用来说明为什么使用Memo是一个非常明智的选择

原文地址 Why We Memo All the Things

我是Coinbase团队的一员,我们要求所有人在任何时候都要使用React的性能优化工具-memouseMemouseCallback。因为一些原因,这个行为是非常有争议的。我想这和Twitter有很大关系。这篇博文解释了我们为什么要这样做。

为什么我们在所有的组件上使用React.memo

让我们从我们都同意这个观点开始: 在大多数应用中,一些组件是可以通过使用React.Memo获得一些性能优化。可能是因为它们重新渲染的成本很高或者它们是渲染频率很高的额组件的子组件。或者两者都是。

所以完全不使用memo就不是可选项。因此我们只有两个选择了:

  • 在某些时候使用memo
  • 全都使用memo

第一个选择听起来最吸引人,不是吗?弄清楚我们什么时候可以从使用React.memo中受益,然后只在那时采取使用它。然而,在我们深入讨论这问题之前,我们必须提醒自己,我们是在一个庞大的团队中工作。无论我们在培训、代码审查和性能分析上多么努力, 我们也无法在100%的时间内做到完全正确 。所以我们反问我们自己:

如果我们没用正确的使用这些工具会有什么结果呢?

如我们在一个不需要的组件上使用了memo,我们所做的就是在每个潜在的渲染中对该组件的props进行浅层的想等性检查。 如果我们不使用memo,我们将会面临:

  1. 执行渲染函数
  2. 重新分配所有回调
  3. 重新分配所有的useMemo函数
  4. 重新分配一堆JSX元素
  5. 在所有的子元素上,递归的重复前面4步
  6. 导致React的协调器去对新旧DOM树进行比较

如果你曾经分析过React应用程序—即使是在生产模式下-你会知道每个渲染的组件对性能都有不可忽视的影响。相比之下,使用mome进行props比较所带来的性能消耗基本上无法在性能分析器中看到(代价太小了)。

重新渲染一次组件所浪费的时间远比测试props变化所浪费的时间多很多。因此,我们希望避免不必要的重新渲染。因为我们非常容易犯错,因此实现这一目标唯一且万无一失的方法,就是默认使用memo

合理的默认选项

更重要的是,将何时使用memo留给我们的工程师来决策,这只会给工程师带来不必要的心智负担。我们是否希望每个人都非常熟悉这些并且能权衡是否使用?对每个组件进行性能分析并作出决定?额外的脑力劳动和时间来尝试正确处理这件事的成本是多少?是否值得?为什么不提供一个合理的默认选项,并在必要的时选取不同的选项?

React.memo的CPU消耗

但是,你可能会想,如果我的绝大部分组件渲染消耗非常小怎么办?那么所有非必要的memo使用加起来是否超过一个非必要的代价很大的重渲染所浪费的时间?

在我的经历中,这个答案是否。我自己从来没有看到memo出现在性能分析的结果中,但是代价很大的渲染占用大量的CPU时间确是很常见到的。如果你看到不同的东西,你可能遇到更大的问题了,例如加载了过多的组件(组件拆分过细,性能可能会变差)。

如果你有一个合理且实用的的案例,并且其中memo所带来的性能消耗,可以影响整体性能,那么请联系我。我会很高兴根据全新的信息更新这篇博文。

React.memo的内存消耗

memo的一个观点广为流传:大家都说React.memo非常消耗内存,因为,就像其它备忘录技术一样-你必须保留旧值,以防你以后再次需要它们。很有道理?不幸的是,这不完全正确。由于React的工作方式,之前渲染的结果总是会保存下来-以便与后续的渲染进行比较。这是React协调算法的基础。脱离这个前提,该算法是无法工作的。

不要相信我的话。Twitter上Christopher Chedeau有一条是这样回应Dan Abramov:

   我并不认为这是一个很好的类比。对每个函数执行memoize()会很糟糕,因为你必须存储所有调用的输入/输出状态。在React当中,React已经为所有的操作做了这件事,所以它是“免费的”。
   为了防止你不认识Christopher Chedeau,我就多说两句,从2012年开始Christopher Chedeau就在Facebook的React核心团队工作,换句话说,他从React最开始的时候,他就参于在其中。并且他创造了React Native,Prettier,CSS-in-JS,Excalidraw和Yoga。

这不是过早优化吗?

过早的优化是指在你知道哪些代码需要改进以提高性能之前花费时间优化你的代码来提高性能。仅仅因为我们担心使用memo的潜在性能消耗,就要求工程师思考是否在某个组件上使用memo,这迫使工程师们花费更多的时间去考虑性能问题。在我看来,这才是过早优化,反之亦然。

为什么我们在所有的回调上使用React.useCallback

我们对React.memo都有相同的认知了吗?很好,那么下面的事情就简单的多了。在大多数情况下,回调是作为props传递给其它组件的。如果你不将这个函数用useCallback进行包装,你将会让memo失去作用。就这么简单。将回调函数用useCallback进行包装,就是为了让memo正常工作。

那么传递给原生组件的回调呢?这种情况下useCallback不就是多余的吗?是的。但是,当下一个实习生将你的原生组件封装到另一个组件中时,他们是否会知道返回到参数所在的地方,并将所有传入的回调用useCallback进行包装?我恐怕是不会的。

另外,上面关于使用合理的默认值不要给工程师带来额外的心智负担的理论,在这里同样适用。同样useCallback的CPU和内存消耗也可以忽略不计。使得,无论如何回调都会存在于内存。毕竟它们需要被调用。

所以,让我们简单点,在所有回调上使用useCallback

为什么我们在所有的Props和依赖上使用React.useMemo

任何时候我们创建对象和数组都存在相同的问题。我们都使用useMemo对其进行包装,否则当这个值作为props传递给其它组件时,会让其它组件的memo无法正常工作。

在每次渲染时重新创建的任何数据结构,会破坏下游所有依赖这些数据结构的useCallbackuseMemo。然后其它回调或者派生值中引用这种不断变化的值,因此这种破坏性是具有传递性的-就像一个只有一半人戴口罩的城镇中出现了新冠病毒一样可怕。如果默认不使用memo,当出现性能问题时,我们调试它们就会像一场漫长的打地鼠游戏一样,你需要沿着依赖关系不断向前查找,并在每一步添加memo函数。

有人会考虑子组件吗?

很多人没有意识到这一点:子组件会偷偷摸摸的破坏memo。JSX每次渲染都会创建一个全新的数据结构。任何时候,一个组件渲染并将JSX传给另一个组件,他都将会破坏掉子组件的memo

我们该如何处理?我们处理渲染的方式和我们处理其它复杂数据结构的方式相同:我们使用useMemo

我想你会认为:没人会用useMemo去包装子组件,是的,你是对的。我必须承认,我们自己的代码库中也不常有这种案例。这不是React的惯用方法,并且在所有的地方都这样使用需要做更多的工作。因此,我们在这里妥协了。这意味着,我们必须时刻保持警惕,需要持续的监控我们的应用是否存在性能问题。好消息是,当我们确认一个,我们就可以直接用useMemo去包装子组件的prop,这比将各种优化添加到没有任何优化且包含大量的复杂的Hooks和组件的web页面要直接的多。

总结

在所有地方使用memo和它相关的函数是一个非常合理的默认值。这就如同在新冠病毒大爆发期间我们需要戴口罩一样。当然,我们可以让每个人每天都进行测试,这和只要求具有活跃传染性的人戴口罩一样。但是,要求每个人默认戴口罩就简单的多了,并且也更加有效果。

译注

React 17给前端带来了非常实用的函数式编程方案,但是函数式编程并非银弹。React的函数组件渲染代价是很大的,因为每次都需要重新执行函数内部的所有代码。因此全面使用memo和其相关的函数,是可以大大减少非必要渲染的次数,从而让我们的React应用更加流畅。编程中很多时候就是在空间和时间之间寻求一个平衡,以求达到一个合适的效果。