talk is cheap ,show me the code

  1. const UNIT_SIZE = 10
  2. const MAGIC_NUMBER_OFFSET = 2
  3. export function msToExpirationTime(ms: number): ExpirationTime {
  4. return ((ms / UNIT_SIZE) | 0) + MAGIC_NUMBER_OFFSET
  5. }
  6. export function expirationTimeToMs(expirationTime: ExpirationTime): number {
  7. return (expirationTime - MAGIC_NUMBER_OFFSET) * UNIT_SIZE
  8. }
  9. function ceiling(num: number, precision: number): number {
  10. return (((num / precision) | 0) + 1) * precision
  11. }
  12. function computeExpirationBucket(
  13. currentTime,
  14. expirationInMs,
  15. bucketSizeMs,
  16. ): ExpirationTime {
  17. return (
  18. MAGIC_NUMBER_OFFSET +
  19. ceiling(
  20. currentTime - MAGIC_NUMBER_OFFSET + expirationInMs / UNIT_SIZE,
  21. bucketSizeMs / UNIT_SIZE,
  22. )
  23. )
  24. }
  25. export const LOW_PRIORITY_EXPIRATION = 5000
  26. export const LOW_PRIORITY_BATCH_SIZE = 250
  27. export function computeAsyncExpiration(
  28. currentTime: ExpirationTime,
  29. ): ExpirationTime {
  30. return computeExpirationBucket(
  31. currentTime,
  32. LOW_PRIORITY_EXPIRATION,
  33. LOW_PRIORITY_BATCH_SIZE,
  34. )
  35. }
  36. export const HIGH_PRIORITY_EXPIRATION = __DEV__ ? 500 : 150
  37. export const HIGH_PRIORITY_BATCH_SIZE = 100
  38. export function computeInteractiveExpiration(currentTime: ExpirationTime) {
  39. return computeExpirationBucket(
  40. currentTime,
  41. HIGH_PRIORITY_EXPIRATION,
  42. HIGH_PRIORITY_BATCH_SIZE,
  43. )
  44. }

React 中有两种类型的ExpirationTime,一个是Interactive的,另一种是普通的异步。Interactive的比如说是由事件触发的,那么他的响应优先级会比较高因为涉及到交互。
在整个计算公式中只有currentTime是变量,也就是当前的时间戳。我们拿computeAsyncExpiration举例,在computeExpirationBucket中接收的就是currentTime5000250
最终的公式就是酱紫的:((((currentTime - 2 + 5000 / 10) / 25) | 0) + 1) * 25
其中25250 / 10| 0的作用是取整数
翻译一下就是:当前时间加上498然后处以25取整再加1再乘以 5,需要注意的是这里的currentTime是经过msToExpirationTime处理的,也就是((now / 10) | 0) + 2,所以这里的减去2可以无视,而除以 10 取整应该是要抹平 10 毫秒内的误差,当然最终要用来计算时间差的时候会调用expirationTimeToMs恢复回去,但是被取整去掉的 10 毫秒误差肯定是回不去的。
现在应该很明白了吧?再解释一下吧:简单来说在这里,最终结果是以25为单位向上增加的,比如说我们输入10002 - 10026之间,最终得到的结果都是10525,但是到了10027的到的结果就是10550,这就是除以25取整的效果。
另外一个要提的就是msToExpirationTimeexpirationTimeToMs方法,他们是想换转换的关系。有一点非常重要,那就是用来计算expirationTimecurrentTime是通过msToExpirationTime(now)得到的,也就是预先处理过的,先处以10再加了2,所以后面计算expirationTime要减去2也就不奇怪了

小结

React 这么设计抹相当于抹平了25ms内计算过期时间的误差,那他为什么要这么做呢?我思考了很久都没有得到答案,直到有一天我盯着代码发呆,看到LOW_PRIORITY_BATCH_SIZE这个字样,bacth,是不是就对应batchedUpdates?再细想了一下,这么做也许是为了让非常相近的两次更新得到相同的expirationTime,然后在一次更新中完成,相当于一个自动的batchedUpdates
不禁感叹,真的是细啊!
以上就是expirationTime的计算方法。

currentTime

  1. function requestCurrentTime() {
  2. if (isRendering) {
  3. return currentSchedulerTime
  4. }
  5. findHighestPriorityRoot()
  6. if (
  7. nextFlushedExpirationTime === NoWork ||
  8. nextFlushedExpirationTime === Never
  9. ) {
  10. recomputeCurrentRendererTime()
  11. currentSchedulerTime = currentRendererTime
  12. return currentSchedulerTime
  13. }
  14. return currentSchedulerTime
  15. }

