React 16任务调度优势及原理解析
小结:
1)
浏览器的帧,什么是帧,浏览器通过一定频率的刷新页面,让页面得以变化。帧就是一次的页面刷新。对于chrome来说每秒60次的刷新频率使得帧的执行时间大约为16.6ms,那么这16.6ms都做了哪些事情。
2)
React15对于大量的DOM节点的更新,会出现卡顿,主要的原因是因为15采用的函数递归调用的方法实现的节点的更新。当Diff的计算时间超过帧渲染时间(60Hz的屏幕刷新率,对应的时间大约是16.6ms)就会感知到卡顿。
https://mp.weixin.qq.com/s/RefSMTqHWkFgX1rodcSGPg
React 16任务调度优势及原理解析
2017年4月19日,在F8大会上,开源了React Fiber。React Fiber的出现让我们的react的页面变得更加流畅丝滑 ,尤其是对于react 任务调度来说,引入时间分片策略,将任务采用时间分片调度方式,进而实现更流畅的体验。本文通过对比react15与react16,帮助读者更好的理解React 16基于时间分片的任务调度方式,对于提升用户体验来说其显著优势在哪里,以及该方式是怎么实现的。
我以一段代码片段开始谈起(目前工作中使用的是react16)。下面是一个react的代码片段,再简单不过的用法:
babel编译器会将JSX的代码转换成React.createElement函数调用(注意在新的React 17里不在转换成该函数调用)。我们截取render里面的JSX结构,通过babel转化成了如下代码:
以上函数调用,react会生成一个FiberTree。
01
Fiber Tree 的生成
首先生成Fiber树,需要一个一个Fiber节点串联形成,那么Fiber节点是怎么连接而成的?下面我们介绍下Fiber的数据结构,Fiber的数据结构是个链表的结构:
链表的数据结构可以串联起来形成树的结构,树结构可以通过数据结构的算法进行遍历(React 采用的是DFS算法进行遍历),进而我们很容易到达FiberTree的任何节点,对节点的增删改的操作也变得易于操作,它的空间复杂度平均为O(logN)。
下面就是一个Fiber Tree的样子:
02
react15 VS react16 性能对比
以上我们对Fiber Tree 有了一个大体的印象,然后谈下为什么React要提出Fiber Tree这个概念?首先我们要对比下React15,看看React15现实应用中的情况。React15核心就是生成了VDom,reconciler是实现VDom的主要方法。React15对于大量的DOM节点的更新,会出现卡顿,主要的原因是因为15采用的函数递归调用的方法实现的节点的更新。当Diff的计算时间超过帧渲染时间(60Hz的屏幕刷新率,对应的时间大约是16.6ms)就会感知到卡顿。下面的地址可以帮助大家看下React15 与 React16 对于大量dom的对比效果。
对比效果展示:React15实现大量dom的更新 && React16实现大量dom的更新。
基于功能实践,对比二者性能指标如下:
-
React15采用stack的方式,有些情况出现卡帧现象,主要是因为task 耗时太多,300ms远远超出浏览器的刷新时间,导致渲染时间延后,出现人眼可感知的卡帧现象。
2. React16采用基于时间分片的Fiber方式 ,任务保证在16.7ms 内完成,与帧频同步,所以没有出现卡帧现象。因此fiber 的性能对于交互友好,出色于stack 方式。
下面基于目前的实现的现状背景,我们来探讨关于react 时间分片调度的优势。
react15采用函数调用方式实现DOM更新,如下图所示,调用栈越深,调用花费的时间越长,且中途无法中断。一旦时间超出一帧的时间,这样就会出现掉帧问题。
而React16则采用Fiber的实现方式,所谓Fiber的实现方式实际上是通过时间分片来完成一个个Fiber任务。一个个Fiber任务的有序调度执行的过程,最终会完成一次节点的更新。时间被分成一个个片段,任务在时间分片中执行,时间分片中的任务可以被中断,一旦中断就会释放执行权,进而执行高优任务。例如我们React在更新节点时,用户触发了一个点击的交互,为了响应该交互(优先级较高),react需要中断,去执行高优的任务。如果采用函数调用栈的方式,那么我们很难中断后,再回来继续执行任务。因此Fiber这种时间分片,以及链表的数据结构可以胜任这项工作。
下图是react16采用时间分片的方式执行任务:
那么问题来了,时间分片是什么?
03
时间分片
首先我们需要了解浏览器的帧,什么是帧,浏览器通过一定频率的刷新页面,让页面得以变化。帧就是一次的页面刷新。对于chrome来说每秒60次的刷新频率使得帧的执行时间大约为16.6ms,那么这16.6ms都做了哪些事情。下图大体可以了解到一帧都做了哪些事情。
1.注意被标记的绿色部分,requestAnimationFrame函数,执行的位置在渲染之前,任务在该位置执行,避免了重构重绘,其次它的执行频次是跟系统同步的,它能保证回调函数在屏幕每一次的刷新间隔中都被执行,因此不会导致丢帧的问题。那么它属于理想模型范畴,但是他的执行时机会根据系统频率变动,假设页面重新渲染前有8ms 的空闲,那么这个时间会被浪费掉。所以这个API不是最优解。
2.注意被标记成蓝色的部分,他就是React用到的时间分片。它其实是帧剩余时间。他的位置十分特殊,他位于渲染之后,下一帧到来之前。因此我们在这里做的事情不会影响当前帧的渲染,对于高优任务我们也能在下一帧到来之前做好处理的准备。因此这个时间片段需要注意理解他跟渲染的关系,以及与下一帧的关系。那么JS又是通过谁来触发该时机执行任务呢,原生的requestIdleCallback函数就支持在该时间片段执行任务,是不是有点兴奋,大招直接用?可是浏览器有些不兼容。requestIdleCallback兼容性请看下图:
考虑到以上兼容性问题,React自己实现了requestIdleCallback。那么怎么实现?我们可以根据一段React源码。截取一段关键代码如下图,发现react用到了一个MessageChannel API。接下来我们会思考他是做啥的,他的执行时机是怎样的。这又会涉及事件循环,接下来我们来简单说下事件循环 -- Event loop。
04
Event Loop
在JS事件循环中,每进行一次循环操作的关键步骤如下:
-
执行一个宏任务
-
执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
-
宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
-
当前微任务执行完毕,开始检查渲染,然后GUI线程接管渲染
-
渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)
上面说到的MessageChannel 就会触发一个宏任务。因此MessageChannel作为一个宏任务他与requestIdleCallback具有相同特点 -- 时机可控,宏任务会在渲染之前执行,因此不会造成频繁多次重渲染,其次他会让出主线程,在下一次宏任务到来恢复,其次就是有节奏的触发宏任务,进而形成上面时间分片图里面的波形图。
这里插播一个思考,说到宏任务我们常用到setTimeout(fn,0) 去触发,但是为何没有采用这个API呢,答案是在你因为连续递归调用setTimeout 时,你会发现setTimeout 与下一个setTimeout 之间有4ms 的间隔,这样会出现4ms 的浪费。所以没有作为最优解。下面我们回到正题说下react的任务是怎么调度执行的?
05
React 中任务的调度
状态更新由用户交互产生,用户心里对交互执行顺序有个预期。React根据人机交互研究的结果中用户对交互的预期顺序,进而为交互产生的状态更新赋予不同优先级。因此React 将优先级进行级别划定,从上到下优先级依次降低,分为如下级别:
-
Immediate 立即执行优先级,需要同步执行的任务。
(例如:load、error、loadStart、abort、animationEnd 事件)
-
UserBlocking 用户阻塞型优先级
(例如:touchMove、mouseMove、scroll、drag、dragOver 事件)。
-
Normal 普通优先级(基于当前时间片来说还有5 s 该任务过期)。
-
Low 低优先级(基于当前时间片来说还有10s 该任务过期)。
-
Idle 空闲优先级(永不过期)。
上面是对优先级定义的标准,而react 对于任务优先级会用一个变量进行衡量 ,它叫做ExpirationTime。ExpirationTime = MaxTime(31位的最大整数) —(currentTime+delayTimeByTaskPriority)。数值越大优先级越高,例如currentTime相同情况下,delayTimeByTaskPriority 则是衡量优先级的大小的因素,越高优先级,它的值越小。
react根据优先级(ExpirationTime大小)去调度任务的执行,对于调度器(Scheduler)来说,它管理了两个队列,taskQueue与timerQueue,timerQueue存放没有过期的任务,直到过期时间到来时,通过advanceTimers函数找到将要过期的任务,将其放到taskQueue的队列里,一旦时间到来且主线程空闲,我们就会去执行taskQueue队列的任务。执行该任务就会用到上面说到的MessageChannel去实现一个宏任务,执行该回调任务。
我们最后以一个示例讲述任务调度的过程:
上面的页面有个列表,点击乘2按钮,列表数字进行乘2操作,点击加粗按钮,列表数字加粗。以上是页面的具体功能。基于以上代码,生成相关Fiber 树如下:
当点击X2按钮,生成JSX,其次 React 会开始深度遍历整个当前的Fiber树(current Fiber Tree),与此同时将JSX与current Fiber Tree 进行对比,生成workInProgress树(react更新会生成一个个Fiber节点的拷贝,经过对比计算生成了一棵树,这就叫workInProgress Tree)。在diff对比过程中点击字体加粗按钮,该点击交互的任务进入任务队列,由于该优先级高于当前Fiber任务的优先级,所以React 将取出该字体加粗的高优先级任务,执行整个页面字体加粗的任务,中断了当前diff 过程。
当加粗的任务完成后,我们又回到react刚才被中断的任务,继续diff,当遍历到更新的叶子节点时发现该节点值改变,我们将它打上一个红色的TAG,将该节点放入到EffectList中。继续遍历剩余的节点,直到回溯到根节点为止,结束所有的遍历。结束完所有遍历之后,我们将EffectList中的所有节点进行遍历,进入commit阶段,基于EffectList,执行完成commit 阶段的相关节点的更新,删除,增加。
06
总结
最后我们将本文的要点做一个总结:react16 将任务细化为Fiber节点,就好比单核操作系统实现任务并行,采取的时间分片策略一样。react16通过链表的形式将该Fiber串联成Fiber Tree , 然后对任务定义优先级,利用调度器去执行Fiber任务,对于高优先级的用户交互,调度器会将react的任务暂停,迅速让出主线程的占用,通过触发一个JS的宏任务的处理高优先级的任务,一旦处理完,调度器会回到刚才的终止状态,继续执行Fiber 任务。对于时间分片的时间小于任务时间的情况,也会导致任务的中断,因此在workInProgress指针中会记录当前中断的位置,当下个时间分片到来时,我们会基于该指针恢复任务的执行,以上就是React16 的任务调度。
参考:
-
Scheduling-in-React:
https://philippspiess.com/scheduling-in-react/
-
[1] React源码:
https://github.com/facebook/react/blob/1fb18e22ae66fdb1dc127347e169e73948778e5a/packages/scheduler/src/forks/SchedulerHostConfig.default.js#L224-L226
-
[2] React15实现大量dom的更新:
https://claudiopro.github.io/react-fiber-vs-stack-demo/stack.html
-
[3] React16实现大量dom的更新:
https://claudiopro.github.io/react-fiber-vs-stack-demo/fiber.html