理解 React Fiber & Concurrent Mode

2,014 阅读5分钟

抛出问题

React 目前主要的问题有两个

react-cpu-io.jpg

  1. 页面在大量 DOM 节点同时更新的情况下,会出现延迟很严重的现象,具体表现为交互/渲染的卡顿现象
  2. 异步操作(比如 data fetching)需要在组件 mount 之后进行,导致子组件的 fetching 有可能被父组件的 fetching 阻塞(下一篇会细讲)

其中第一个问题会在设备计算能力差(比如 mobile device)的情况下被放大,我们可以把这个问题和 CPU 绑在一起(CPU-bound)

第二个问题会在设备网络情况差(比如 3G slow network)的情况下被放大,我们可以把这个问题和 IO 绑在一起(IO-bound)

今天我们主要来讲下,React concurrent mode 是如何解决第一个问题的,第二个问题会在后面的代码实战文章再仔细讲下。

分析问题

首先,这个问题并不是一个 performance 问题,而是一个调度 (scheduling) 问题,所以提高处理速度并不能完全解决这个问题,就像提高网速并不能解决 HTTP/1.1 协议的线头阻塞 (Head-of-line blocking) 问 题。

no-debounce.gif

这个问题的本质是,浏览器的 main thread 是单线程的,短时间大量 CPU consuming 的 task 被加到了 call stack,导致 event loop 在好几个周期都没有空闲去处理 queue 里面的用户交互 (click, scroll, etc.),从而用户感知到了卡顿。

react-event-loop.gif

这两个问题在目前能不能解决呢?实际上是可以的,但是解决的方式不够优雅,或者说,没有完全解决。比如第一个问题在 Table 搜索的时候很常见,如果 Table 数据特别多,搜索的时候的卡顿是一定的。解决方法也很简单,使用 debounce 函数在用户连续输入的时候,不进行操作,等用户输入暂停再搜索。这种 workaround 相当于解决了一半的问题,用户能接受最好,不能接受,其实也没有别的更好的方法了。

debounce.gif

解决问题 - React Fiber

解决这个问题的原理其实很简单,HTTP/2 采用分帧 (Frame) 的方式解决的 HTTP/1.1 的线头阻塞问题,我们也可以将大量的更新任务 (Batch update task) 拆分成一个个小的 task,从而让 event loop 有机会处理用户交互的 callback。

http2-frame.jpg

React Fiber 就是 React Team 一直在持续开发用来解决上面问题的新 algorithm 或者叫 reconciler。

Fiber 的原理挺复杂的,简单说就是原来需要一口气放到 call stack 里面执行的一堆任务,被放到了 heap 里面由一个 scheduler 来调度执行。

react-concurrent-mode-img.jpg

其实 React Team 应该在一开始就意识到这个问题了,所以 setState() 被设计成了异步的,但就像官方文档里说的,他们并没有完全利用 asynchronous update (not taking advantage of this) 。仅仅异步是不够的,blocking 还是会 blocking,只有 scheduling the update 才能实现可中断渲染 (interruptible rendering)

React Concurrent Mode

react-concurrent-mode-demo.gif

Concurrent Mode 指的就是 React 利用上面 Fiber 带来的新特性的开启的新模式 (mode)。目前 React 实验版本允许用户选择三种 mode

  • Legacy Mode: 就相当于目前稳定版的模式
  • Blocking Mode: 应该是以后会代替 Legacy Mode 而长期存在的模式
  • Concurrent Mode: 以后会变成 default 的模式

Concurrent Mode 其实开启了一堆新特性,其中有两个最重要的特性可以用来解决我们开头提到的两个问题

  • Suspense
  • useTrasition

其中 Suspense 可以用来解决请求阻塞的问题,UI 卡顿的问题其实开启 concurrent mode 就已经解决的,但如何利用 concurrent mode 来实现更友好的交互还是需要对代码做一番改动的,后面的文章会用实际代码的方式讲下这两个的使用,这里给一个大数据下的不同 mode 下的页面更新速度的对照 demo

需要注意

上面 Fiber 的 update 机制,有个细节问题就是,既然现在的更新是可以被打断的,那如果打断的操作影响了更新怎么办?比如说有个 background-color 我原来要变成绿色,但是用户点击了一个按钮,background-color 要变成红色,那最终结果会怎样?期望肯定是红色,不然就是 bug 了。因此 React 这里做了处理,更新到一半的 work 是可以被丢弃而重新开始的。

这样的做法没错,但是就带来一个问题,有些生命周期函数可能会被多次调用

react-lifecycle-methods.jpg

我们可以将所有的生命周期函数分成两类,分别运行在 Render 阶段和 Commit 阶段

  • Render phase 简单说就是决定究竟渲染什么的阶段,涉及绝大多数生命周期。

  • Commit phase 简单说就是将需要渲染的内容推到 DOM (如果是 DOM 环境) 的阶段。

componentDidMount(), componentDidUpdate(), componentWillUnmount() 这三个生命周期函数 (lifecycle method) 运行在 commit 阶段,其余的生命周期函数都可以简单理解为运行在 render 阶段。

我们上面说更新到一半的 work 有可能被重新启动,这就意味着,像是 rendershouldComponentUpdate 等等这些在 render 阶段的生命周期函数并不是像你期望的那样只执行一个,而是有可能被执行多次。

解决方式有两种:

  1. 把在这些生命周期执行的逻辑放到 commit phase 执行,如果能完全做到,这里可以直接将组件改成 React hook 的形式。
  2. 保证 render phase 执行的逻辑是幂等的 (idempotent),不理解的可以查下,这个很简单不多讲

总结

React Concurrent Mode 通过启用 Fiber reconciler 实现了可中断渲染 (interruptible render),从而让一系列的交互优化变成了可能。下篇文章会使用实际代码来讲解究竟如何正确使用 Concurrent Mode API

Ref