在 React 中我们计算expirationTime要基于当前得时钟时间,一般来说我们只需要获取Date.now或者performance.now就可以了,但是每次获取一下呢比较消耗性能,所以呢 React 设置了currentRendererTime来记录这个值,用于一些不需要重新计算得场景。
但是在 React 中呢又提供了currentSchedulerTime这个变量,同样也是记录这个值的,那么为什么要用两个值呢?我们看一下requestCurrentTime方法的实现。

首先是:

  1. if (isRendering) {
  2. return currentSchedulerTime
  3. }

这个isRendering只有在performWorkOnRoot的时候才会被设置为true,而其本身是一个同步的方法,不存在他执行到一半没有设置isRenderingfalse的时候就跳出,那么什么情况下会在这里出现新的requestCurrentTime呢?

  • 在生命周期方法中调用了setState
  • 需要挂起任务的时候

也就是说 React 要求在一次rendering过程中,新产生的update用于计算过期时间的current必须跟目前的renderTime保持一致,同理在这个周期中所有产生的新的更新的过期时间都会保持一致!

然后第二个判断:

  1. if (
  2. nextFlushedExpirationTime === NoWork ||
  3. nextFlushedExpirationTime === Never
  4. )

也就是说在一个batched更新中,只有第一次创建更新才会重新计算时间,后面的所有更新都会复用第一次创建更新的时候的时间,这个也是为了保证在一个批量更新中产生的同类型的更新只会有相同的过期时间

各种不同的 expirationTime

在 React 的调度过程中存在着非常多不同的expirationTime变量帮助 React 去实现在单线程环境中调度不同优先级的任务这个需求,这篇文章我们就来一一列举他们的含义以及作用,来帮助我们更好得理解 React 中的整个调度过程。

  • root.expirationTime
  • root.nextExpirationTimeToWorkOn
  • root.childExpirationTime
  • root.earliestPendingTime & root.lastestPendingTime
  • root.earliestSuspendedTime & root.lastestSuspendedTime
  • root.lastestPingedTime
  • nextFlushedExpirationTime
  • nextLatestAbsoluteTimeoutMs
  • currentRendererTime
  • currentSchedulerTime

另外,所有节点都会具有expirationTimechildExpirationTime两个属性
以上所有值初始都是NoWork也就是0,以及他们一共会有三种情况:

  • NoWork,代表没有更新
  • Sync,代表同步执行,不会被调度也不会被打断
  • async模式下计算出来的过期时间,一个时间戳

接下去的例子我们都会根据下面的例子结构来讲,我们假设有如下结构的一个 React 节点树,他的Fiber结构如下:
expirationTime 公式 - 图1
后续我们会在这个基础上讲解不同情况下expirationTime的情况

childExpirationTime

在之前我们说过,每次一个节点调用setState或者forceUpdate都会产生一个更新并且计算一个expirationTime,那么这个节点的expirationTime就是当时计算出来的值,因为这个更新本身就是由这个节点产生的
最终因为 React 的更新需要从FiberRoot开始,所以会执行一次向上遍历找到FiberRoot,而向上遍历则正好是一步步找到创建更新的节点的父节点的过程,这时候 React 就会对每一个该节点的父节点链上的节点设置childExpirationTime,因为这个更新是他们的子孙节点造成的
expirationTime 公式 - 图2
如上图所示,我们先忽略最左边的child1产生的一次异步更新,如果当前只有child2产生了一个Sync更新,那么AppFiberRootchildExpirationTime都会更新成Sync
那么这个值有什么用呢?在我们向下更新整棵Fiber树的时候,每个节点都会执行对应的update方法,在这个方法里面就会使用节点本身的expirationTimechildExpirationTime来判断他是否可以直接跳过,不更新子树。expirationTime代表他本身是否有更新,如果他本身有更新,那么他的更新可能会影响子树;childExpirationTime表示他的子树是否产生了更新;如果两个都没有,那么子树是不需要更新的。
对应图中,如果child1child3child4还有子树,那么在这次child2的更新中,他们是不需要重新渲染的,在遍历到他们的时候,会直接跳过
注意:这里只讨论没有其他副作用的情况,比如使用老的context api之类的最终会强制导致没有更新的组件重新渲染的情况我们先不考虑。
了解了childExpirationTime的作用之后,我们再来讲一下他的特性:

  • 同一个节点产生的连续两次更新,最红在父节点上只会体现一次childExpirationTime
  • 不同子树产生的更新,最终体现在跟节点上的是优先级最高的那个更新

