【React Conf 2021】React without memo 篇章笔记

2,688 阅读8分钟

该系列为 React Conf 2021 分享专题的文字摘录,旨在除核心内容之余,了解分享会上其他的更为细节的内容。

笔记会携带个人理解并可能大量使用自己归纳的话术,可能会产生部分理解偏差, 如有错误理解欢迎指正! 推荐您有时间仍可观看视频内容,来了解原生分享内容。

React without memo

分享者:Xuan Huang (黄玄)

主要内容:

  • React and Memo
  • React without memo
  • React Forget

视频地址:React without memo

React Conf 2021完整视频地址:React Conf 2021 Replay

React and Memo(React 和 memorization 优化相关的内容)

我们先来查看一个 TODO 应用的例子:

TODO应用例子

如图左侧为代码,右侧为运行结果示意图。

当我们打开控制台使用 React Devtool 对上述代码进行调试的话,不难发现,在频繁的进行完成操作、添加操作时,都会增加已存在列表项的更新次数。如下图所示,在开关 oops 这一项时(对于 TODO 应用,选择表示完成这一项待办,再次点击时表示取消完成),其余的 memo、useCallback、useMemo 都发生了重渲染,其中列表项可能会包含几十上百条数据(暂不考虑虚拟化列表的场景),其承载的更新是一个比较重,容易引发性能问题的点。

渲染示意

为了解决这个问题,我们通常的做法是使用 React.memo 结合 useCallback hooks 对代码进行改造。

如果你不了解 React.memo,推荐你翻阅官方文档来进行学习,地址如下:React.memo

改造结果如下图所示:

优化写法1

首先,我们将原本渲染单条待办事项的组件使用 React.memo 进行包裹,使其完成类似于 pureComponents 的作用,当前后两次传入的 props 浅比较结果相同时,组件将不重新渲染。同时在使用 <TODO /> 组件的模块中,对 handleChange 进行了 useCallback 包裹,防止函数组件在依赖未曾改变的更新过程中重新创建 handleChange,导致前后两次 handleChange 储存的值发生改变。

通过这个改造,我们可以从右边的调试反馈中很清晰的感知到,在继续对 Fixed! 进行操作时,其余待办事项并无更新产生。

然而,TODO 应用可能远比我们想象的要复杂的多。TODO 应用可能仍需要一些其余功能,例如过滤器;而对于面向市场的 TODO 应用,仍需要一些诸如主题的功能。

因此我们需要继续扩充我们的代码。

支持筛选器

首先,我们增设过滤器功能,通过选择分类对待办事项进行筛选;

支持主题色

其次,我们增加主题功能,通过传递一个主题色来简单示意这一场景;

接下来,继续通过页面操作,来查看具体的反馈:

优化前表现

我们在选择主题色的同时,页面出现了比较缓慢的反馈。

原因是当我们进行主题色改变时,<BlazingTodoList /><TodoList /> 组件将会重新渲染,getFiltered() 方法将会随着 <TodoList /> 组件的重渲染而进行重新计算。而改变颜色值,从我们主观的理解上,它并不会影响到可显示待办事项的结果(只会改变主题色这类诸如样式呈现的结果)。

要解决这一问题,我们需要使用 useMemo 这一 hooks 来缓存计算结果。

const filtered = useMemo(
  () => getFiltered(todos, visibility),
  [todos, visibility]
);

上述代码的意思是,在 todosvisibility 发生改变时,才需要重新调用函数,获得执行结果并缓存,而未发生改变时,直接应用缓存值赋值给 filtered 变量。因此,你可以将 useMemo 的优化理解为 vue 中的计算属性,是对一些高计算消耗的一种优化。

优化后代码及表现

通过以上的改造,我们就得到了比较丝滑的用户体验。但是纵观完整代码,对于开发者的体验似乎并不是特别的令人满意。具体思考如下:

  • 我们需要写许多额外的代码(e.g.声明依赖项、包裹组件使其具备浅比较能力、使用hooks来达到性能优化的目的)来满足我们对平滑体验的设想;

  • 我们也需要在代码中理清数据之间的依赖关系,来防止后续迭代过程中打破原本的这种缓存优化。

相关关联示意

React without memo (可以在不使用 React.memo 等优化手段下达到原本使用情况下相似的用户体验)

社区一直在思考,如何让 React 的使用变得更加简单,而上一部分的例子,确实让 React 团队一度陷入两难的境地。如果以开发者体验为主,用户体验将会受到影响;而注重用户体验,那么开发者就要忍受这一写法带来的复杂度和上手难度的提升。

React 团队综合对用户体验、开发者体验、JSX模板语法、hooks解决的问题与class这一最初不可变渲染更易跟踪的现实进行了整理和讨论,认为在这里确实缺少了一部分东西。

接下来,将会展示一些 React 团队在这一方向上的研究,是如何解决这一问题的。

