深入浅出React并发模式

5,205 阅读17分钟

React Fiber架构有一定的复杂度,如果硬着头皮去啃源码,我们会深陷于庞大的代码量和实现细节之中,往往学不到什么东西。

React并发模式是ReactFiber架构的重要应用,本文不贴任何React源码,纯粹使用文字帮助大家从并发模式的角度去理解React Fiber架构。

☕️这不是一篇关于React并发模式API使用的文章。为节约篇幅,下文不会详细介绍API的用法,只会讲解原理。因此,本文假设读者已经了解过React并发模式相关概念。

🍺为了方便读者理解,本文中的一些关键名词,在最后一小节给出了简单的解释

React仓库状态 📦

目前React代码库(v16.12.0)已经全面使用Fiber架构重构,并同时存在三种模式:

  • Legacy Mode(我们正在用的), 使用ReactDOM.render(...)
  • Blocking Mode, 使用ReactDOM.createBlockingRoot(...).render(...)
  • Concurrent Mode, 使用ReactDOM.createRoot(...).render(...)

但是源码编译后只会暴露出Legacy Mode的接口,因为并发模式现在还不是很稳定。

它们的特点如下:

  • Legacy Mode:同步地进行Reconcile Fiber,Reconcile任务不能被打断,会执行到底
  • Blocking Mode:同步地进行Reconcile Fiber,Reconcile任务不能被打断,会执行到底
  • Concurrent Mode:“并发地”进行Reconcile Fiber,Reconcile任务可以被打断

更多细节请看👉:详细区别

注意,Concurrent Mode所谓的并发,只是一种假象,跟多线程并发完全不一样。多线程并发是多个Task跑在多个线程中,这是真正意义上的并发。而Concurrent Mode的并发是指多个Task跑在同一个主线程(JS主线程)中,只不过每个Task都可以不断在“运行”和“暂停”两种状态之间切换,从而给用户造成了一种Task并发执行的假象。这其实跟CPU的执行原理一样,这就叫时间分片(Time Slicing)。

因此,现在我们通过npm i react所安装的包使用ReactDOM.render(...)所创建的React应用的状态是这样的:

  • Fiber:React的Reconcile过程的最小处理单元
  • Sync:React的Reconcile过程不能被打断,是同步的
  • unbatchedUpdates:在非React的事件中(比如setTimeout),setState无法被批处理
  • Suspense:仅能用于加载异步组件

总的来说就是:虽然已经使用Fiber重构了,但是其实还是老样子😔

如果想体验并发模式请看👉:Adopting Concurrent Mode

卡顿的本质 ⌛️

在分析之前,我们先来探究一下卡顿的本质。

显示器刷新率(Refresh Rate)与浏览器帧率(Frame Rate or FPS)

刷新率是硬件层面的概念,它是恒定不变的。大部分显示器的刷新速率都是60Hz,也就是每隔16ms读取GPU Buffer中数据,显示到屏幕上,对外表现为一次屏幕刷新,这个过程叫v-sync。

帧率是软件层面的概念,它是存在波动的。每个软件都会有一套自己的帧率控制策略,拿浏览器来说,它有多方面考虑:

  • 为了保证性能的同时让用户不感觉得卡顿,它会尽量每隔16ms输出图像信息给GPU
  • 为了减少电池损耗,在未插电源的时候降低帧率
  • 为了减少内存占用,在检测到页面上没有用户交互的时候降低帧率
  • 等等...

刷新率跟页面卡顿没有一毛钱关系,页面的卡顿只跟帧率有关系。

什么是卡顿

众所周知,光线打到人类的视网膜能停留的时间大概是24ms,在考虑一些其他因素,如果光每隔16ms打到视网膜上,人类看到的"连续画面"就算比较舒服的了。

注意,如果是一个静态画面,就算每隔1天输入一帧我们也不会感觉有什么不同。但事实上,我们所使用的大多数人机交互设备都是输出动态画面的,比如动画、滚动、交互等。

如果一个连续画面,没有按照16ms/帧的速率输出,那么我们就会感觉到画面不连续,也就是所谓的卡顿。

为什么会卡顿

这张图叫做The pixel pipeline,它描述了chrome浏览器像素的生成过程。

我们可以看到,首先要执行JavaScript生成DOM节点,之后才会进行后续的Style/Layout/Pain/Composite过程,最终把画面渲染出来。为了方便分析问题,我们把这些阶段分为两个部分:

  • JavaScript:事件循环部分
  • Style/Layout/Pain/Composite:UI渲染部分

