最近又学习了掘金小册《React进阶实战》,在调度、调和这个以前也不是特别清楚的地方查漏补缺了一番。
当然,目前的理解也不一定是对的,不过整体过程能整体圆起来,能解释通。

调度:形象化例子

每一个更新, 看作一个人拿着材料去办事处办理业务,办事处处理每一个人的业务需要时间,但工作人员,需要维护办事处的正常运转,不能全身心投入给顾客办理业务。
所以其策略是:
1. 首先需要所有来访的顾客排成一队。然后工作人员开始逐一受理业务;
2. 工作人员每次办理一个任务后,就先维持办事处的正常运转,等到工作人员有闲暇的时间,再来办理下一个业务;

那么在这个背景下,调度:

  1. 一定是在多个任务情况下,单个更新任务就没调度的必要;
  2. 调度任务就是每一次执行一批任务,然后先让浏览器完成后续的渲染操作,然后在空暇时间,再执行下一些任务;

不同模式的调度策略有所不同

  • Legacy:v17 及其以下版本,所有的任务都是紧急任务,那么所有来办理的人员都是平等的,所以工作人员只需要按序办理业务就可以了;
  • Concurrent Mode:根据优先级分了会员和非会员。

如果会员和非会员排列到一起,那么优先会办理会员的业务(正常的紧急优先任务),正常情况下,会办理完所有的会员业务,才开始办理非会员任务。在一些极端的情况下,怕会员一直办理,非会员无法办理(被饿死的情况),所以设置一个超时时间,达到超时时间,会破格执行一个非会员任务。

调和:形象化例子

假设我们的应用看作一台设备,那么每一次更新,看作一次检修维护更新。
修师傅会用一个机器 (workLoop可以看作这个机器) ,依次检查每一个需要维护更新的零件(fiber可以看作零件)。需要检修的零件都会进入检查流程,如果需要更新,那么会更新,如果有子零件更新(子代 fiber)则继续向下检修。

不同模式:

  • legacy:所有的零件维修,没有优先级的区分,所有都被维修师傅依次检查执行;
  • Concurrent Mode:对于设备的维修,实际检修有很多种类,有的迫在眉睫,比如影响设备正常运转的;有的没那么重要,比如机器打蜡,清理等;

师傅用机器检修零件,但是遇到更高优先处理的任务,就会暂定当前零件的检修,而去检修更重要的任务(可中断)。

为什么要调度机制

React更新的特点需要一套调度机制:React不能向Vue那样做到精准的更新,必须从“顶上”自顶向下去找不同,这样大的工作量,很容易阻塞浏览器的正常渲染流程。所以在当前React版本中,更新是可以中断的,同时,也是“批量”的(尽量不阻塞浏览器的渲染流程),为了满足这样的需求,设计了数据结构Fiber和调度机制。

时间分片

时间分片的目的就是将不可中断的大规模计算,变成穿插在“空闲”时间执行的可中断的小规模计算。
这里两个要点:1. 可中断;2. 空闲时间

空闲时间的实现

废弃方案:setTimeout 可以产生宏任务而且兼容性好,但是连续的setTimeout之间总是有4-5ms的时间间隔,造成浪费;
废弃方案:requestIdleCallback 兼容性不好,只有Chrome支持
采取方案:MessageChannel,可以产生宏任务,so不会阻塞渲染流程
React把将要执行的任务赋给一个全局变量,然后借助MessageChannel发送postMessage消息,而onmessage接受到消息的时候已经是下一个宏任务了,在这个新的宏任务中再执行这个全局变量函数,就相当于把这个函数的执行异步化了。
比如 期望在下一个宏任务中执行callback,requestHostCallback(callback),当我执行这个函数的时候,callback会在下一次宏任务背执行,怎么实现呢:

  1. // 全局变量
  2. let scheduledHostCallback = null
  3. // 消息通道
  4. const channel = new MessageChannel();
  5. // 建立一个port发送消息
  6. const port = channel.port2;

