时间切片是 Concurrent Mode
的核心机制之一。它的主要目的是将长任务分解为小的任务单元,每个单元只执行一小段时间,然后让出控制权,避免长时间阻塞主线程。
react 的并发模式在 render 阶段 每处理一个 fiber 就会根据以下两点判断一次是否应该打断并把控制权交换给主线程:
- 是否超过分片时间(5ms)
- 根据
isInputPending
判断是否有用户输入
但是并不会根据优先级来打断,优先级只会影响任务队列的任务排序,所以按照按优先级排序好的任务顺序来执行,也就能让高优先级任务得到及时处理。
当一个任务开始执行时, Scheduler
会为其分配一个时间片 (通常为 5ms)。在这个时间片内,任务可以连续执行。
- 如果任务在时间片内完成,则直接结束
- 如果任务执行时间超过了时间片,则
Scheduler
会中断任务的执行,保存当前的进度,并让出控制权给浏览器,以便响应用户交互或执行其他高优先级的任务 - 如果任务执行中,遇到用户输入等需要浏览器及时反馈的操作,会通过判断
isInputPending
,会中断当前任务,并让出控制权给浏览器,以便响应用户交互或执行其他高优先级的任务
1 | function workLoopConcurrent() { |
通过 shouldYield
判断,具体方法就详细说明了,可以去看《react调度器scheduler源码分析》
scheduleTaskForRootDuringMicrotask
scheduleTaskForRootDuringMicrotask
每次执行都会判断上一次中断任务优先级和root下的最高优先级是否一样。
- 如果一样,说明没有更高优先级的更新产生,可以继续上次未完成的协调;
- 如果不一样,说明有更高优先级的更新进来,要清空之前已开始的协调过程,从根节点开始重新协调。等高优先级更新处理完成以后,再次从根节点开始处理低优先级更新。
1 | // packages/react-reconciler/src/ReactFiberRootScheduler.js |
1. 高优先级打断低优先级
cancelCallback
1 | function unstable_cancelCallback(task: Task) { |
cancelCallback(existingCallbackNode)
,cancelCallback
函数就是将 root.callbackNode
赋值为 null
performConcurrentWorkOnRoot
1 | export function performConcurrentWorkOnRoot( |
1 | export function getContinuationForRoot( |
performConcurrentWorkOnRoot
函数是每个并发任务的入口,下面简要分析以下它的运行流程:
- 会先把
root.callbackNode
缓存起来,存在originalCallbackNode
变量中 - 并发模式下会使用
renderRootConcurrent
执行- 在
render
阶段中也有可能在代码执行中,触发更高优先级事件,例如点击事件,高优先级事件又触发了setState
就是相对高优先级的 setState
执行scheduleUpdateOnFiber
的prepareFreshStack
和markRootUpdated
函数,就已经把update
添加到fiber
上并且将更新优先级标记到root
的pendingLanes
上了
- 在
- 如果render阶段结束,做一些检查,commit阶段,如果render阶段中断,会直接跳过上述操作,走到函数的末尾
- 在函数末尾会调用
getContinuationForRoot
函数,先执行scheduleTaskForRootDuringMicrotask
就是把render
中目前最高优先级事件和当前任务优先级作对比,进行cancelCallback
或者scheduleCallback
,同时root.callbackNode
也可能被改掉了 - 再判断
root.callbackNode
和开始缓存起来的值originalCallbackNode
是否一样,如果不一样,就代表root.callbackNode
被赋值为null
或者其他的task
值,代表有更高优先级任务进来。 - 此时
performConcurrentWorkOnRoot
返回值为null
pop(taskQueue)
1 | const callback = currentTask.callback; |
上面是 Scheduler
模块里面 workLoop
函数的代码片段,currentTask.callback
就是 scheduleCallback
的第二个参数,也就是performConcurrentWorkOnRoot
函数。
承接上个主题,如果 performConcurrentWorkOnRoot
函数返回了 null
,workLoop
内部就会执行 pop(taskQueue)
,将当前的任务从 taskQueue
中弹出。
低优先级任务重启
上一步中说道一个低优先级任务从 taskQueue
中被弹出。那高优先级任务执行完毕之后,如何重启回之前的低优先级任务呢?
1 | let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes); |
markRootFinished
函数刚刚上面说了是释放已完成任务的 Lane
,那也就是说未完成任务依然会存在 lanes
中,所以我们可以重新调用 ensureRootIsScheduled
发起一次新的调度,去重启低优先级任务的执行。我们可以看下重启部分的判断:
1 | const workInProgressRoot = getWorkInProgressRoot(); |
commit
的最后还是会 ensureRootIsScheduled
,高优先级插队后低优先级任务能重启的原因:taskQueue
中被打断的低优先级的任务已经 pop
了,但是 root
上还有 pendingLanes
,通过 ensureRootIsScheduled
重新把低优先级的任务加入 taskQueue
中。
流程总结
- 任务通过
unstable_scheduleCallback
进行调度,将任务添加到taskQueue
中,如果是首次加载通过requestHostCallback
调度宏任务 Scheduler
里执行flush
后执行workLoop
方法,在workLoop
方法中取出第一个任务,判断是否超出切片时间或者有更需要及时反馈的用户操作,如果没有,那当执行任务的callback
时,也就是performConcurrentWorkOnRoot
方法,执行流程可以看上面。所以我们知道performConcurrentWorkOnRoot
方法可能会返回null,也可能会返回performConcurrentWorkOnRoot
- 当返回
null
时候,Scheduler
会执行pop(taskQueue)
,将当前的任务从taskQueue
中弹出 - 低优先级任务重启,
commitRootImpl
最后会再次调用ensureRootIsScheduled(root, now())
,判断如果nextLanes
为NoLanes
,就证明所有任务都执行完毕了,如果nextLanes
不为NoLanes
,就代表还有任务未执行完,也就是那些被打断的低优先级任务,会重新进行调度
任务插队情况具体总结分析
结合 eventLoop
, 用户 click
是一个宏任务,会把回调 push
到宏任务队列,等待下一次执行。react
的并发,也是分成了 5ms
的宏任务来执行。
情况1 - 用户手动触发高优先级任务
例如正在执行一个并发任务的时候,用户点击了按钮准备触发 setState
(可能是一个 lane
为 2
的同步任务,也可能是比现在优先级高的其它任务),会把这个回调加入到宏任务队列,等到并发任务执行结束,( flushWork
的返回值如果是 true
,也会 schedulePerformWorkUntilDeadline
再次 push
一个任务到宏任务队列,click
的回调在这个任务的前面)
下次进入宏任务队列,取出一个任务也就是 click
的回调,执行 dispatchSetState
,isRenderPhaseUpdate
为 false
(还没进入到 render
阶段),scheduleUpdateOnFiber
也会向 root
上标记待执行任务的优先级,ensureRootIsScheduled
调度了微任务scheduleImmediateTask
,宏任务执行完毕
进入微任务执行 processRootScheduleInMicrotask
,执行 scheduleTaskForRootDuringMicrotask
:
- 这里会进行高优先级打断低优先级的
cancelCallback
操作、通过scheduleCallback
将任务加入到taskQueue
中(taskQueue
是最小堆,也会根据lane
排序) - 上面如果是同步任务,就不会执行
scheduleCallback
,会在microtask
结束时,flushSyncWorkOnAllRoots()
;(flush
任何pending
的同步work
。这必须放在最后,因为它执行实际的可能会抛出异常的渲染工作。) - 最后执行完微任务,等到下次执行宏任务的时候,高优先级的就会先执行,以后再执行到低优先级时,其实低优先级没有
callback
了,就会跳过,后面commit
最后再重新发起调度低优先级任务
情况2 - 代码里触发了高优先级任务
还有另一种情况是在某个并发任务的 performConcurrentWorkOnRoot
里的 render
过程中加入了可能是一个 lane
为 2
的同步任务,也可能是比现在优先级高的其它任务,会在此次任务切片(5ms)
结束后,来进行判断(切片结束是通过 workLoopConcurrent
里面判断)
1 | export function performConcurrentWorkOnRoot( |
- 通过
markRootUpdated
在root
上标记要执行任务的优先级, 再去对比当前任务和root
上的最高优先级的任务是否一致,如果是比现在优先级高的任务但又不是同步任务,getContinuationForRoot
的scheduleTaskForRootDuringMicrotask
会进行打断低优先级任务并对新的且不是同步任务的发起scheduleCallback
调度加入到taskQueue
中,最终performConcurrentWorkOnRoot
的返回值return null
,在workLoop
的continuationCallback
为null
, 会把当前的低优先级任务移除。取出taskQueue
中的目前最高优先级的任务进行新的调度。 - 如果是添加同步任务是在
dispatchSetState
里的ensureRootIsScheduled
发起的微任务调度,或者在performConcurrentWorkOnRoot
的最后ensureRootIsScheduled
发起的调度,执行完本次任务切片的宏任务接着执行微任务的时候同步任务就会被执行了
所以所有同步任务也都是在 microtask
结束时执行的,已保证最高的优先级且尽可能早的执行
2. 饥饿任务问题
上面说到,在高优先级任务执行完毕之后,低优先级任务就会被重启,但假设如果持续有高优先级任务持续进来,那低优先级任务岂不是没有重启之日?
所以 react
为了处理解决饥饿任务问题,在 scheduleTaskForRootDuringMicrotask
函数开始的时候做了以下处理:(参考markStarvedLanesAsExpired函数)
1 | // 在 调度更新的过程中会被调用 |
可以参考 render
阶段执行的函数 performConcurrentWorkOnRoot
中的代码片段
1 | // ! 1. render |
可以看到只要 shouldTimeSlice
只要返回 false
,就会执行 renderRootSync
,也就是以同步优先级进入 render
阶段。而 shouldTimeSlice
的逻辑也就是刚刚的 expiredLanes
属性相关
1 | const shouldTimeSlice = |
1 | // 检查root是否包含过期的lane |