每一帧的页面渲染都由这两部分组成,也就是说这两部分需要保证在16ms之内完成任务并输出一帧画面,用户才不会感觉到卡顿。事实上,浏览器还有别的工作要做,所以可能最多只有10ms左右的时间。

因此,最终能不能在10ms的时间内完成一帧画面,取决于两点:

  • 事件循环耗费了多久
  • UI渲染耗费了多久

本文将主要分析事件循环的问题,UI渲染的问题请看 👉:浏览器层合成与页面渲染优化

如果这两者其中一个或多个耗费的时间过长,就会导致这一帧画面花费了超过16ms才得以输出,最终导致浏览器没达到60FPS的帧率,体现到使用者的眼中就变成了不连续的画面,进而感觉到卡顿。

注意,这种现象称为"帧率降低",而不是"丢帧"。因为帧并没有丢,只是输出的速度变慢了。丢帧是帧率大于显示器的刷新率,那么这些多出来的帧率其实并没有体现到显示器上,跟丢了没有区别。

React想要解决的两类问题 🚨

解决CPU计算密集型的问题

React面临着“想让页面及时响应用户交互”与如下两个事实之间的矛盾:

  • “JS是单线程的”的事实
  • “渲染页面的确需要消耗CPU一定工作时间”的事实

想让页面及时响应用户交互,就需要及时获取主线程的执行权,但是渲染页面又的确需要消耗主线程一定工作时间。

💡 可能有人会问,那么React先执行“响应用户交互”的任务不就好了吗?这样做,的确页面会及时响应交互,但是页面的渲染就会因此卡住,就相当于给页面渲染加了一个debounce,这样的交互体验不是React想要的。

基于这两个事实,有两种解决思路:

  • 把页面渲染的任务放到别的线程去跑,比如WebWorker
  • 让页面渲染的任务可以在恰当的时候暂停,让出主线程

为什么没有用第一种方案,也许可以参考这个讨论👉:用web worker多核并行diff虚拟dom的操作存在哪些问题?

解决用户体验问题

解决了CPU计算密集型的问题,用户体验已经得到了显著的提升。但是React没有止步于此,借助暂停这种能力,React又提供了一系列新API:Suspense、SuspenseList、useTransition、useDeferredValue。"组合"使用这些API,我们将可以从一个全新的维度去优化用户体验——网速快性能高的设备的页面渲染将更快,网速慢性能差的设备的页面体验将更好。

这种集成到框架内部的功能实现是前所未有的,这对其他框架来说可以称得上是一种"降维打击",但是对React本身也是一种挑战,因为这些API出现之后,React的render函数将变得越来越难以理解。

关于这些新API的更多讨论请看 👉如何评价React的新功能Time Slice 和Suspense?

Suspense以及其他新的API是React并发模式的一种应用场景,只要理解了React并发模式的原理,这些API的原理也就自然懂了。

API的介绍和使用可以通过React并发模式的相关文档进行学习。

解决问题的关键——暂停 🗝

通过暂停正在执行的任务,一方面让出主线程的控制权,优先处理高优先级任务,解决CPU计算密集型问题;另一方面,让Reconcile的结果暂留在内存中,然后在合适的时间后再显示到屏幕上,为解决用户体验问题提供了机会。

暂停的含义

这里的暂停,并不是真正意义上的暂停执行代码,而是通过把待处理的任务安排到下一次事件循环,从而释放主线程控制权,达到暂停的目的。之后在下一个事件循环中,再次尝试执行待处理任务,进而实现暂停的恢复。

React把这种行为称为: interruptible-rendering

暂停的实现原理

其实并不复杂,主要分为两部分:

  • 调度任务
    • Scheduler:负责调度任务,任务优先级可能各有不同
  • 执行任务
    • performWork:负责执行处理Fiber的任务,由Scheduler进行调度
    • Fiber:负责维护组件信息
    • workInProgress(WIP):负责维护当前正在处理的Fiber的中间结果

先来说说调度任务:

所谓调度任务,就是控制任务的执行时机,通常情况下,任务会一个接着一个的串行执行。但是如果Scheduler接收到了一个高优先级的任务,同时当前已经存在一个正在执行的低优先级任务,这个时候调度器就会"暂停"这个低优先级任务,即通过把这个低优先级任务安排到下一次事件循环再尝试执行,从而让出主线程去执行高优先级任务。