第一点很好理解,同一个节点在第一次更新还没有结束的情况下再次产生更新,那么不管他们优先级哪个高,最终都会按照高优先级那个过期时间把所有更新都更新掉了,因为Fiber对象只有一个,updateQueue也只有一个,无法区分同一个对象上连续的不同更新。
第二点是因为 React 在创建更新向上寻找root并设置childExpirationTime的时候,会对比之前设置过的和现在的,最终会等于NoWork的最小的childExpirationTime,因为expirationTime越小优先级越高,Sync是最高优先级
对应到上面的例子中,child1产生的更新是异步的,所以他的expirationTime是计算出来的时间戳,那么肯定比Sync大,所以率先记录到父节点的是child2,同时也是child2的更新先被执行。即便是child1的更新先产生,如果他在chidl2产生更新的时候他还没更新完,那么也会被打断,先完成child2的渲染,再回头来渲染child1
以上是childExpirationTime的作用和特性,他在每个节点completeWork的时候会reset父链上的childExpirationTime,也就是说这个节点已经更新完了,那么他的childExpirationTime也就没有意义了。那么这个我们在讲节点更新的时候会讲到,到时候大家再对应起来看效果会更好。

pendingTime

FiberRoot上有两个值earliestPendingTimelastestPedingTime,他们是一对值,用来记录所有子树中需要进行渲染的更新的expirationTime的区间
expirationTime 公式 - 图3
在这个例子里,我们同时在child1child2child3产生里更新,并且根据优先级计算出了不同的更新时间(再次重申,请忽略细节,现实中不太会出现同时产生的情况)。
每个更新创建的时候,React 会通过markPendingPriorityLevel标记root节点的earliestPendingTimelastestPedingTime。他们只记录区间,也就是说现在我们产生了三个不同的过期时间,但是这里只记录最大和最小的。
那么他的作用就很明显了,通过追踪最大和最小值,React 可以判断在当前更新之后是否还具有优先级更低的任务需要执行(当前过期时间处理这两个值之间)。

suspendedTime

同样的在ReactFiber上有两个值earliestSuspendedTimelastestSuspendedTime这两个值是用来记录被挂起的任务的过期时间的
首先我们定义一下什么情况下任务是被挂起的:

  • 出现可捕获的错误并且还有优先级更低的任务的情况下
  • 当捕获到thenable,并且需要设置onTimeout的时候

我们称这个任务被suspended(挂起)了。记录这个时间主要是在resolvepromise之后,判断被挂起的组件更新是否依然处于目前已有的suspenedTime中间,如果不是的话是需要重新计算一个新的过期时间,然后从新加入队列进行调度更新的。
另外就是在确定目前需要执行的任务的过期时间,也就是root.expirationTimeroot.nextExpirationTimeToWorkOn的时候也是一个考虑因素。我们接下去会讲到

lastestPingedTime

这个值是用来记录最新的一次suspended组件resolve之后,如果挂起之前的expirationTime依然在earliestSuspendedTimelastestSuspendedTime之间,则会标志这个时间为pingedTime
pingedTime目前看来没有什么别的作用,唯一跟suspendedTime的区别是他的优先级比suspendedTime高一些,会优先选择为渲染目标。

root.expirationTime 和 root.nextExpirationTimeToWorkOn

root.expirationTime是用来标志当前渲染的过期时间的,请注意他只管本渲染周期,他并不管你现在的渲染目标是哪个,渲染目标是由root.nextExpirationTimeToWorkOn来决定的。
那么他们有什么区别呢?主要区别在于发挥作用的阶段
expirationTime作用于调度阶段,主要指责是:

  • 决定是异步执行渲染还是同步执行渲染
  • 作为react-schedulertimeout标准,决定是否要优先渲染

nextExpirationTimeToWorkOn主要作用于渲染阶段:

  • 决定那些更新要在当前周期中被执行
  • 通过跟每个节点的expirationTime比较决定该节点是否可以直接bailout(跳过)

他们都是通过pendingTimesuspenededTimepingedTime中删选出来的,唯一的不同是,nextExpirationTimeToWorkOn在没有pending或者pinged的任务的时候会选择最晚的suspendedTime,而expirationTime会选择最早的
expirationTime的变化:

  • scheduleWork的时候通过markPendingExpirationTime设置
  • beginWork的时候被设置为NoWork
  • onUncaughtError的时候设置为NoWork
  • onSuspend的时候又会设置回当次更新的expirationTime

