React 任务调度
2017 年 4 月 19 日,在 F8 大会上,开源了 React Fiber, React 的 Fiber 的提出让我们的 react 的页面变得更加流畅丝滑 ,尤其是对于 react 任务调度来说,引入时间分片策略,将任务采用时间分片调度方式,进而实现更流畅的体验。本文通过对比 react15 与 react16,帮助读者更好的理解 React 16 基于时间分片的任务调度方式上,对于提升用户体验来说的显著优势在哪里,以及该方式是怎么实现的。
React 前端开发工程师的日常
我引一段代码片段开始谈起(目前工作中使用的是 react16)。下面是一个 react 的代码片段,再简单不过的用法。
babel 编译器会将 JSX 的代码转换成 React.createElement 函数调用(注意在新的 React 17 里不在转换成该函数调用)。我们截取 render 里面的 JSX 结构,通过 babel 转化成了如下代码:
以上函数调用 react 会生成一个 FiberTree。
Fiber Tree 的生成
首先生成 Fiber 树,需要一个一个 Fiber 节点串联形成,那么 Fiber 节点是怎么连接而成的?下面我们介绍下 Fiber 的数据结构,==Fiber 的数据结构是个链表的结构==:
链表的数据结构可以串联起来形成树的结构,树结构可以通过数据结构的算法进行遍历(React 采用的是 DFS 算法进行遍历),进而我们很容易到达 FiberTree 的任何节点,对节点的增删改的操作也变得易于操作,它的空间复杂度平均为 O(logN)。下面就是一个 Fiber Tree 的样子:
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 远远超出浏览器的刷新时间,导致渲染时间延后,出现人眼可感知的卡帧现象。
React16 采用基于时间分片的 Fiber 方式 ,任务保证在 16.7ms 内完成,与帧频同步,所以没有出现卡帧现象。因此 fiber 的性能对于交互友好,出色于 stack 方式。
下面基于目前的实现的现状背景,我们来探讨关于 react 时间分片调度的优势。
react15 采用函数调用方式实现 DOM 更新,如下图所示,调用栈越深,调用花费的时间越长,且中途无法中断。一旦时间超出一帧的时间,这样就会出现掉帧问题。
而 React16 则采用 Fiber 的实现方式,所谓 Fiber 的实现方式实际上是通过时间分片来完成一个个 Fiber 任务。一个个 Fiber 任务的有序调度执行的过程,最终会完成一次节点的更新。时间被分成一个个片段,任务在时间分片中执行,时间分片中的任务可以被中断,一旦中断就会释放执行权,进而执行高优任务。例如我们 React 在更新节点时,用户触发了一个点击的交互,为了响应该交互(优先级较高),react 需要中断,去执行高优的任务。如果非时间分片采用函数调用栈的方式,那么我们很难中断后再回来继续执行任务。因此 Fiber 这种时间分片,以及链表的数据结构可以胜任这项工作。下图是 react16 采用时间分片的方式执行任务:
那么问题来了,时间分片是什么?
时间分片
首先我们需要了解浏览器的帧,什么是帧,浏览器通过一定频率的刷新页面,让页面得以变化。帧就是一次的页面刷新。对于 chrome 来说每秒 60 次的刷新频率使得帧的执行时间大约为 16.6ms,那么这 16.6ms 都做了哪些事情。下图可以了解到一帧都做了哪些事情。
注意被标记的绿色部分,它是 requestAnimationFrame 函数,执行的位置在渲染之前,任务在该位置执行,避免了重构重绘,其次它的执行频次是跟系统同步的,它能保证回调函数在屏幕每一次的刷新间隔中都被执行,因此不会导致丢帧的问题。那么它属于理想模型范畴,但是他的执行时机会根据系统频率变动,假设页面重新渲染前有 8ms 的空闲,那么这个时间会被浪费掉。所以这个 API 不是最优解。注意被标记成蓝色的部分,他就是 React 用到的时间分片。它其实是帧剩余时间。他的位置十分特殊,他位于渲染之后,下一帧到来之前。因此我们在这里做的事情不会影响当前帧的渲染,对于高优任务我们也能在下一帧到来之前做好处理的准备。因此这个时间片段需要注意理解他跟渲染的关系,以及与下一帧的关系。那么 JS 又是通过谁来触发该时机执行任务呢,原生的 requestIdleCallback 函数就支持在该时间片段执行任务,是不是有点兴奋,大招直接用?可是浏览器有些不兼容。
requestIdleCallback 兼容性请看下图:
考虑到以上兼容性问题,React 自己实现了 requestIdleCallback。那么怎么实现?我们可以根据一段React源码截取一段关键代码如下图,发现 react 用到了一个 MessageChannel API。接下来我们会思考他是做啥的,他的执行时机是怎样的。这又会涉及事件循环,接下来我们来简单说下事件循环 -- Event loop。
Event Loop
在事件循环中,每进行一次循环操作的关键步骤如下:
执行一个宏任务
执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
当前微任务执行完毕,开始检查渲染,然后 GUI 线程接管渲染
渲染完毕后,JS 线程继续接管,开始下一个宏任务(从事件队列中获取)
上面说到的 MessageChannel 就会触发一个宏任务。因此 MessageChannel 作为一个宏任务他与 requestIdleCallback 具有相同特点 -- 时机可控,宏任务会在渲染之前执行,因此不会造成频繁多次重渲染,其次他会让出主线程,在下一次宏任务到来恢复,其次就是有节奏的触发宏任务,进而形成上面时间分片图里面的波形图。这里插播一个思考,说到宏任务我们常用到 setTimeout(fn,0) 去触发,但是为何没有采用这个 API 呢,答案是在你因为连续递归调用 setTimeout 时,你会发现 setTimeout 与下一个 setTimeout 之间有 4ms 的间隔,这样会出现 4ms 的浪费。所以没有作为最优解。下面我们回到正题说下 react 的任务是怎么调度执行的?
React 中任务的调度
1.优先级的定义
状态更新由用户交互产生,用户心里对交互执行顺序有个预期。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 去实现一个宏任务,执行该回调任务。
我们最后以一个示例讲述任务调度的过程:
code:
上面的页面有个列表,点击乘 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 阶段的相关节点的更新,删除,增加。
总结
最后我们将本文的要点做一个总结:react 将任务细化为 Fiber 节点,就好比单核操作系统实现任务并行,采取的时间分片策略一样。react 通过链表的形式将该 Fiber 串联成 Fiber Tree , 然后对任务定义优先级,利用调度器去执行 Fiber 任务,对于高优先级的用户交互,调度器会将 react 的任务暂停,迅速让出主线程的占用,通过触发一个 JS 的宏任务的处理高优先级的任务,一旦处理完,调度器会回到刚才的终止状态,继续执行 Fiber 任务。对于时间分片的时间小于任务时间的情况,也会导致任务的中断,因此在 workInProgress 指针中会记录当前中断的位置,当下个时间分片到来时,我们会基于该指针恢复任务的执行,以上就是 React 的任务调度。
版权声明: 本文为 InfoQ 作者【贝壳大前端技术团队】的原创文章。
原文链接:【http://xie.infoq.cn/article/dae49299746f3f3181f31b396】。文章转载请联系作者。
评论