本来看到《Scheduling in React》有想法将其翻译一下。不过既然有掘金大佬捷足先登,我就不再做这事了,转过来看看React里面的调度的使用和原理。
这里也就结合自己理解和原文做一些小总结和阐发。主要是归总学习性质,这篇探索性的东西不多。
文章写到一半的时候, 因为一些疑问查资料,又看到了《深入剖析 React Concurrent》。索性对结构有大修了。所以这里把Concurrent放在了前面。
并发 & 调度
并发: Concurrent React
Concurrent React 或者叫Time Slicing,实际上对个人而言,是一个很艰难的话题。在之前文章里,面对它我都采取了略过的态度。
一则同步都没搞明白,更何况异步的处理?二则实际上直到16.9.0,关于它的特性也依然没有正式发布。
后来慢慢一步步深入之后大抵也有了继续探寻的基础。
不过这之前,先得说明白,为什么需要Time Slicing。
- 动画的流畅性原理。一个动画如果想在人眼中显得『流畅』,那么起码需要24帧每秒(React这里默认是按30帧算),现在显示屏一般技术规格是60Hz(相当于每秒60帧),这足以保证流畅了。算下来就是1000/60≈16.67ms。但是,这里要求的是每个帧是变化运动的。如果一个帧占用多个帧的时间,看起来就是卡顿。
- 浏览器Event Loop && requestAnimationFrame && requestIdleCallback。这里需要重点理解,读不懂它们,就读不懂全文。
rAF
首先是requestAnimationFrame(简称rAF)。在一个帧里面,它的生命周期如下,每一帧都包含了 用户交互、js执行、rAF调用,布局计算以及页面重绘 等工作。在这个过程中rAF是一个必须执行完成的过程,如果它耗时长,那么帧就会一直等它技术然后再进行 布局计算和重绘。这个过程中可能会超过理想值16.67ms。
- 这里的参考路径:requestAnimationFrame Scheduling For Nerds。主要提及是requestAnimationFrame的调度。文章中也提到了帧之间的调度。
- 关于rAf,这里还有一篇参考,个人认为很值得看看使用 requestAnimationFrame 实现性能优化与懒执行
- rAF有个特点,它必须在页面TAB激活情况下才能运行,如果切换到其他页面TAB它会暂停,返回后又重新开始。
pollyfill
因为提到的rAF会在页面切换时候进行冻结的问题,这里React做了一个pollyfill。
1 | var requestAnimationFrameWithTimeout = function(callback) { |
核心是rAF优先级高于setTimeout。如果页面正在显示那么和rAF没区别,因为setTimeout会被rAF取消,但是如果在页面被隐藏时候,此时rAF就不会运行了,此时setTimeout会接替它的工作。
requestIdleCallback
其次是requestIdleCallback。如果说rAF是每一帧必须执行的话,那么requestIdleCallback相反。它是选择性执行的。如果一个帧执行完毕时候耗时不到16.67ms,那么此时浏览器就处于空闲状态,此时它完成了它的任务,下个任务有没有开始。
此时就可以执行requestIdleCallback的任务了。注意的是,requestIdleCallback执行的时候,整个帧都已经完成了,收尾了,处于可以无缝交接给下一个帧的情况。如果在这个任务里面有改变布局、处理DOM、触发Promise.resolve的情况,会导致下一个帧开头就需要重新计算,或者干脆因为Promise异步导致这个帧重新开始处理拉长耗时下个帧无法开始工作。
这里流程的图片是:
当然,如果多个帧一直没有空闲,那么requestIdleCallback就无法开始执行,为了保障它的执行,它有第二个参数可以设置一个timeout。规定它最迟执行的时间(当然限于浏览器eventLopp也不可能完全准)。
pollyfill
requestIdleCallback兼容性很差。所以这里还是需要进行pollyfill。
这里找到的hack办法是window.MessageChannel
。这个api可以创建一个新的消息通道,实现sub/pub模式。最关键的地方是,响应订阅的函数,执行时机是Paint之后的空余时间。
小总结
将这些归纳起来说,就可以明白这个Time Slicing的含义了。它的意义在于,将大量耗时js操作打碎,将通过类似rAF来实现分帧进行必须的渲染,避免阻塞UI。
但是这带来一个问题就是优先级问题。一个任务,究竟该如何确定是放到rAF,还是放到requestIdleCallback?——所以这里有了调度器,通过它可以对任务进行优先级划分。
调度:调度器的意义
这里我们继续上一小结的话题继续伸延。
在旧版本(v16之前)的里面,render是一个递归的调用,一个组件的更新会引起下级所有组件的重新计算和渲染。
由于渲染会占据主线程(这是宏任务和微任务的范畴了),当这个计算时间超过一定长度(60Hz显示器上是16.67ms)时候,用户就会有卡顿的感觉。
这在需要即时响应用户输入并输出到屏幕的场景特别明显。
针对这个问题,有两个难题需要去解决
- 一是受制于微任务(render|update)长时间占据主线程,使得浏览器无法对页面进行重新渲染,导致页面卡顿。
- 二是优先级。微任务耗时可以通过任务分解的方式解决,但是分解之后,任务之间优先级如何安排则是一个问题。
React的解决方案
- **Concurrent React (或Time Slicing)**。上一小节介绍过它了。
- **Scheduler(调度器)**。它将任务优先级设定了优先级。
- Immediate。立刻执行
- UserBlocking。250ms timeout,响应用户界面。
- Normal。5s timeout,不是必须立刻响应用户的更新
- Low。10s timeout,可以延迟执行,但是最终必须执行的更新。
- Idle。这个任务优先级不是很好描述,它是那种视情况进行更新的那种优先级,不是所有场景下都需要执行。比如屏幕之外的内容的更新。
常规调用
我们知道这些其实远远不够。所以这里需要看看更深入一些的东西。
这里首当其冲的,是React在更新阶段链表的构建、更新的标记。所以这里看看事件触发之后,这一块发生的事情。
调用入口
我们回顾一下之前Reconciler文章提到的事件调用栈。
1 | dispatchInteractiveEvent |
然后展开一下performWork函数
1 | performWork () { |
这其中findHighestPriorityRoot能确保fiberRoot在调度中(或者是null)。
当然,这里是对FiberRoot上的东西进行遍历然后对比差异。
我们还需要看看如何标记一个节点有变更。
标记变更
这里仔细思考,实际上变更大多情况下是setState引起的。
不管是一个粒度非常小的组件更新内部text,还是通过redux的dispatch来更新App组件的props。实际上都是通过setState标记本身变更,然后由它进行的下级children进行的变更。
这里归总一下《react-v16-Update renderPhase篇》里面提到的setState相关。这里关联的是classComponentUpdater.enqueueSetState
。
1 | enqueueSetState(inst, payload, callback) { |
这里很容易可以观察到update对象里面会赋值新的state值(payload参数),如果setState传入第二参数,也会赋值给updata.callback。
完成之后,使用enqueueUpdate将update放到对应fiberNode上。这个函数核心部分:
1 | if (queue2 === null || queue1 === queue2) { |
说白了核心调用是appendUpdateToQueue。主要就是讲update添加到fiber.updateQueue或者fiber.alternate。updateQueue两个链表上。
当这些处理完毕之后,使用scheduleWork(fiber, expirationTime)进行任务调度,开始遍历fiberNode链表。
但是这里我们假设setState进而引发了一个新的组件的props变化。它会发生什么?这里往下走一走逻辑,观察调用栈:
1 | scheduleWork |
这里requestWork有三个调用分支
- performWorkOnRoot(root, Sync, false)
- performSyncWork() === performWork(Sync, false)
- scheduleCallbackWithExpirationTime(root, expirationTime)
这里performSyncWork这个和开头提到的『调用入口一致』,最终还是到了performWorkOnRoot。前面两个都是同步的处理。
scheduleCallbackWithExpirationTime则是异步的处理,核心是对scheduleDeferredCallback(performAsyncWork, {timeout})
的调用。这个函数指向Scheduler.unstable_scheduleWork
,他根据回调和超时时间生成了一个callbackNode,加入到链表并返回。
所以分为同步和异步两种情况来讲。
同步路径
这里performWorkOnRoot的宏观理解需要理解链表是如何模拟树遍历的,这必须优先理解。这块着重理解之前的render篇里面的『遍历理论』就可以了。
但是不要忽略,performWorkOnRoot必然从fiberRoot开始。
微观上来讲的话,就必须看workLoop -> beginWork + completeUnitOfWork
。他负责每个fiberNode节点具体处理。
因为这里关注点主要是调度,所以就不考虑初始渲染情况下根据全新VDOM节点构建fiberNode链表的情况。这里需要关注再更新环节它的处理。
beginWork
beginWork要操作的组件类型过多,这里仅仅就HostRoot(fiberRoot) & HostComponent & ClassComponent做一些共性说明。
HostComponent处理的入口函数是updateHostComponent,它主要执行
reconcileChildren(args) && return workInProgress.child
。ClassComponent的入口函数式updateClassComponent,它在这里场景下,主要通过updateClassInstance判断是否更新,在这场判断过程中,它调用了componentWillReceiveProps,根据workInProgress上的updateQueue、props、lifecyclesHooks更新了stateNode属性,也就是组件实例 新的props之类都在这个stateNode上保存起来了。当然最重要的,是更新了updateQueue链表。
完毕之后返回finishClassComponent返回值。finishClassComponent检测到变化后,主要调用则如下:
1 | const instance = workInProgress.stateNode; |
这里的reconcileChildren实际上就是和HostComponent分支这里的处理事同一个入口了。
这里更细致的细节,参见之前提到的文章《react-v16-ChildReconciler》。这里不再做赘述,但是有一些基于它的细节却必须说一下。
reconcileChildren这里实质上只有两种处理逻辑,当它的child是单节点时候按单节点处begininWork + completeUnitOfWork,他负责单个fiberNode节点具体处理,如果有多个节点,那么它就按数组方式处理。但是无论怎样,它只处理自己VDOM树结构下一层对应fiberNode。
更深层次的fiberNode,自然有workLoop函数继续调用beginWork处理。
1 | function completeUnitOfWork(workInProgress: Fiber): Fiber | null { |
completeUnitOfWork: 标记变更
这个函数在beginWork强大的功能面前可能容易被忽略。但是它做的事情却并非那么容易让人忽视。
我们已经知道,v16的更新统一从FiberRoot起,那么问题来了,如果全部从头到尾的进行遍历,岂不是太费劲而且不必要?所以我们需要一个变更节点列表,以便进行更新时候只更新它们。
这就是这个函数的作用。它里面有一些核心的调用。这里做一些说明。
- 首先是处理完毕后对fiberNode做一些属性更新,捕获boundary错误之类。
- 其次,从底部往上遍历,将子节点上的effects链到父节点上,这样,最终我们到fiberRoot节点就有一个完整的side-effects链表了。
变更引用
这里直接上之前提到的代码吧:
1 | function workLoop () { |
每次当beginWork结束,都会执行一个completeUnitOfWork(workInProgress)
。在这个函数中,有一个引用:
resetChildExpirationTime(workInProgress, nextRenderExpirationTime)
。
这个函数批量更新了后续所有fiberNode的child节点上的ExpirationTime。
再往上一点说,workLoop上面是renderRoot,renderRoot上面还有performWorkOnRoot,而在这个函数里面,renderRoot结束之后,会执行completeRoot函数。
这个函数就是render commitphase环节的入口级调用。
然后这里就到了之前Update篇之commitPhase环节了。这里直接上代码了(commitRoot)。主要是side-effects的遍历处理。
1 | // commit tree中所有的side-effects。这里分两个步骤 |
异步逻辑
scheduleCallbackWithExpirationTime
这里的异步处理,实际上上面已经简单提到过了。
scheduleCallbackWithExpirationTime是异步的处理,核心是对scheduleDeferredCallback(performAsyncWork, {timeout})
的调用。这个函数指向Scheduler.unstable_scheduleCallback
1 | function unstable_scheduleCallback(callback, deprecated_options) { |
这里核心地方就2个,第一个,根据任务优先级获取不同的expirationTime,第二个,根据expirationTime生成任务节点,排序插入链表。
我们这里忽略了排序细节,不过需要说的是,当我们排序完毕,会有一个ensureHostCallbackIsScheduled
函数会被执行。这个函数用来对任务进行执行。
这里的调用在排序逻辑中有两种情况:
- 原链表为空,所加入的节点为唯一节点,此时立即执行
- 新节点取代旧的firstNode节点成为新的firstNode节点时候,此时立即执行
它对应着两个分支逻辑: 只有一个节点的情况,执行任务;新节点有最高优先级,需要停止继续执行任务转而重新执行任务。它的意义在于,在合适的时候,开始执行任务。
1 | function ensureHostCallbackIsScheduled() { |
关于这个requestHostCallback函数,源码里面做了好几个环境分支,比如jest分支,jscore分支,最后才是常规的浏览器环境分支。我们来看看里面大致细节:
1 | var channel = new MessageChannel(); |
代码有点长,删了细节,只保留了主干。它其实就是requestIdleCallback的的pollyfill而已。这里原理环节可参考前面。
但是这里有一些相互的调用,还是得说明白。
首先是animationTick
函数。这个函数在每一帧开始的rAF里面的回调,当有任务时候,需要进行递归执行requestAnimationFrameWithTimeout(animationTick)
。它核心的作用是对frameDeadline变量进行累加,计算出当前帧的截止时间: 截止时间 = 开始时间 + 渲染时间。
渲染时间默认为33ms,这是为了保证每秒30帧(30Hz)的计算出来的(1000/30)。源码里面有一个对这个值进行优化的逻辑,因为不是重点,这里且就认为它是33ms。
当animationTick
执行到尾部,会执行
1 | // isMessageEventScheduled默认为false。进入animationTick后设为true |
接下来是onmessage的内容。这个函数主要是对剩余时间的利用。
- 如果当前帧还有时间空余->当前任务已经过期->didTimeout = true立刻执行任务
- 如果当前帧还有时间空余->当前任务没过期->执行flushWork && 递归调用rAF
onmessage在上面两个分支处理完毕后针对这两种情况调用scheduledHostCallback函数,里面会针对这两种情况进行分支处理。
这个scheduledHostCallback函数呢,本质上,就是flushWork。
isAnimationFrameScheduled变量本质上和isMessageEventScheduled变量是同一回事。
callback: flushWork
flushWork
函数是异步流程里面的实质上的执行者。
我们之前讨论了rAF->requestIdleCallback->rAF->requestIdleCallback不间断直到完成任务的流程里面,实质上就是这个这个函数在执行异步任务。
这个函数处理三种情况下的逻辑:
- 任务已经超时 此时走同步逻辑,遍历执行所有已经过期任务
- 任务没过期,当前帧有时间富余 那么从队列首部以类似数组pop方法的形式挨个执行未过期的任务。
- 异步任务经过上面逻辑还有剩余,那么新开新一轮调度 && 立即执行最高优先级任务
逻辑一:
1 | // 这里明白这个队列是根据过期时间从小打大排列就可以了 |
逻辑二:
1 | if (firstCallbackNode !== null) { |
这里shouldYieldToHost函数计算的,rAF deadLine时间戳是否有剩余。如果有剩余就继续执行callback链表上的节点。其他和逻辑一雷同。
逻辑三:
1 | isExecutingCallback = false; |
这里ensureHostCallbackIsScheduled函数在上一小节里面有,他用来唤起一个新的调度。重新走rAF->requestIdleCallback->rAF->requestIdleCallback这个流程。
flushImmediateWork函数则是直接把剩余的最高优先级任务一口气执行完毕。但是这里要注意到,它执行完毕之后,又开始执行ensureHostCallbackIsScheduled了。
考虑到函数里面这样一个调用
1 | try {} finally { |
当所有的异步任务都执行完毕,isHostCallbackScheduled = false
。
异步的贯通
和同步不同,异步本身更加复杂。前面讨论了很多很多东西,但是都没有把整个逻辑贯通成环,这样也就无法在宏观上理解这个环节。
所以这一小节的目标是:基于之前的诠释,贯通这个流程。
但是这里必须知道,脱离实际使用去讲原理是不可能的事情,我们说同步可以默认看之前都知道,但是异步这块不行,所以有后面的案例和分析。
异步案例&分析
注意,这里我在使用用例上根据v16.9做了部分更新。但是实际分析暂时还是使用的16.8.6的源码。
基础使用
首先是外部容器的处理
1 | // v16.8是这种写法 但是注意 16.9有更新 |
这里是加了一个React.unstable_ConcurrentMode
容器。
关于v16.8这个容器,根据Fiber链表结构,它是第二个FiberNode。它是走的createFiberFromMode函数创建的。不过我看了一下v16.9的逻辑,它这里直接将第一个和第二个节点直接融合为一个了。操作办法就是将FiberRoot的mode设为 ConcurrentRoot。
其次是api调用。
初步的优化,是使用unstable_next将用户交互的优先级保障起来,将后续更新优先级降低,相当于强制提升了用户交互的优先级。
1 | import { unstable_next } from "scheduler"; |
这里看看这个函数是怎样做的。
1 | function unstable_next(eventHandler) { |
优先级的处理
关于currentPriorityLevel。我们彻底追踪一下调用栈。这个调用起于dispatchInteractiveEvent函数。
1 | dispatchInteractiveEvent |
这里初始的定义是在interactiveUpdates函数里面(packages/react-reconciler/src/ReactFiberScheduler.js:)。
1 | function interactiveUpdates<A, B, R>(fn: (A, B) => R, a: A, b: B): R { |
简而言之,有runWithPriority这个调用在,这里事件相关的回调优先级都是UserBlockingPriority。
所以当我们运行到unstable_next,这里priorityLevel会被强制调整为UserBlockingPriority。这和《Scheduling in React》里面提到的一样。
执行
当我们标记完成了优先级,就要开始干活了,这里运行的是flushImmediateWork()
。这是从unstable_next函数中try…finally的finally代码块里面来的。
1 | function flushImmediateWork() { |
这里我们必须注意,优先调用并不会引起最终结果的变化。
倘若我们正常的更新顺序是A->B->C->D。优化之后,我们的B变成优先级最高了之后,B会被最先执行一次。到了后面再执行时候,我们的firstUpdate会指向A,A的next指向B,所以会重复执行ABCD。
这也是为什么componentWillMount现在会被调用多次。
例子: 正常流程A->B->C->D。优化后更高优先级A&C。当优先执行完毕再次开始新的流程时候,firstUpdate指向B, 会执行BCD。
当我们将最高优先级ImmediatePriority弄完之后,我们可以开始执行低它一级的优先级任务(UserBlockingPriority)了。这个任务流的启动,由ImmediatePriority来启动。这源于之前我们已经提到的一段结论。这里直接引用一下,如果忘了可以回头看看唤起回忆。
flushImmediateWork函数则是直接把剩余的最高优先级任务一口气执行完毕。但是这里要注意到,它执行完毕之后,又开始执行ensureHostCallbackIsScheduled了。
结合之前提到的,我们可以明白这里就开始进行rAF->requestIdleCallback->rAF->requestIdleCallback流程了,直到callback链表为空。
最高优先级之后
这里接着上面说最高优先级之后发生的事情。
当我们走出了这个rAF->requestIdleCallback->rAF->requestIdleCallback互相调用直至回调列表为空的流程。此时核心就是requestIdleCallback函数的执行,rAF相当于一个足够敏感的定时器。
这个requestIdleCallback函数主要是对剩余时间的利用(这里不明白可以继续回头看之前的小结)。
- 如果当前帧还有时间空余->当前任务已经过期->didTimeout = true立刻执行任务
- 如果当前帧还有时间空余->当前任务没过期->执行flushWork && 递归调用rAF
所以这里核心还是在flushWork函数上。
这个函数处理三种情况下的逻辑:
- 任务已经超时 此时走同步逻辑,遍历执行所有已经过期任务
- 任务没过期,当前帧有时间富余 那么从队列首部以类似数组pop方法的形式挨个执行未过期的任务。
- 异步任务经过上面逻辑还有剩余,那么新开新一轮调度 && 立即执行最高优先级任务
这里我们例子中的代码是
1 | setInputValue(value); |
意思是对更新做了切割,先将Input里面的东西渲染好。然后开始更新ListItem高亮情况,这里的更新就是异步的更新了。当任务进入回调队列(由props.onChange(value)
引起),整个长耗时的渲染会被React分帧走renderPhase & commitPhase,整个页面可以保持流畅帧率而不卡顿。
更低的优先级
我们上面分析时候已经有了提及。
1 | runWithPriority(UserBlockingPriority, () => { |
如果想要更低的优先级,则可以参考这个写法。再之前提到的文章中,它是这样操作的:
1 | function sendDeferredAnalyticsPing(value) { |
和React内部调用比较一致。不过个人感觉这个API等正式发布,可能后面会单独有个封装的API,不然这样使用相对麻烦,而且暴露内部API可能不像React的风格。
结合我们之前的代码(完整例子参见scheduletron3000)。这里输入关键词之后,随后要将很多个ListItem符合关键词的全部高亮。当我们输入一个关键词,首先要将输入到关键词显示到Input中,这是一个unstable_LowPriority执行优先级任务,会被优先执行。
参考文章:
这一篇从开头到结尾实在挺不容易,参考了众多的文章去弥补自己未知的地方。
这里至以诚挚谢意。