由于Scheduler目前代码状态很不稳定,同时React也在推进把Scheduler集成到浏览器API中这项工作,Scheduler的代码可能还会发生更多变化。另外,这块儿代码对于理解React并没有多大帮助,反而会给读者造成阅读困难。基于以上考虑,本小结就不继续探究Scheduler的代码实现了,但是后面小结依然会给出关于Scheduler目前状态的简单介绍。


然后来看执行任务:

在页面首次渲染以及后续更新的过程中,会使用调度器调度performWork这个任务,而performWork工作就是:从当前Fiber节点开始,使用一个while循环遍历整个FiberTree,由上而下完成每一个Fiber节点的更新。

在遍历FiberTree的过程中,每个Fiber节点的处理工作是一个最小单元(unitWork),也就是说"暂停"只能发生在:Fiber节点开始处理之前,或者处理完毕之后。

暂停会发生performWork这个过程的多个unitWork之间,这就会遇到一个问题:暂停之后,我们如何从当时的工作中恢复,而不是重新再走一遍performWork呢?

React通过一个workInProgress的全局变量来解决这个问题。在每一次unitWork执行完毕后,它的结果(更新后的Fiber)会被赋值给workInProgress,也就是说workInProgress总是保存着最近一次的unitWork的结果。当我们从暂停中恢复时,直接读取这个全局变量,并从这个变量为起点继续完成performWork的工作即可。

workInProgress也是Fiber,React使用了Double Buffering的方式,维护了一个当前Fiber的副本,他们的区别如下:

  • workInProgress所表示的FiberTree中,可能同时存在更新完毕的Fiber节点和未更新的Fiber节点
  • workInProgress所表示的FiberTree没有体现到屏幕上,仅仅是停驻于内存中的一个变量而已

基于这种机制,React实现了并发模式。官方文档有一段话很好的描述了这种机制:

Conceptually, you can think of this as React preparing every update “on a branch”. Just like you can abandon work in branches or switch between them, React in Concurrent Mode can interrupt an ongoing update to do something more important, and then come back to what it was doing earlier. This technique might also remind you of double buffering in video games.

从概念上讲,你可以将它视为 React “在分支上”准备每一次更新。就像你可以放弃分支上的工作或者在它们之间切换一样,React 在 Concurrent 模式中可以中断一项正在执行的更新去做一些更重要的事情,然后再回到之前正在做的工作。这项技术也许会使你想起电子游戏中的双重缓冲。

在这段话中,分支(branch)就是workInProgress。

Scheduler最新状态

requestAnimationFrame实现被移除了,取而代之的是MessageChannel实现。相比于rAF实现通过rAF之间的时间间隔去计算帧长,MessageChannel将帧长直接固定为5ms。

也就是说,MessageChannel实现中,任务每次只会执行5ms,之后便会立即释放主线程,把剩余任务安排到下一次事件循环。(MessageChannel可以直接理解成setTimeout,只不过它性能更好)

这样做有如下好处:

  • 帧长稳定,rAF实现基于rAF回调的执行时间来计算帧长,是非常不稳定的,因为浏览器的帧数会因为各种因素产生波动,导致帧长存在很大误差。
  • 更好地支持高刷新率设备,因为固定帧长5ms,其实就是假定浏览器帧率为5ms/1帧,也就是1000ms/200帧,也就是最高可以支持每秒200帧的帧率。

优化用户体验 🚀

基于上述暂停机制,React解决了CPU计算密集型的问题,因此使用React并发模式开发的web应用将会带来更好的用户体验。但是React团队没有止步于此,他们又推出了几个新的API:

  • Suspense
  • SuspenseList
  • useTransition
  • useDeferredValue

"组合"使用这些API,我们将可以从一个全新的维度去优化用户体验——网速快性能高的设备的页面渲染将更快,网速慢性能差的设备的页面体验将更好。

继续阅读之前,读者必须要知道这几个API的用法,否则将没有意义。

Suspense原理

Suspense的思想并不复杂,其实我们完全可以自己实现Suspense组件,这里是一个超简化的React Suspense实现 例子:

这个实现使用了React的错误边界的概念。Suspense的实现原理就是Suspense所包裹的子组件内部throw一个promise出来,然后被Suspense的componentDidCatch捕获到,在其内部处理这个promise,从而实现Suspense的render函数的三元表达式条件渲染的功能。

然而作为一个框架,React会考虑更多。比如,在上面的这个极简例子中,在使用三元表达式进行条件渲染时,不可避免的会导致children被卸载,也就是说子组件的状态会丢失。为了解决这个问题,React在处理Suspense组件时会有一个特别的reconcile过程:

