React | 2020-07-13 01:27:08 605次 2次
本篇继续进行调度过程分析,进入 unstable_scheduleCallback 进行 flushWork、workLoop 操作,执行传入的回调 performConcurrentWorkOnRoot 异步方法或者 flushSyncCallbackQueueImpl 队列中是 performSyncWorkOnRoot 同步方法(同步需要批量更新),进而进入 performUnitOfWork 开始 beginWork 也就是开始下个阶段 render 阶段的执行。
再回顾下上一篇主要做的事情就是根据不同的任务模式 expirationTime === Sync 判断是立即渲染同步任务还是调度异步模式任务,调度模式里面根据任务优先级进行传入相关回调,到这里根据 requestHostCallback 开始利用空闲时间或者分片调度任务。
一、unstable_scheduleCallback
这个方法中就涉及到了浏览器空闲时间调度的概念,react 源码中没有直接使用浏览器自带的 requestIdleCallback 方法,而是自己实现了一个这样的机制。这里面关于优先级的到期时间,如果过了这个时间还没有被执行,就要立即执行(尽管可能还是阻塞浏览器的渲染,引起卡顿,所以这个调度并不是想象中那么完美)。开始还有个误区,以为 react 会知道业务中代码的运行时间更加动态调整,现在发现不是,而是根据不同的事件类型来自动指定一个固定时间。
//优先级 | 同步 or 异步渲染 | 传入一个 对象 timeout 指定过期时间 function unstable_scheduleCallback(priorityLevel, callback, options) { //获取当前运行时间 var currentTime = getCurrentTime(); var startTime; var timeout; //如果传入了配置则使用配置的 反之需要自己计算一下 if (typeof options === 'object' && options !== null) { //延迟的时间,暂无发现哪里使用 var delay = options.delay; if (typeof delay === 'number' && delay > 0) { startTime = currentTime + delay; } else { startTime = currentTime; } timeout = typeof options.timeout === 'number' ? options.timeout : timeoutForPriorityLevel(priorityLevel); } else { //根据优先级获取对应的过期时间 //高优先级-1 | 阻塞型 250ms | 普通优先级 5s | 低优先级 10s timeout = timeoutForPriorityLevel(priorityLevel); startTime = currentTime; } //执行到这个方法时才去计算的时间+优先级事件需要的对应时间(或者手动指定) var expirationTime = startTime + timeout; //包装任务 var newTask = { id: taskIdCounter++, //初始1 callback, // 传入的回调 performSyncWorkOnRoot | performConcurrentWorkOnRoot priorityLevel, startTime, expirationTime, sortIndex: -1, }; ... //只有 startTime = currentTime + delay 指定延迟的情况才会触发 if (startTime > currentTime) { // 当这是个延迟的task,需要被加入timerQueue,按照startTime排序 newTask.sortIndex = startTime; // 将新任务保存在小顶堆中 // 小顶堆按 sortIndex(如果相同则比较 id)排序 // 所以堆顶任务为sortIndex最小(或id最小)任务 push(timerQueue, newTask); // 取出 第一个元素 if (peek(taskQueue) === null && newTask === peek(timerQueue)) { // 所有任务都延迟了,当前任务是优先级最高的 if (isHostTimeoutScheduled) { // 清除定时器 cancelHostTimeout(); } else { isHostTimeoutScheduled = true; } //延迟的直接通过定时器触发,startTime - currentTime 为何不写成 delay? requestHostTimeout(handleTimeout, startTime - currentTime); } } else { // 否则就是正常的需要被执行的任务,按照expirationTime作为排序依据 newTask.sortIndex = expirationTime; push(taskQueue, newTask); ... // 执行回调方法,如果已经再工作需要等待一次回调的完成 if (!isHostCallbackScheduled && !isPerformingWork) { isHostCallbackScheduled = true; //对 requestIdleCallback 模拟实现 requestHostCallback(flushWork); } } //返回这个任务 return newTask; }
二、push 小顶堆
push 这个方法涉及到小顶堆的插入操作,调用 siftUp 来创建,并通过 sortIndex(如果相同)则继续根据 id 比较。
function push(heap, node) { const index = heap.length; heap.push(node); siftUp(heap, node, index); } function siftUp(heap, node, i) { let index = i; while (true) { const parentIndex = (index - 1) >>> 1; //相当于 parseInt((index-1) / 2) const parent = heap[parentIndex]; if (parent !== undefined && compare(parent, node) > 0) { // The parent is larger. Swap positions. heap[parentIndex] = node; heap[index] = parent; index = parentIndex; } else { // The parent is smaller. Exit. return; } } } function compare(a, b) { // Compare sort index first, then task id. const diff = a.sortIndex - b.sortIndex; return diff !== 0 ? diff : a.id - b.id; } let s = [] push(s, {sortIndex: 9}) push(s, {sortIndex: 12 }) push(s, {sortIndex: 17}) push(s, {sortIndex: 30}) push(s, {sortIndex: 50}) push(s, {sortIndex: 20}) push(s, {sortIndex: 60}) push(s, {sortIndex: 65}) push(s, {sortIndex: 4}) push(s, {sortIndex: 19})
最终生成:[4, 9, 17, 12, 19, 20, 60, 65, 30, 50]的顺序小顶堆,如下图:
三、requestHostCallback
这个方法本来以为就不变了,参考模拟实现。结果目前这个版本中又是一个新的方案,之前通过 requestAnimationFrame 来模拟的,现在的方案是通过高频调用 postMessage 来调度任务,为了在每一帧执行更多的任务,提升运行效率,但是目前这种方案无疑加剧了浏览器资源的争夺,没有真正的利用浏览器空闲时间(个人理解)。这里面通过 MessageChannel 两个通道通信的机制。
const channel = new MessageChannel(); const port = channel.port2; channel.port1.onmessage = performWorkUntilDeadline; requestHostCallback = function(callback) { scheduledHostCallback = callback; //默认false if (!isMessageLoopRunning) { isMessageLoopRunning = true; //prot2发送消息,只有prot1才可以接到 port.postMessage(null); } };
时间分片:为什么一个 MessageChannel 可以梭哈调度,其实并不是,还有一个 shouldYield 在 workLoopConcurrent 方法中,来进行时间分片,时间不够了则中断,当返回true 的时候(比如时间片 5ms 到期或者有更高优先级任务插入进来),循环被中断,一个时间分片就结束了,浏览器将重获控制权,当前中断的节点信息被记录在 workInProgress 上,方便下次继续执行,时间分片我理解为是属于 Reconciler(render阶段),伴随着时间分片而产生多个 fiber 任务单元。
多任务分片:taskQueue 初始时候会被存入一个任务,通过 workLoop 来执行(清空)任务队列。接着时间分片的概念,如果有过期的任务会继续执行调度逻辑,从而任务队列中会再增加一个任务,react 执行 port.postMessage 发起了一个事件,进入到 performWorkUntilDeadline 这里继续执行回调(workLoop 这里面也会有一个 5ms 期限判断,不会无限执行,过期需要交换控制权),如果还有剩余的任务会返回一个 true,所以可以判断 hasMoreWork 为 true 则认为还有任务会继续发一个 port.postMessage 。那么浏览器获取控制权就在这个间隙内,去执行浏览器自身的渲染工作,也就是每隔 5ms 就交还一次执行权,达到一种高频率的这种调用,貌似会比之前的方式效果更好,之前的方式有可能会长时间阻塞。
const performWorkUntilDeadline = () => { //这个就是传入的任务回调,有任务再继续 if (scheduledHostCallback !== null) { const currentTime = getCurrentTime(); //结束时间 5ms 之后(5ms的执行时间) deadline = currentTime + yieldInterval; const hasTimeRemaining = true; try { //查看当前是否还有任务 进行前面传入的回调执行:flushWork const hasMoreWork = scheduledHostCallback( hasTimeRemaining, currentTime, ); //没有任务了 if (!hasMoreWork) { isMessageLoopRunning = false; scheduledHostCallback = null; } else { //还有任务则通过消息机制再触发,这样再到下一次执行期间的这段时间控制权交还给浏览器渲染 port.postMessage(null); } } catch (error) { //错误继续调度,但是会抛出错误,这样我们可以捕获 port.postMessage(null); throw error; } } else { isMessageLoopRunning = false; } // Yielding to the browser will give it a chance to paint, so we can // reset this. needsPaint = false; };
现在可见 requestHostCallback 这个方法变得极其精简,理解上要比之前那版要相对容易,接下来主要看下 scheduledHostCallback 也就是 flushWork 这个方法。
四、flushWork
这里面看主要的逻辑就是执行了 wookLoop,其中 currentPriorityLevel 最终要被恢复为初始值为普通优先级,这种操作调度中大量出现:
//刷新调度队列,执行调度任务 function flushWork(hasTimeRemaining, initialTime) { ... //这里重置,之前根据这个变量判断控制是否调用requestHostCallback,可能是防止无限被调度 isHostCallbackScheduled = false; //和延迟有关 if (isHostTimeoutScheduled) { isHostTimeoutScheduled = false; cancelHostTimeout(); } isPerformingWork = true; //暂时记录下之前的优先级值,执行完workLoop后恢复 const previousPriorityLevel = currentPriorityLevel; try { //忽略。。 if (enableProfiling) { ... } else { // 执行工作循环 return workLoop(hasTimeRemaining, initialTime); } } finally { //这里将优先级恢复为默认值 currentTask = null; currentPriorityLevel = previousPriorityLevel; ... } }
五、advanceTimers
这里先看一个 advanceTimers 方法,用的地方还挺多,这里面取出延迟的任务,对没有传入回调的任务直接移除,但是目前没有发现延迟的任务,伊萨尔小老弟告诉我说这是兼容上一版本中的 requestAnimationFrame 模式的,比如浏览器至于后台时,这个方法就会暂停,属于一个致命的缺陷对于 react 的这种设计而言。
先熟悉下它的大概做的事情吧,方便后续的阅读,将 timerQueue 中将要执行的任务放入到 taskQueue 任务中并重新创建堆:
// 遍历timerQueue 将延迟的任务取出(目前没发现延迟任务啊, timer 一直为null) function advanceTimers(currentTime) { let timer = peek(timerQueue); while (timer !== null) { if (timer.callback === null) { // 没有传入回调的就移除,就是 performConcurrentWorkOnRoot 异步方法 //或者 flushSyncCallbackQueueImpl 队列中是 performSyncWorkOnRoot 同步方法 pop(timerQueue); } else if (timer.startTime <= currentTime) { // 先移除,下面再push pop(timerQueue); //需要被执行,排序按照过期时间(当前时间+过期的时间) 和被创建时的sortIndex条件类型对应起来 timer.sortIndex = timer.expirationTime; push(taskQueue, timer); ... } else { // Remaining timers are pending. 等待状态 return; } timer = peek(timerQueue); } }
六、workLoop
workLoop 这里面也会有一个 5ms 期限判断,不会无限执行,过期需要交换控制权。并且这里面的 taskQueue 任务队列(小顶堆结构)不仅仅包含过期分片的任务,还包含比如同时 ReactDOM.render 多次产生的这种任务都在这里。
//工作循环的开始,react16模拟实现文章里面就是从这个方法开始的,这里是可以进行多任务的taskQueue function workLoop(hasTimeRemaining, initialTime) { //initialTime就是传入的currentTime时间 let currentTime = initialTime; advanceTimers(currentTime); //取出头任务 currentTask = peek(taskQueue); //有任务 while ( currentTask !== null && !(enableSchedulerDebugging && isSchedulerPaused) ) { //任务没过期但是没有剩余时间了 跳出循环。下面返回 true 证明还有任务,下个时间段再继续执行 if ( currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost()) ) { break; } //performConcurrentWorkOnRoot 异步方法或者 flushSyncCallbackQueueImpl 回调,如果不存在下面直接丢掉 pop const callback = currentTask.callback; if (callback !== null) { currentTask.callback = null; currentPriorityLevel = currentTask.priorityLevel; //超时 const didUserCallbackTimeout = currentTask.expirationTime <= currentTime; ... //performConcurrentWorkOnRoot.bind(null, root)所以这里是一个参数 const continuationCallback = callback(didUserCallbackTimeout); currentTime = getCurrentTime(); // performConcurrentWorkOnRoot 中有个情况会返回函数 if (typeof continuationCallback === 'function') { currentTask.callback = continuationCallback; ... } else { ... //清除任务 if (currentTask === peek(taskQueue)) { pop(taskQueue); } } advanceTimers(currentTime); } else { pop(taskQueue); } currentTask = peek(taskQueue); } // 是否还有更多任务 if (currentTask !== null) { return true; } else { ... return false; } }
总结
react 的第一阶段,调度基本上就这些内容,它的主要目的是将产生的高优先级的任务推去 render 阶段优先执行。调度过程中会创建任务队列 taskQueue(不是更新队列,反正我开始是容易搞混)并且非阻塞执行,因为会进行 5ms 的一个时间期限判断,然后交还执行权等待下次再 workLoop 直到任务为空。
调度核心:GUI渲染线程与JS引擎是互斥的,所以一直 5ms 发起一个宏任务,根据事件循环机制避免 js 长时间占用。
正常执行:比如初始一系列操作后产生一个 taskQueue 任务,通过执行 callback 进入 render 阶段,这里面开始进行不停的 performUnitOfWork 单元工作(这里是针对 fiber 节点而言),如果到时间需要交还浏览器执行权时候会再次执行调度 ensureRootIsScheduled 并且创建新的 taskQueue 任务这样循环下去直到 performUnitOfWork 中不再有返回值,通过 wip 记录中断位置。
高优先级插队:上面说的是一种正常的情况,如果这个过程中通过 update 产生了高优先级的任务,会打断当前正在进行的任务,通过 cancelCallback 方法(取消的是 unstable_scheduleCallback 方法中返回这个任务),将 callback 置为 null,这样workLoop 中这个任务就不存在,先去执行更高优先级任务,保证高优先级任务先去 performUnitOfWork,等待高优先级更新完成后,回过头来再执行低优先级,重要的是它是需要再从 root 根开始进行工作(高优先级执行的这个过程中不知做了什么事)所以从根再执行一遍,wip 也是会被重置。
补录:高优先级任务如果插队了,那它可能没有前序计算结果,或者前序计算结果不完全。高优先级不依赖于前序结果,只保证它自身先完成任务渲染给用户看,以后再进行一般优先级的渲染,这次会将所有任务包括之前已经完成的高优先级任务,而这次的baseState 会以上次被跳过的优先级的前序计算结果为基准,再进行计算。也就是说高优先级任务执行完仍然存在更新队列中,并且还会被再执行一次。要保证结果一致性。
调度更新就是调度的 rootFiber,不是调度 fiber 节点,节点只会被进行分片执行和调度无关。
关于 调度更新(schedule) 与 批量更新(batched) 的关系(借鉴卡颂老师的话):
调用 this.setState 会在 fiber 上创建 update, 经过一顿操作后最后会返回一个 rootFiber。
rootFiber 会被调度更新。所以**调度更新**是针对 rootFiber 的,而不是某一个 fiber 节点。
如果某个 fiber 上创建了多个同优先级的 update(比如一次事件回调内调用多次 this.setState),那么同样的,经过一顿操作后最后会返回多个 rootFiber。
但是这些 rootFiber 由于优先级(lane)是相同的,他们只会被**调度一次更新**。也就是说只会进入一次 render - commit 阶段。 这就叫 batchedUpdate。
在 Legacy mode 时,batchedUpdate 是通过在触发创建 update 的回调函数前在上下文中赋值一个标记判断的 executionContext (以前是有个 isBatchingUpdates 变量) ,所以才会有 unstable_batchedUpdate 这个 API 开放给开发者使用。是否批量更新的源头来自 scheduleUpdateOnFiber 方法:
if (expirationTime === Sync) { if (...) { ... } else { //批量更新的模式下进入调度,但是同时多个setState操作会被return掉,确保异步更新 ensureRootIsScheduled(root); schedulePendingInteractions(root, expirationTime); //如果处于非批量更新的状态下会进入这里立即执行了 //(比如定时器中的多个set操作,除非手动调用那个批量钩子,修改 executionContext 的值) // 这里也就是为什么定时器中连续的 set 操作会是同步,每次都执行任务了 if (executionContext === NoContext) { //执行任务 flushSyncCallbackQueue(); } } } //批量更新的方法 通过 unstable_batchedUpdates 方式使用 function batchedUpdates$1(fn, a) { var prevExecutionContext = executionContext; executionContext |= BatchedContext; try { return fn(a); } finally { executionContext = prevExecutionContext; if (executionContext === NoContext) { // Flush the immediate callbacks that were scheduled during this batch flushSyncCallbackQueue(); } } }
所以批量更新的情景下进入 ensureRootIsScheduled 中会有个判断 existingCallbackNode 存在且更新时间优先级一样的情况下直接 return 掉,非批量更新时(比如定时器中的 set)会立即执行任务,进入调度时 existingCallbackNode 就不存在了,所以也不会 return。setState 中传入的回调函数为什么可以取到最新值呢,因为它是在 render 之后被执行的 commitUpdateQueue。
补充批量更新:内置事件默认批量更新模式,batchedEventUpdates$1 中对 executionContext (默认为0)赋值,执行完 commit阶段重置为0。批量模式:discreteUpdates$1去清空多个任务 flushSyncCallbackQueue,只执行一次 performSyncWorkOnRoot;非批量模式:调度多次,每一次都进行 flushSyncCallbackQueue 执行多次 performSyncWorkOnRoot。
在 Concurrent Mode 时是否 batchedUpdate 是根据优先级(lane)决定的,相近时间差被抹平,不需要标记变量,所以完全是自动的,开发者不需要手动介入。
下一个阶段进入 render 阶段,会执行 performConcurrentWorkOnRoot 异步方法或者 flushSyncCallbackQueueImpl 同步方法中 performSyncWorkOnRoot。这些方法中会再次执行调度,为了继续执行被高优先级打断的任务或者被时间分片的任务。
2人赞