React Fiber架构有必定的复杂度,若是硬着头皮去啃源码,咱们会深陷于庞大的代码量和实现细节之中,每每学不到什么东西。html
React并发模式是ReactFiber架构的重要应用,本文不贴任何React源码,纯粹使用文字帮助你们从并发模式的角度去理解React Fiber架构。react
☕️这不是一篇关于React并发模式API使用的文章。为节约篇幅,下文不会详细介绍API的用法,只会讲解原理。所以,本文假设读者已经了解过React并发模式相关概念。git
🍺为了方便读者理解,本文中的一些关键名词,在最后一小节给出了简单的解释github
目前React代码库(v16.12.0)已经全面使用Fiber架构重构,并同时存在三种模式:web
ReactDOM.render(...)
ReactDOM.createBlockingRoot(...).render(...)
ReactDOM.createRoot(...).render(...)
可是源码编译后只会暴露出Legacy Mode的接口,由于并发模式如今还不是很稳定。chrome
它们的特色以下:npm
更多细节请看👉:详细区别promise
注意,Concurrent Mode所谓的并发,只是一种假象,跟多线程并发彻底不同。多线程并发是多个Task跑在多个线程中,这是真正意义上的并发。而Concurrent Mode的并发是指多个Task跑在同一个主线程(JS主线程)中,只不过每一个Task均可以不断在“运行”和“暂停”两种状态之间切换,从而给用户形成了一种Task并发执行的假象。这其实跟CPU的执行原理同样,这就叫时间分片(Time Slicing)。浏览器
所以,如今咱们经过npm i react
所安装的包使用ReactDOM.render(...)
所建立的React应用的状态是这样的:多线程
总的来讲就是:虽然已经使用Fiber重构了,可是其实仍是老样子😔
若是想体验并发模式请看👉:Adopting Concurrent Mode
在分析以前,咱们先来探究一下卡顿的本质。
刷新率是硬件层面的概念,它是恒定不变的。大部分显示器的刷新速率都是60Hz,也就是每隔16ms读取GPU Buffer中数据,显示到屏幕上,对外表现为一次屏幕刷新,这个过程叫v-sync。
帧率是软件层面的概念,它是存在波动的。每一个软件都会有一套本身的帧率控制策略,拿浏览器来讲,它有多方面考虑:
刷新率跟页面卡顿没有一毛钱关系,页面的卡顿只跟帧率有关系。
众所周知,光线打到人类的视网膜能停留的时间大概是24ms,在考虑一些其余因素,若是光每隔16ms打到视网膜上,人类看到的"连续画面"就算比较舒服的了。
注意,若是是一个静态画面,就算每隔1天输入一帧咱们也不会感受有什么不一样。但事实上,咱们所使用的大多数人机交互设备都是输出动态画面的,好比动画、滚动、交互等。
若是一个连续画面,没有按照16ms/帧的速率输出,那么咱们就会感受到画面不连续,也就是所谓的卡顿。
咱们能够看到,首先要执行JavaScript生成DOM节点,以后才会进行后续的Style/Layout/Pain/Composite过程,最终把画面渲染出来。为了方便分析问题,咱们把这些阶段分为两个部分:
每一帧的页面渲染都由这两部分组成,也就是说这两部分须要保证在16ms以内完成任务并输出一帧画面,用户才不会感受到卡顿。事实上,浏览器还有别的工做要作,因此可能最多只有10ms左右的时间。
所以,最终能不能在10ms的时间内完成一帧画面,取决于两点:
本文将主要分析事件循环的问题,UI渲染的问题请看 👉:浏览器层合成与页面渲染优化
若是这二者其中一个或多个耗费的时间过长,就会致使这一帧画面花费了超过16ms才得以输出,最终致使浏览器没达到60FPS的帧率,体现到使用者的眼中就变成了不连续的画面,进而感受到卡顿。
注意,这种现象称为"帧率下降",而不是"丢帧"。由于帧并无丢,只是输出的速度变慢了。丢帧是帧率大于显示器的刷新率,那么这些多出来的帧率其实并无体现到显示器上,跟丢了没有区别。
React面临着“想让页面及时响应用户交互”与以下两个事实之间的矛盾:
想让页面及时响应用户交互,就须要及时获取主线程的执行权,可是渲染页面又的确须要消耗主线程必定工做时间。
💡 可能有人会问,那么React先执行“响应用户交互”的任务不就行了吗?这样作,的确页面会及时响应交互,可是页面的渲染就会所以卡住,就至关于给页面渲染加了一个debounce,这样的交互体验不是React想要的。
基于这两个事实,有两种解决思路:
为何没有用第一种方案,也许能够参考这个讨论👉:用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接收到了一个高优先级的任务,同时当前已经存在一个正在执行的低优先级任务,这个时候调度器就会"暂停"这个低优先级任务,即经过把这个低优先级任务安排到下一次事件循环再尝试执行,从而让出主线程去执行高优先级任务。
因为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的副本,他们的区别以下:
基于这种机制,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。
requestAnimationFrame实现被移除了,取而代之的是MessageChannel实现。相比于rAF实现经过rAF之间的时间间隔去计算帧长,MessageChannel将帧长直接固定为5ms。
也就是说,MessageChannel实现中,任务每次只会执行5ms,以后便会当即释放主线程,把剩余任务安排到下一次事件循环。(MessageChannel能够直接理解成setTimeout,只不过它性能更好)
这样作有以下好处:
基于上述暂停机制,React解决了CPU计算密集型的问题,所以使用React并发模式开发的web应用将会带来更好的用户体验。可是React团队没有止步于此,他们又推出了几个新的API:
"组合"使用这些API,咱们将能够从一个全新的维度去优化用户体验——网速快性能高的设备的页面渲染将更快,网速慢性能差的设备的页面体验将更好。
继续阅读以前,读者必需要知道这几个API的用法,不然将没有意义。
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之中。
使用Suspense配合useTransition能达到这样的效果:
在加载异步数据时,Suspense所包裹的子组件不会当即挂起,而是尝试在当前状态继续停留一段时间,这个时间由timeoutMs指定。若是在timeoutMs以内,异步数据已经加载完成,那么子组件就会直接更新成最新状态;反之,若是超过了timeoutMs,异步数据尚未加载完成,那么才会去渲染Suspense的fallback组件。
这样,在高网速高性能的设备上,一些没必要要的loading状态将完全消失,用户体验获得进一步优化,这就是所谓的延迟渲染。
延迟渲染并非延迟reconcile,而是延迟reconcile的结果(workInProgress)渲染到屏幕上,举两个例子:
例子1:
假设子组件经过useTransition在第0ms开始拉取异步数据,同时假设timeoutMs为500ms,异步数据拉取耗时300ms,那么这整个过程会是这样:
这样这整个流程就走完了。
例子2:
假设子组件经过useTransition在第0ms开始拉取异步数据,同时假设timeoutMs为500ms,异步数据拉取耗时600ms,那么这整个过程会是这样:
这样这整个流程就走完了。
根据这两个例子,印证了咱们的结论:延迟渲染并非延迟reconcile,而是延迟reconcile的结果(workInProgress)渲染到屏幕上。
若是理解了Suspense和useTransition的原理,这两个API就很好理解了,所以在这里就再也不赘述了。
明天就要返工了,你们必定要作好防御措施,在『20200202』这个特别的日子,祝安康!