零代码深刻浅出React并发模式,带你理解React Fiber架构

React Fiber架构有必定的复杂度,若是硬着头皮去啃源码,咱们会深陷于庞大的代码量和实现细节之中,每每学不到什么东西。html

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

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

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

React仓库状态 📦

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

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

可是源码编译后只会暴露出Legacy Mode的接口,由于并发模式如今还不是很稳定。chrome

它们的特色以下:npm

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

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

注意,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』这个特别的日子,祝安康!

相关文章
相关标签/搜索