How to memorization? 如何去记忆/缓存

将之前的 TodoList 演示代码去除优化、hooks等内容后,归根结底,<TodoList /> 函数组件是将 props 和 state 作为输入值,并且输出一个 UI 呈现和副作用,并且需要创建中间值变量,例如 filteredhandleChange 在过程中去做一些事情。

原文:The TodoList is just a function that takes inputs from props and states and generate outputs such as UI and effects. And the need to create intermediate value, such as filtered and handleChange to do it.

分析最简单结构

上图中展示了state(todos)、props(visibility、themeColor)作为 <TodoList /> 组件的输入,而在组件中创建了 filteredhandleChange 的中间变量,各自应用到不同的输入值(或没有),而这个组件的产出是一个 UI(以及这段UI所包含的副作用),UI可以直接应用输入值,也可以应用经由输入值再次加工后的中间值。

理想情况下,我们只想让 <TodoList /> 组件在 visibility 或者 todos 发生改变时重新执行 getFiltered() 方法,这是否有什么途径能让我们不使用 useMemo 来做到这件事?

想象一下,我们是否可以拥有某种神奇的变量来告知我们它的输入是否发生了变化?

尝试新方式

比如,我们可以通过上述伪代码来实现我们的逻辑?但这又存在一个问题,else 分支上,这一变量是什么?

这里我们需要思考 useMemo 的到底做了什么?

useMemo 实质上是返回给我们上一次渲染结果缓存的值,那么如果我们直接在代码中去复刻一个具有同 useMemo 相同逻辑的代码段,并在这个自己实现的缓存中去进行计算和存放结果并且能够读取,是否就可以达到相同的目的?其相关代码如下:

let filtered;
if (hasVisibilityChanged || hasTodosChanged) {
  filtered = memoCache[1] = getFiltered(todos, visibility);
} else {
  filtered = memoCache[1];
}

同理 handleChange 这种使用 useCallback 进行优化的也可以改写成以下形式:

useCallback 与 useMemo useCallback(fn, dep); 等价于 useMemo(() => fn, dep);

const handleChange = memoCache[0] || (
  memoCache[0] = todo => setTodos(todos => getUpdated(todos))
)

除此之外,我们还能做什么?

我们还未曾处理 themeColor 这种直接输入传递给输出的情况,这一内容亦可使用这种缓存的写法:

const jsx_addTodo = hasThemeColorChanged ? (
  memoCache[3] = <AddTodo setTods={setTods} themeColor={themeColor}>
) : memoCache[3]

对于列表渲染这一昂贵的开销,也可以将其进行缓存,不过它似乎与 filtered 共享相同的生命周期,因此可以将这一部分提升,例如:

let filtered, jsx_todos;
if (hasVisibilityChanged || hasTodosChanged) {
  filtered = memoCache[1] = getFiltered(todos, visibility);
  jsx_todos = memoCache[2] = (
    <ul>
      {
        filtered.map(todo => (
          <Todo key={todo.id} todo={todo} onChange={handleChange} />
        ))
      }
    </ul>
  )
} else {
  filtered = memoCache[1];
  jsx_todos = memoCache[2];
}

最终我们将这一部分提取到一个大型的 if 语句中,这样我们可以对这一个组件所有的 UI 部分应用相同的策略,无论是那一个部分发生了改变。

最终聚合出的if逻辑

以上便是 React 团队对自动缓存和更易使用 React 方向上的探索研究,总结来说:

  • 你可以不用在项目代码中书写 useMemo、useCallback、React.memo 来达到和使用相似的效果
  • 我们会帮助你(通过编译器转换代码)形如上文介绍的那样,创建输入值是否被使用的变量,构建大型的if判断并自动应用缓存。通过这一方式来实现这种令人看起来形同魔法一般的效果,不过最终生成的代码不会完全相同。

React Forget (React Forget,一个自动缓存记忆的编译器)

React Forget 是一个令组件具备自动缓存记忆功能的编译器,目前,该编译器正在开发过程中,并且还没有正式推出生产可用的版本。相关团队还在研究一些更难的问题,最坏的情况是,这个研究最终有一定可能会失败。

目前 React Forget 会在 Meta(或者一个更熟悉的名字 Facebook) 的业务场景规模中去证明 React Forget 的可行性,在通过后,才会将这一编译器进行开源并发布 alpha 版本。

总结来说:

  • React Forget 是一个编译器
  • 其核心功能就是如同 React without memo 中介绍的思路那样,对未曾应用缓存优化手段的组件模块进行再编译
  • React Forget 所需处理的场景可能要复杂的多,因此还会有许多难点等待团队去解决,只有通过 Meta 内部项目这一规模的应用后才会最终开源发布,提上日程。
  • 最坏的情况是 React Forget 的相关研究会失败。