在requestHostCallback中发起postMessage

  1. requestHostCallback = function (callback) {
  2. scheduledHostCallback = callback;
  3. port.postMessage(null);
  4. };

监听Message并执行全局函数:

  1. channel.port1.onmessage = function(){
  2. // 执行任务
  3. scheduledHostCallback()
  4. // 执行完毕,清空任务
  5. scheduledHostCallback = null
  6. };

任务进入调度器发生了什么?调度过程

不上代码,纯文字描述
当一个任务进入调度器,需要根据其“过期时间”判断需要入哪个队:有存放还没有过期的队列TimeQueue和存放过期的队列TaskQueue
那么分两种情况:

  • 假设任务过期:放到过期队列中,然后去请求浏览器空闲时间,就按照上文那样,调用函数:请求时间(事情) ,这个事情是什么呢?就是清掉工作(flushWork)—从过期队列里面取出任务执行。
  • 假设任务没有过期:放到未过期任务中,根据过期时间和当前时间的差,定个闹钟,闹钟一到就把这个任务移动到过期任务队列里面,然后再向浏览器请求空闲时间。

就是下面这幅图:
image.png
补充:
其实现在的React,进入TimeQueue和TaskQueue的依据不是简单的用时间来判断了,这两个队列也不能叫时间队列,应该叫未就绪队列、就绪队列:

  1. 判断的依据是优先级,这个优先级的算法比较复杂,大致就是时间 + lane优先级;
  2. 队列也并不是真正的队列,而是最小堆实现的优先级队列,所以peek出来的都是高优先级的;

调度和调和整体逻辑概要

当点击事件中setState,产生了一次更新之后。
React生成任务,此任务进入调度器中,经过上面调度器流程。调度器最终从浏览器中“申请”到了时间执行任务。
这个任务其实就是“调和过程”,正常的应该是从根节点开始遍历fiber tree,拿到访问每一个fiber节点(执行render阶段:beginWork和completeWork),这个阶段的目的就是找出复用节点和副作用,但是这个阶段是可以被中断的。
(中断发生在什么情况下呢?有更重要的事情、或者没有更多的时间继续下去了,就是浏览器需要处理帧的“日常”, 每次获取fiber前的shouldYield就是判断该不该中断的函数)
如果发生了中断,就会生成一个从中断处开始并有标记这是一个中断任务的新的task,重新进入scheduler,同时,退出调和过程,将控制权交给浏览器。
image.png

页面初始化、setState之后如何进入调度

  1. 页面初始化就是调用ReactDOM.render之后,做的工作就是新建一个update,然后调用调用 在FIber上更新调度方法(scheduleUpdateOnFiber)
  2. setState分两种情况:1) 发生在正常的事件中(React可控);2)发生在异步任务中(React不可控)。(这里的可控就是在合成事件上下文的控制范围内,因为React事件统一都走的是合成事件的处理规则,当合成事件处理的开始阶段会打开一个标志isBatchingxxx = true, 当合成事件处理完之后,会将标志置为false,所以,异步事件处理回调的时候肯定是没有这个标志的)

不过话说回来,setState之后大致的过程也是新建一个update,然后调用调用 在FIber上更新调度方法(scheduleUpdateOnFiber)。

所以,不管页面初始化,还是setState,统一的都会调用scheduleUpdateOnFiber。
但是scheduleUpdateOnFiber方法在对他们做了不同的对待:

  • 页面初始化的update被打上unbatch的标记,不需要进入调度器调度,直接执行,这很合理。
  • setState
    • 可控的:update进入了一个叫 确保根节点被调度的方法(ensureRootIsScheduled),这个方法里面会计算update的优先级,进入调度流程。怎么进入调度流程呢?异步进入调度流程:当合成事件结束之后发起一个方法 冲刷回调队列(flushSyncCallbackQueue),这个方法会发起调度器调度任务
    • 不可控的:update也进入ensureRootIsScheduled,但是因为不存在合成事件上下文,所以同步的调用了 冲刷回调队列(flushSyncCallbackQueue)发起调度任务。