当渲染Suspense被挂起,也就是渲染其fallback组件时,React会同时生成两个fiber,一个是fallbackFiber,一个是primaryFiber,它们分别用来维护fallback组件的信息和子组件的信息。这样,即使子组件被卸载,组件的状态信息依旧会维持在primaryFiber之中。

useTransition延迟渲染的原理

使用Suspense配合useTransition能达到这样的效果:

在加载异步数据时,Suspense所包裹的子组件不会立即挂起,而是尝试在当前状态继续停留一段时间,这个时间由timeoutMs指定。如果在timeoutMs之内,异步数据已经加载完成,那么子组件就会直接更新成最新状态;反之,如果超过了timeoutMs,异步数据还没有加载完成,那么才会去渲染Suspense的fallback组件。

这样,在高网速高性能的设备上,一些不必要的loading状态将彻底消失,用户体验得到进一步优化,这就是所谓的延迟渲染。

延迟渲染并不是延迟reconcile,而是延迟reconcile的结果(workInProgress)渲染到屏幕上,举两个例子:


例子1:

假设子组件通过useTransition在第0ms开始拉取异步数据,同时假设timeoutMs为500ms,异步数据拉取耗时300ms,那么这整个过程会是这样:

  1. 在第0ms一开始,React就会进行第一次reconcile,因为这个时候异步数据未加载完成,因此reconcile的结果所表示的组件其实是fallback。然后reconcile的结果会存储在内存中的workInProgress变量。假设reconcile耗时50ms,也就是说在50ms这个时间点,render阶段已经完毕,接下来要做的事就要把workInProgress信息同步到dom上,也就是commit阶段。 但是由于我们设置了timeoutMs,React不会立即去commit,而是去等待500ms之后再去commit。
  2. 于是时间来到了300ms,此时异步数据拉取完成,React再次进行reconcile,因为这个时候异步数据已经加载完成,因此reconcile的结果所表示的组件是真正的子组件。然后reconcile的结果又会复制给workInProgress这个变量,因此上一次的reconcile结果被覆盖了。假设reconcile耗时100ms,也就是说在400ms这个时间点,render阶段已经完毕,然后会直接进入到commit阶段,立即把workInProgress渲染到页面。
  3. 因为在400ms时已经commit了,那么在500ms时就不会做任何事情了。

这样这整个流程就走完了。


例子2:

假设子组件通过useTransition在第0ms开始拉取异步数据,同时假设timeoutMs为500ms,异步数据拉取耗时600ms,那么这整个过程会是这样:

  1. 第一步同上,完全一样。
  2. 时间来到500ms,此时异步数据依旧没有拉取完成,因此第一步的commit延迟时间已经到了,所以React会立即把fiber渲染到页面,页面于是会显示fallback组件。
  3. 时间来到600ms,此时异步数据拉取完成,React再次进行reconcile,得到下一个workInProgress,之后立即把workInProgress渲染到页面。

这样这整个流程就走完了。


根据这两个例子,印证了我们的结论:延迟渲染并不是延迟reconcile,而是延迟reconcile的结果(workInProgress)渲染到屏幕上。

SuspenseList和useDeferredValue

如果理解了Suspense和useTransition的原理,这两个API就很好理解了,因此在这里就不再赘述了。

名词的简单解释

  • Fiber:Reconcile的最小单元,包含组件信息、组件状态信息、组件关系信息、副作用列表等内容,可以理解为给每个组件包了一层。
  • FiberTree:通过Fiber的组件关系信息:return(父),child(子),sibling(兄弟),构建出来的一颗树。每一个Fiber都可以表示一颗树,因此FiberTree本质就是Fiber,它们是等价的。FiberTree中的任意Fiber节点都可以通过上述三个属性描述出整个FiberTree。
  • Reconcile
    1. 通过遍历FiberTree,完成各个Fiber的更新的过程就叫Reconcile。
    2. 这个过程分为render和commit两个阶段,render阶段的输入是Fiber,输出是更新后的Fiber,是纯粹React层面的工作。commit阶段输入是更新后的Fiber,输出是副作用执行、DOM更新;
    3. 只有render阶段可以被打断。
  • workInProgress:上述中,在render阶段输出的更新后的Fiber就叫workInProgress,它本质上也是一个Fiber,不同的是,workInProgress仅仅存在于内存之中,还没有体现到屏幕上。

参考

最后

明天就要返工了,大家一定要做好防护措施,在『20200202』这个特别的日子,祝安康!