这里的不同选择我到目前也没有非常清晰的理解,尝试跟dan沟通了解没得到什么反馈,跟司徒正美大大聊起过,他觉得这部分功能目前其实还不是特别稳定,有些代码还是比较临时性的,所以现在可以不必要太深究,所以目前来说大家只要知道代码逻辑就可以
展示一下代码:

  1. function findNextExpirationTimeToWorkOn(completedExpirationTime, root) {
  2. const earliestSuspendedTime = root.earliestSuspendedTime
  3. const latestSuspendedTime = root.latestSuspendedTime
  4. const earliestPendingTime = root.earliestPendingTime
  5. const latestPingedTime = root.latestPingedTime
  6. let nextExpirationTimeToWorkOn =
  7. earliestPendingTime !== NoWork ? earliestPendingTime : latestPingedTime
  8. if (
  9. nextExpirationTimeToWorkOn === NoWork &&
  10. (completedExpirationTime === NoWork ||
  11. latestSuspendedTime > completedExpirationTime)
  12. ) {
  13. nextExpirationTimeToWorkOn = latestSuspendedTime
  14. }
  15. let expirationTime = nextExpirationTimeToWorkOn
  16. if (
  17. expirationTime !== NoWork &&
  18. earliestSuspendedTime !== NoWork &&
  19. earliestSuspendedTime < expirationTime
  20. ) {
  21. expirationTime = earliestSuspendedTime
  22. }
  23. root.nextExpirationTimeToWorkOn = nextExpirationTimeToWorkOn
  24. root.expirationTime = expirationTime
  25. }

每次开始调度任务,或者设置pendingTimesuspendedTimepingedTime都会调用这个方法重新删选接下去执行的任务。
需要注意一点,并不是所有情况都是从这个方法删选出这两个值的,比如在renderRoot出现错误捕获,并且没有更低优先级的任务的时候,强制以同步的方式把当前expirationTime的任务再重新执行了一遍

  1. root.didError = true
  2. const suspendedExpirationTime = (root.nextExpirationTimeToWorkOn = expirationTime)
  3. const rootExpirationTime = (root.expirationTime = Sync)
  4. // 什么事情都不做,回到主线程
  5. onSuspend(
  6. root,
  7. rootWorkInProgress,
  8. suspendedExpirationTime,
  9. rootExpirationTime,
  10. -1, // Indicates no timeout
  11. )
  12. return

这里直接把root.expirationTime设置为Sync,然后直接return,这样回到performWork的时候调用findHighestPriorityRoot会直接设置当前root为下一个渲染的目标,然后再次进行渲染。performWork的循环我们在fiber-scheduler中有详细讲解

nextFlushedExpirationTime

这个是在fiber-scheduler中的一个全局变量,用来记录下一个需要渲染的FiberRoot的过期时间。注意他删选的时候整个应用中所有FiberRoot的优先级(是的,你没看错,应该 React 应用是可以有多个FiberRoot,比如你执行两次ReactDOM.render),并不关心每个FiberRoot子树的优先级。
他是在findHighestPriorityRoot中被赋值的,会遍历firstScheduleRoot -> lastScheduledRoot链表中所有root,并找到优先级最高(也就是expirationTime最小)的那个root进行赋值,并安排渲染

nextLatestAbsoluteTimeoutMs

他的作用是在Suspense组件捕获到挂起之后,增加一个timeout来强制重新渲染一次的,不过目前看不出这个timeout有什么用
这个跟Suspense组件的maxDuration有关,但是官方没公布用法,从源码中也看不出有什么用处,原以为是多少毫秒内不显示fallback的内容,结果测试了一下发现没用。等有空再研究一下,或者等官方正式发布吧,毕竟现在就算研究出来了,可能分分钟就被改了,16.5 的时候还叫delayMs,16.6 就改成maxDuration

currentSchedulerTime & currentRendererTime

这两个时间是用来是用来记录当前时间的,在计算过期时间比较任务是否过期的时候都会用到currentRendererTimecurrentSchedulerTime大部分时候都是等于currentRendererTime的,那为什么要设置两个时间呢?
这就是因为batchedUpdates了,在这种情况下如果同时创建了多个更新是会为每次更新计算过期时间的,而计算是要花时间的,如果每次都是请求当前时间,那么同一个batch中的不同更新得到的过期时间就会是不一样的,所以在一个batch中获取的当前时间应该是一样的,所以就设置了这么一个值,在我们请求当前时间的方法中有这么一段代码:

  1. findHighestPriorityRoot()
  2. if (
  3. nextFlushedExpirationTime === NoWork ||
  4. nextFlushedExpirationTime === Never
  5. ) {
  6. recomputeCurrentRendererTime()
  7. currentSchedulerTime = currentRendererTime
  8. return currentSchedulerTime
  9. }

findHighestPriorityRoot会设置nextFlushedExpirationTime,也就是只有在当前没有等待中的更新的情况下,才会重新计算当前时间。