这里可以看到setState不管是不是可控,最终都经过了调度器的调度,但是调度器唤起的时机是不一样的,可控的因为可控,所以是合成事件上下文最后唤起调度,这个就体现了其异步性;而不可控因为不在事件上下文内,所以只好自己同步唤起调度器了

可控的setState如何实现批量更新

已经说过,在合成事件上下文中,有一个开关isBatchxxx,合成事件处理的开始阶段就标记为true,合成事件的结束阶段标记为false。
而在这个标记生效内的update走的都是批量更新,比如在一个onClick中执行三次相同赋值操作,其实只执行了一次。
那么这个具体是怎么做到的?
就是在update进入ensureRootIsScheduled方法之后会被计算优先级,而相同的更新优先级是相同的,那么后续优先级相同的就会被抛弃掉。
即:多次触发相同优先级的更新只有第一次会进入到调度中

调和和render

在一次更新中,所有的子组件都会被遍历然后进入调和过程吗?(不是)
进入调和过程的组件一定会被render吗?(不是)
这里讨论的一个前提是,组件是更新的最小单位。
但是一个组件可能由多个fiber节点构成,这些fiber节点是调和的最小单位。

那些组件会被调和

讨论这个问题,需要讨论产生更新之后发生了什么,当某组件产生一个更新,会根据这个更新计算一个优先级lane,得到这个优先级之后,往上return递归各个父节点的一致到root,把他们的childLane都设置为计算出来的这个lane。
当从rootFiber开始的调和过程开始了之后:
凡事发现childLane不等于lane的节点都不用对其children做调和,可以认为跟这次更新没有关系,直接调和其sibling节点。总有节点的childlane === lane 或者 节点自身的lane === lane
如果childrenLane === lane 说明更新发生在子、孙、重孙子…节点。
如果lane === lane 说明就是当前节点发生了更新。

所以那些组件会被调和?

  1. 发生更新的节点和其子代所有节点;
  2. 从该节点到root的整个链路上的节点;
  3. 上述链路节点的第一级子节点;

为什么要叫调和?

这个单词就是这个意思:调和、和解
image.png
上面我们知道,React调和虽然是从顶层开始,但并没有对所有的节点进行处理,而是尽力的在找那些需要被处理的节点,包括DIff的目的也是一样。
那么,为什么叫调和,大致是因为这样吧?
某次,发生事件,又需要更新了…

我:“有得新任务咯!从root开始,都给老子更新撒!” React: “哥,不至于,咱们只更新一部分就成…您瞅准咯,有一方法,给您算算看 ….” 我:“好你mmp个龟儿,抓紧算起撒!” React:“甭上火,您看,这就算好了,咱们只用更新这些就行~”

所以,这“一套方法”,就是个“和事佬”,稍微花点功夫,叫程序不要那么无脑和暴躁。

调和的节点都会执行render吗?

组件更新类组件是执行render,函数组件是执行函数;
但不是所有调和的点都需要执行render,现在已经知道那些组件在调和,但怎么知道这些需要调和的节点是不是需要更新呢?
调和时需要检查这几个要点:

  • oldProps 和 newProps 是不是相等

    • 先说相等的逻辑,如果相等,在看这个组件的lane和当前render的lane是不是想等,oldProps 、newProps没变化且lane不相等,这说明当前节点不用更新
    • oldProps 、newProps没变化且lane相等,这说明当前组件引起的更新这时候,那就需要更新
  • 如果oldProps 和 newProps 不相等,则肯定是父辈节点有更新,(在没有SCU、memo控制的情况下)自己作为子组件也需要更新

当然,如果按照调和的fiber链表的顺序走的话,肯定也是先找到发动更新的组件,然后render他的子组件

这幅图描绘了一个进入了调和的fiber在beginWork阶段到底能不能被render的全部过程:
image.png