React18.2x源码解析(二)render阶段之scheduler调度流程

本节将深入理解React18.2x的scheduler调度程序的执行过程。

在学习之前,我们要先了解react应用的渲染流程主要有哪些阶段?

根据react18的源码,我们可以分成两个大的阶段:

  • render阶段:这个阶段由scheduler调度程序和Reconciler协调流程两个模块构成,主要内容是更新任务的调度以及FiberTree【虚拟DOM树】的构建。
  • commit阶段:根据创建完成的FiberTree,构建出真实的DOM内容渲染到页面。

react应用的每次加载或更新流程,都会执行这两个阶段的程序,所以理解每个阶段的执行逻辑,对于我们理解react源码至关重要。

第二,三章节分别讲解render阶段的scheduler调度流程和reconciler协调流程,第四章节讲解commit阶段的内容。

1,schedule调度准备

scheduleUpdateOnFiber

接着上一章节,我们继续回到scheduleUpdateOnFiber方法:

JavaScript 复制代码
// packages\react-reconciler\src\ReactFiberWorkLoop.new.js
​
export function scheduleUpdateOnFiber(
  root: FiberRoot, // 根节点容器
  fiber: Fiber, // 当前Fiber节点
  lane: Lane, // 更新优先级
  eventTime: number,
) {
  checkForNestedUpdates();
​
  // Mark that the root has a pending update.
  // 标记root根节点的pendingLanes
  markRootUpdated(root, lane, eventTime);
​
  if (
    (executionContext & RenderContext) !== NoLanes &&
    root === workInProgressRoot
  ) {
    warnAboutRenderPhaseUpdatesInDEV(fiber);
​
    workInProgressRootRenderPhaseUpdatedLanes = mergeLanes(
      workInProgressRootRenderPhaseUpdatedLanes,
      lane,
    );
  } else {
​
    # 渲染阶段之外的正常更新:
    
    // 如果root根元素= 当前的工作节点
    if (root === workInProgressRoot) {
      if (
        deferRenderPhaseUpdateToNextBatch ||
        (executionContext & RenderContext) === NoContext
      ) {
        workInProgressRootInterleavedUpdatedLanes = mergeLanes(
          workInProgressRootInterleavedUpdatedLanes,
          lane,
        );
      }
      if (workInProgressRootExitStatus === RootSuspendedWithDelay) {
        markRootSuspended(root, workInProgressRootRenderLanes);
      }
    }
​
    # 确定根节点(root)的调度【重要】
    ensureRootIsScheduled(root, eventTime);
    ...
  }
}

根据函数名我们可以知道,scheduleUpdateOnFiber方法主要作用是开启一个新的调度更新任务,这个函数在很多地方被调用,比如组件状态变化【setStateuseState】,就会调用此函数触发一次调度任务,执行一次完整的更新流程。

这个函数中主要执行了两个重点逻辑:

JavaScript 复制代码
// 1,标记root有更新任务
markRootUpdated(root, lane, eventTime);
​
// 2,开始调度
ensureRootIsScheduled(root, eventTime);
markRootUpdated

首先查看markRootUpdated方法:

JavaScript 复制代码
// packages\react-reconciler\src\ReactFiberLane.new.js
​
export function markRootUpdated(
  root: FiberRoot,
  updateLane: Lane,
  eventTime: number,
) {
​
  # 设置本次更新的优先级
  root.pendingLanes |= updateLane;
​
  // 如果updateLane优先级不是为空闲级的优先级【不是低优先级,当前不是】
  if (updateLane !== IdleLane) {
    // 则重置root应用根节点的优先级
    root.suspendedLanes = NoLanes; 
    root.pingedLanes = NoLanes;
  }
​
  const eventTimes = root.eventTimes;
  const index = laneToIndex(updateLane);
  eventTimes[index] = eventTime;
}

markRootUpdated方法的主要作用是将本次更新的优先级updateLane,添加到root应用根节点上的pendingLanes属性,标记其有正在等待更新的内容。此时挂载的优先级将会后在后续被转换为调度优先级,方便调度程序的使用。

ensureRootIsScheduled

继续查看ensureRootIsScheduled方法:

JavaScript 复制代码
// packages\react-reconciler\src\ReactFiberWorkLoop.new.js
​
# 确保root应用根节点被调度
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
  // 取出现行存在的回调节点【即任务task】, 默认是null
  const existingCallbackNode = root.callbackNode;
​
  // 检查更新通道是否被其他任务占用,
  markStarvedLanesAsExpired(root, currentTime);
​
  // Determine the next lanes to work on, and their priority.
  // 基于root.pendingLanes,计算出本次更新的批次Lanes 
  const nextLanes = getNextLanes(
    root,
    root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
  );
​
  // 如果优先级等于0 ,说明根节点没有可处理的回调,则退出任务调度
  if (nextLanes === NoLanes) {
    if (existingCallbackNode !== null) {
      cancelCallback(existingCallbackNode);
    }
    root.callbackNode = null;
    root.callbackPriority = NoLane;
    return;
  }
​
  // We use the highest priority lane to represent the priority of the callback.
  # 获取lanes批次集合中最高优先级的lane,作为为本次回调任务的优先级
  const newCallbackPriority = getHighestPriorityLane(nextLanes);
  // 检查现在的优先级
  const existingCallbackPriority = root.callbackPriority;
  // 判断新的优先级与当前任务优先级是否相等
  if (existingCallbackPriority === newCallbackPriority) {
    // The priority hasn't changed. We can reuse the existing task. Exit.
    // 如果相同,则表示优先级无变化,可以重用当前任务
    // 这种情况一般出现在:多个状态触发的更新,可以共用一个调度任务,不会发起新的调度
    return;
  }
​
  // Schedule a new callback.
​
  # 创建一个新的回调任务
  let newCallbackNode;
  # 1,如果新的回调优先级为 同步Lane
  if (newCallbackPriority === SyncLane) {
​
    // 同步回调任务处理;
    // Special case: Sync React callbacks are scheduled on a special
    // internal queue
    // 就根据根节点的Tag来选择调度方式 3
    if (root.tag === LegacyRoot) {
      scheduleLegacySyncCallback(performSyncWorkOnRoot.bind(null, root));
    } else {
      // v18 同步任务调度,将performSyncWorkOnRoot函数,添加到一个同步队列syncQueue
      scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
    }
​
    // 如果支持微任务
    if (supportsMicrotasks) {
      // Flush the queue in a microtask
      # 支持微任务的情况下:将cb回调任务添加到微任务
      scheduleMicrotask(() => {
         flushSyncCallbacks();
       });
    } else {
      // Flush the queue in an Immediate task.
      // 不支持微任务,则立即冲刷队列queue,执行回调任务
      scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
    }
    newCallbackNode = null;
  } else {
​
    # 2,异步回调任务处理:
    // 定义一个【调度的优先级】,因为react事件优先级和scheduler的优先级不同,需要经过转换【即:事件优先级转换为调度优先级】
    let schedulerPriorityLevel; 
    // lanes转换成事件优先级,匹配符合的优先级,然后赋值对应的scheduler的优先级
    switch (lanesToEventPriority(nextLanes)) {
      case DiscreteEventPriority:
        schedulerPriorityLevel = ImmediateSchedulerPriority;
        break;
      case ContinuousEventPriority:
        schedulerPriorityLevel = UserBlockingSchedulerPriority;
        break;
      case DefaultEventPriority: // 16
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
      case IdleEventPriority:
        schedulerPriorityLevel = IdleSchedulerPriority;
        break;
      default:
        schedulerPriorityLevel = NormalSchedulerPriority;
        break;
    }
​
    # 根据【调度优先级】,调度任务,返回新的任务task
    // scheduleCallback调度方法里面会执行很多逻辑,简单来说会创建新的task,以及生成一个新的宏任务来处理回调
    newCallbackNode = scheduleCallback(
      schedulerPriorityLevel,
      // 最终要执行的cb回调任务 , 使用bind这种方式的好处,可以传递传递参数,又可以传递函数进去
      performConcurrentWorkOnRoot.bind(null, root),
    );
  }
​
  root.callbackPriority = newCallbackPriority;
  // 设置根节点的回调节点 为新的任务task
  root.callbackNode = newCallbackNode;
}

ensureRootIsScheduled方法源码虽然比较多,但要执行的内容并不复杂。主要处理了以下重点逻辑:

  • 基于root.pendingLanes,计算出本次更新的批次nextLanes
  • 获取nextLanes批次集合中最高优先级的lane,作为为本次回调任务的优先级newCallbackPriority
  • 取出当前已存在的调度优先级,与newCallbackPriority进行对比,如果相同则不会发起新的调度任务。
  • 创建新的回调任务newCallbackNode,这里会根据新的回调优先级newCallbackPriority,来决定newCallbackNode的创建。

这里要解释一下第三点:

js 复制代码
    // 新的优先级
    const newCallbackPriority = getHighestPriorityLane(nextLanes);
    // 检查现存的优先级
    const existingCallbackPriority = root.callbackPriority;
    // 判断新的优先级与当前任务优先级是否相等
    if (existingCallbackPriority === newCallbackPriority) {
      // The priority hasn't changed. We can reuse the existing task. Exit.
      // 如果相同,则表示优先级无变化,可以重用当前任务
      // 这种情况一般出现在:多个状态触发的更新,可以共用一个调度任务,不会发起新的调度
      return;
    }

如果新的任务优先级与现存优先级相同,则不会发起新的调度任务。这种情况在组件触发更新时比较常见,比如类组件在一个DOM事件中多次调用了this.setState或者函数组件多次调用了useStateset方法,这种情况下只有第一次的修改会发起新的调度任务,后续的修改则会复用前面的任务,不会发起新的调度任务。

关于这里的细节可以查看《React18.2x源码解析:组件的加载过程》中更新阶段逻辑。

下面接着查看回调任务创建的逻辑:

注意: 这里根据新的回调优先级是否为同步优先级,分成了以下两种逻辑处理:

同步更新

一,同步更新:performSyncWorkOnRoot

js 复制代码
    // 同步优先级
    newCallbackPriority === SyncLane

如果新的回调优先级newCallbackPriority等于SyncLane,则代表当前为同步任务,需要进行同步更新。

最常见的即通过click事件触发的更新,属于同步优先级,就会进入同步任务逻辑处理:

js 复制代码
    # 同步更新任务
    ​
    // 1,判断当前应用模式,进入的不同的任务调度
    if (root.tag === LegacyRoot) {
      scheduleLegacySyncCallback(performSyncWorkOnRoot.bind(null,root));
    } else {
      // v18 同步任务调度
      scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
    }
    ​
    // 2,判断是否支持微任务
    if (supportsMicrotasks) {
      // Flush the queue in a microtask
      # 支持微任务的情况下:将cb回调任务添加到微任务
      scheduleMicrotask(() => {
        flushSyncCallbacks();
      });
    } else {
      // 不支持微任务,则立即冲刷队列queue,执行回调任务
      scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
    }

同步任务的逻辑其实很简单,上面主要是两个判断逻辑:

  • 首先判断应用根节点root.tag值,是否为历史遗留模式LegacyRoot,在第一章我们已经讲述过了,react18默认是并发渲染模式,所以不满足判断条件,会进入else分支,执行同步任务的调度。
js 复制代码
    scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));

查看同步任务的调度方法:

js 复制代码
    // packages\react-reconciler\src\ReactFiberSyncTaskQueue.new.js
    ​
    export function scheduleSyncCallback(callback: SchedulerCallback) {
    ​
      // 同步任务调度
      // 将此回调添加到一个内部队列syncQueue
      if (syncQueue === null) {
        // 首次,初始化同步队列
        syncQueue = [callback];
      } else {
        // 同步队列已经存在,直接添加
        syncQueue.push(callback);
      }
    }

根据上面的源码可以看出,同步任务的调度非常简单,就是将执行同步任务的回调添加到一个同步队列syncQueue里面。

这里的callback方法就是上面传入的performSyncWorkOnRoot,它就是专门执行同步更新任务的方法。

下面我们进入这个方法查看一下它的细节:

js 复制代码
    // packages\react-reconciler\src\ReactFiberWorkLoop.new.js
    ​
    function performSyncWorkOnRoot(root) {
    ​
      flushPassiveEffects();
      # 执行Fiber Reconciler协调流程,创建虚拟DOM树
      let exitStatus = renderRootSync(root, lanes);
      ...
    ​
      // We now have a consistent tree. Because this is a sync render, we
      // will commit it even if something suspended.
      const finishedWork: Fiber = (root.current.alternate: any);
      // 存储创建完成的FiberTree,根节点为HostFiber
      root.finishedWork = finishedWork;
      root.finishedLanes = lanes;
      // 进入commit阶段
      commitRoot(
        root,
        workInProgressRootRecoverableErrors,
        workInProgressTransitions,
      );
      ensureRootIsScheduled(root, now());
    ​
      return null;
    }

performSyncWorkOnRoot方法主要逻辑就以下几点:

  • 调用renderRootSync方法创建FiberTree
  • 然后将创建完成的FiberTree挂载到root应用根节点上。
  • 最后调用commitRoot方法,进入commit阶段的内容执行,将虚拟DOM树转换为真实的DOM结构渲染到页面。

继续查看同步任务的第二点处理逻辑:

  • 判断当前环境是否支持微任务,如果支持微任务则会将同步任务的执行放在微任务队列 中,如果不支持微任务,则立即执行同步任务。现代浏览器都支持微任务,所以这里就会进入微任务的逻辑分支,将同步任务的执行方法加入到微任务队列之中,这里使用微任务主要就是来优化处理:同步事件中多次修改state的场景,避免每次修改数据都需要发起新的调度任务。
js 复制代码
    // 添加到微任务中
    // Promise.then(fn)
    scheduleMicrotask(() => {
        flushSyncCallbacks();
    })

这里的flushSyncCallbacks方法作用就是:冲刷之前的syncQueue同步队列,执行回调方法,完成同步更新任务。

js 复制代码
    // packages\react-reconciler\src\ReactFiberSyncTaskQueue.new.js
    ​
    export function flushSyncCallbacks() {
      if (!isFlushingSyncQueue && syncQueue !== null) {
        
        // 简略代码...
        const queue = syncQueue;
        for (; i < queue.length; i++) {
          let callback = queue[i];
          do {
            callback = callback(isSync);
          } while (callback !== null);
        }
      }
      return null;
    }

根据上面的源码可以看出,flushSyncCallbacks方法的核心就是循环同步队列,取出队列之中的callback回调,调用callback回调函数,执行同步更新任务。

总结: 以上就是同步更新任务的执行逻辑,可以看出它的执行逻辑是比较简单的,同步任务不需要被调度程序管理。并且同步更新任务默认是添加到微任务队列之中执行的,所以即使同步任务也并不一定是立即执行的。

异步更新

二,异步更新:performConcurrentWorkOnRoot

js 复制代码
    // 非同步优先级
    newCallbackPriority !== SyncLane

如果新的回调优先级newCallbackPriority不等于SyncLane,则代表当前为异步任务,需要进行异步并发更新处理。

以当前react的初始加载为例,不属于同步优先级,就会进入异步更新流程,所以这里需要定义一个调度的优先级schedulerPriorityLevel

因为react事件优先级和scheduler调度优先级不同,需要经过转换。这里将本次的更新批次nextLanes,转换为对应的react事件优先级,然后再匹配符合的scheduler调度优先级。

js 复制代码
    // 根据【调度优先级,callback】 返回新的任务task
    newCallbackNode = scheduleCallback(schedulerPriorityLevel,performConcurrentWorkOnRoot.bind(null, root))

这里newCallbackNode就是本次的task任务,scheduleCallback函数是从调度器scheduler中导出的方法:

js 复制代码
    import * as Scheduler from 'scheduler';
    ​
    // 从调度器中引入的方法:
    export const scheduleCallback = Scheduler.unstable_scheduleCallback;
    ...

scheduleCallback方法里面执行了非常多的调度逻辑,最后的返回值就是新的回调任务task,下一小节我们将深入其中的调度过程。

js 复制代码
    // 存储更新任务
    root.callbackPriority = newCallbackPriority;
    root.callbackNode = newCallbackNode;

最后将本次的回调任务存储到root应用根节点的callbackNode属性上,同步代码即基本执行完成,callback则会在新生成的宏任务中异步执行。这里我们省略了scheduleCallback方法中关于调度的内幕,下面我们就开始展开讲解。

2,深入scheduleCallback

本节将深入scheduleCallback方法内幕,这是调度器scheduler的核心工作逻辑。

js 复制代码
    newCallbackNode = scheduleCallback()

继续查看scheduleCallback方法:

js 复制代码
    // packages\react-reconciler\src\ReactFiberWorkLoop.new.js
    ​
    function scheduleCallback(priorityLevel, callback) {
       return Scheduler_scheduleCallback(priorityLevel, callback);
    }
js 复制代码
    // packages\react-reconciler\src\Scheduler.js
    ​
    import * as Scheduler from 'scheduler';
    export const scheduleCallback = Scheduler.unstable_scheduleCallback;

到这里我们可以发现,我们使用的scheduleCallback方法即是scheduler调度器的unstable_scheduleCallback方法:

从这里开始,我们将深入react源码中packages/scheduler包的源码:

js 复制代码
    // packages\scheduler\src\forks\Scheduler.js
    ​
    // 调度函数:并发模式下调度一个回调函数 【这里传入的callback就是performConcurrentWorkOnRoot】
    function unstable_scheduleCallback(priorityLevel, callback, options) {
      // 获取当前程序执行时间
      var currentTime = getCurrentTime();
    ​
      # 定义开始时间
      var startTime;
      // 一般没有传递options参数【暂没有发现这种场景】
      if (typeof options === 'object' && options !== null) {
        var delay = options.delay;
        if (typeof delay === 'number' && delay > 0) {
          // 如果存在延期,则开始时间 = 当前时间 + 延期时间
          startTime = currentTime + delay;
        } else {
          // 否则,开始时间 = 当前时间
          startTime = currentTime;
        }
      } else {
        // 开始时间直接等于currentTime
        startTime = currentTime;
      }
    ​
      # 定义超时时间 【根据优先级,设置不同的超时时间】
      var timeout;
      switch (priorityLevel) {
        case ImmediatePriority:
          timeout = IMMEDIATE_PRIORITY_TIMEOUT;
          break;
        case UserBlockingPriority:
          timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
          break;
        case IdlePriority:
          timeout = IDLE_PRIORITY_TIMEOUT;
          break;
        case LowPriority:
          timeout = LOW_PRIORITY_TIMEOUT;
          break;
        case NormalPriority:
        default:
          timeout = NORMAL_PRIORITY_TIMEOUT; // 默认5ms
          break;
      }
    ​
      // 到期时间,如果task已经到期/过期,则在执行的过程中不会被打断,必须同步执行完成
      # expirationTime值越小的,优先级越高【说明到期时间比较快,需要优先执行】
      var expirationTime = startTime + timeout;
    ​
      # 创建新的任务
      var newTask = {
        id: taskIdCounter++, // 任务id
        callback, // 回调任务
        priorityLevel, // 优先级 3
        startTime, // 开始时间
        expirationTime, // 到期时间
        sortIndex: -1, // 排序索引,越小的排在队列前面
      };
    ​
      // 开始时间 大于当前时间:说明是延期任务,先加入到延时队列timerQueue
      if (startTime > currentTime) {
        // This is a delayed task.
        newTask.sortIndex = startTime;
        # 1,延时任务:加入延时队列
        push(timerQueue, newTask);
        if (peek(taskQueue) === null && newTask === peek(timerQueue)) {
          // All tasks are delayed, and this is the task with the earliest delay.
          if (isHostTimeoutScheduled) {
            // Cancel an existing timeout.
            cancelHostTimeout();
          } else {
            isHostTimeoutScheduled = true;
          }
          // Schedule a timeout.
          requestHostTimeout(handleTimeout, startTime - currentTime);
        }
      } else {
    ​
        # 2,正常的任务:直接加入到任务队列taskQueue
        // 设置任务索引为:到期时间,时间越小,则排在taskQueue前面,越先执行
        newTask.sortIndex = expirationTime;
        // 添加到taskQueue队列
        push(taskQueue, newTask);
    ​
        // 判断host主机回调任务是否已经被调度,以及是否正在工作中
        // 如果host主机回调任务还没有被调度 且 当前并未在工作中;则需要开启一个host主机回调任务
        // 【首次加载时,需要调度一个主机回调任务】
        if (!isHostCallbackScheduled && !isPerformingWork) {
          isHostCallbackScheduled = true;
          # 设置host主机回调任务,触发MessageChannel 生成新的宏任务,在宏任务中执行工作循环workLoop
          requestHostCallback(flushWork);
        }
      }
    ​
      // 返回新的任务
      return newTask;
    }

unstable_scheduleCallback方法源码看似很多,实际主要工作就是创建新的回调任务newTask,最后返回newTask

js 复制代码
    var newTask = {
      id: taskIdCounter++, // 任务id
      callback, // 回调任务
      priorityLevel, // 优先级 3
      startTime, // 开始时间
      expirationTime, // 到期时间
      sortIndex: -1, // 排序索引,越小的排在队列前面
    };
    ...
    ​
    return newTask;

这里需要对newTask的几个重要属性,进行展开说明:

一,callback属性,它存储的就是我们之前传入的performConcurrentWorkOnRoot函数。

二,priorityLevel属性,也就是unstable_scheduleCallback方法传入的priorityLevel优先级参数,存储到任务对象中,代表本次任务的优先级,方便后续使用。

三,startTime属性:任务的开始时间。

js 复制代码
    // 获取当前程序执行时间
    var currentTime = getCurrentTime();
    ​
    // 定义开始时间
    var startTime;
    // 一般没有传递options参数
    if (typeof options === 'object' && options !== null) {
      var delay = options.delay;
      if (typeof delay === 'number' && delay > 0) {
        // 如果存在延期,则开始时间 = 当前时间 + 延期时间
        startTime = currentTime + delay;
      } else {
        // 否则,开始时间 = 当前时间
        startTime = currentTime;
      }
    } else {
      // 开始时间直接等于currentTime
      startTime = currentTime;
    }

这个属性是在unstable_scheduleCallback方法头部设置的,如果没有传递第三个参数options,就不会存在延时delay,则代表本次的任务为普通的任务task,任务开始时间为当前时间。

ini 复制代码
startTime = currentTime;

如果传递了delay参数,则开始时间等于当前时间加上延时时间【还没确定什么场景会存在延时参数】。

ini 复制代码
startTime = currentTime + delay;

四,expirationTime属性:任务到期时间。

js 复制代码
    // 定义超时时间 【根据优先级,设置不同的超时时间】
    var timeout;
    switch (priorityLevel) {
      case ImmediatePriority:
        timeout = IMMEDIATE_PRIORITY_TIMEOUT;
        break;
      case UserBlockingPriority:
        timeout = USER_BLOCKING_PRIORITY_TIMEOUT;
        break;
      case IdlePriority:
        timeout = IDLE_PRIORITY_TIMEOUT;
        break;
      case LowPriority:
        timeout = LOW_PRIORITY_TIMEOUT;
        break;
      case NormalPriority:
      default:
        timeout = NORMAL_PRIORITY_TIMEOUT; // 默认5ms
        break;
    }
    ​
    // 到期时间,如果task已经到期/过期,则在执行的过程中不会被打断,要同步执行完成
    // expirationTime值越小的,优先级越高【说明到期时间比较快,需要优先执行】
    var expirationTime = startTime + timeout;

expirationTime到期时间等于开始时间加上超时时间timeout,而timeout与调度优先级priorityLevel有关,以当前初始加载流程的优先级,会匹配默认的超时时间5msexpirationTime值越小的,任务优先级越高【说明到期时间比较快,需要优先执行】。

再回到unstable_scheduleCallback方法内,在创建完newTask后,需要将它添加到指定的队列之中。

js 复制代码
    if (startTime > currentTime) {
        // 加入延时任务队列
        push(timerQueue, newTask);
        ...
    } else {
        // 加入普通任务队列
        push(taskQueue, newTask);
        ...
    }

这里的判断条件就是任务的开始时间startTime是否大于当前时间currentTime

  • 大于则加入延时队列timerQueue
  • 否则加入普通队列taskQueue

当前为react应用初始加载任务,没有配置延时delay,所以会进入普通的任务队列。

下面我们继续查看普通任务的处理逻辑:

js 复制代码
    // 设置任务索引为:到期时间。 即时间越小,则排在taskQueue前面,越先执行
    newTask.sortIndex = expirationTime;
    // 加入到任务队列taskQueue
    push(taskQueue, newTask);
    ​
    // 判断host主机回调任务是否已经被调度,以及是否正在工作中
    // 只有host主机回调任务还没有被调度 且 当前并未在工作中;才会开启一个新的host主机回调任务
    // 【首次加载时,需要调度一个主机回调任务】
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      # 设置host主机回调任务,触发MessageChannel 生成新的宏任务,在宏任务中执行工作循环workLoop
      requestHostCallback(flushWork);
    }

newTask.sortIndex属性被设置为到期时间expirationTime,说明时间越小,则到期越早,则排在taskQueue前面,优先执行。

然后根据条件来决定是否发起一个host主机回调任务,如果当前host主机回调任务还未被调度 且 当前并未在工作中,则需要开启调度,然后发起一个新的host主机回调任务。【这里要注意传入的回调函数为flushWork方法】

requestHostCallback

下面我们继续查看requestHostCallback方法:

js 复制代码
    function requestHostCallback(callback) {
      // 接收当前的回调任务 flushWork
      scheduledHostCallback = callback;
      # 判断消息循环是否在运行中,如何没有则开启运行
      if (!isMessageLoopRunning) {
        isMessageLoopRunning = true;
        // 调度异步执行,创建新的宏任务
        schedulePerformWorkUntilDeadline();
      }
    }

设置全局变量scheduledHostCallback为传入的callback回调函数【flushWork】,然后判断消息循环是否在运行中,如果没有运行,则开启运行,然后调用schedulePerformWorkUntilDeadline方法,生成一个新的宏任务,异步执行callback

MessageChannel

这里要重点关注schedulePerformWorkUntilDeadline方法的定义:

js 复制代码
    # 根据环境设置调度函数, 生成宏任务
    let schedulePerformWorkUntilDeadline;
    if (typeof localSetImmediate === 'function') {
      // Node.js and old IE. 使用setImmediate
      schedulePerformWorkUntilDeadline = () => {
        localSetImmediate(performWorkUntilDeadline);
      };
    } else if (typeof MessageChannel !== 'undefined') {
      // 浏览器dom环境
      const channel = new MessageChannel(); // 宏任务
      const port = channel.port2;
      // port1的消息回调事件
      channel.port1.onmessage = performWorkUntilDeadline;
      // port2触发消息事件
      schedulePerformWorkUntilDeadline = () => {
        port.postMessage(null);
      };
    } else {
      // 其他环境 使用setTimeout
      schedulePerformWorkUntilDeadline = () => {
        localSetTimeout(performWorkUntilDeadline, 0);
      };
    }

根据上面的代码可以知晓:在浏览器dom环境,react的scheduler调度执行采用的是MessageChannel生成的宏任务。

1,首先我们要知道为什么react的调度器要使用宏任务而不使用微任务?

这里就必须要提及宏任务与微任务的关系了:我们都知道执行完一个宏任务之后,都需要去冲刷一次微任务队列,而微任务的特性就是在执行微任务过程中新创建的微任务还会继续添加到微任务队列。而react时间切片功能就是将一个复杂任务分割成多个切片任务,即会生成多个调度任务,最常见的就是创建FiberTree的过程。所以如果react将调度生成的任务task使用微任务去执行,则会导致所有的任务都在微任务中执行,即一直冲刷微任务队列,长时间占用主线程,那就会失去调度的意义。所以只能分割成宏任务,降低优先级,这样才能释放主线程,让渲染的任务有机会优先执行,然后有剩余时间再继续执行其他的切片任务。

时间切片:为了避免一个复杂任务task长时间的占用主线程,React团队提出了 Fiber 架构和 Scheduler 任务调度。Scheduler 的主要功能就是时间分片,将一个复杂任务分割成多个切片任务,每隔一段时间让出主线程,避免长时间的占用主线程,让优先级更高的渲染任务有机会优先执行【典型的化整为零的思想,少量多批次的执行任务】。

再回到到源码:

js 复制代码
    port2.postMessage(null);

这里使用port2生成了一个新的宏任务,port1在收到消息后就会触发它的onmessage监听事件【performWorkUntilDeadline】。

2,宏任务的创建为什么要使用MessageChannel,而不是其他方法?

  • requestIdleCallback:这是一个实验性的API,会在每帧的空闲时期执行,它的缺点是兼容性较差,执行频率不稳定。
  • requestAnimationFrame:该API定义的回调函数会在浏览器下次绘制前执行,一般用于更新动画。由于RAF的的执行取决于每一帧绘制前的时期,即它的执行效率与帧相关,执行效率并不高,所以react也没有选它。
  • setImmediate:在node环境,react使用setImmediate调度宏任务,因为它不同于MessageChannel,它不会阻止nodejs的进程退出,而且相比MessageChannel,执行时机更早。
  • MessageChannel:在支持MessageChannel的浏览器环境,react使用MessageChannel调度宏任务。该API会创建一个新的消息通道,并通过它的两个messagePort属性发送数据,接收消息的回调事件onmessage会在新的宏任务中执行。
  • setTimeout:最后的降级情况是使用setTimeout,因为setTimeout的回调执行在嵌套情况下有最小间隔时间4ms,所以如果使用setTimeout调度宏任务,必然会有被浪费的时间,所以setTimeout最后降级的选择。

3,performWorkUntilDeadline

进入到performWorkUntilDeadline方法:

js 复制代码
    // 执行工作直到最后时间【在有效时间内执行工作】
    const performWorkUntilDeadline = () => {
      // 前面设置了scheduledHostCallback = flushWork ,所以这里有值
      if (scheduledHostCallback !== null) {
        const currentTime = getCurrentTime();
        startTime = currentTime;
        // 默认还存在剩余时间,状态为true
        const hasTimeRemaining = true;
        // 设置默认存在工作
        let hasMoreWork = true;
        try {
          # 执行回调任务flushWork(),根据返回判断是否还有工作
          // 这里调用结束会返回布尔值表示是否还有工作
          hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
        } finally {
          if (hasMoreWork) {
            # 如果还有任务,则又触发MessageChannel事件,生成新的宏任务,即在下一个消息事件继续执行任务
            schedulePerformWorkUntilDeadline();
          } else {
            // 没有工作则设置工作循环状态为false ,停止状态
            isMessageLoopRunning = false;
            // 清空
            scheduledHostCallback = null;
          }
        }
      } else {
        // 没有scheduledHostCallback, 工作循环就不会开启
        isMessageLoopRunning = false;
      }
      needsPaint = false;
    };
js 复制代码
    channel.port1.onmessage = performWorkUntilDeadline;

通过前面,我们已经知道onmessage事件绑定的回调函数就是performWorkUntilDeadline方法,这里的调试截图也可以印证。

此时已经是在一个新的宏任务中开始执行performWorkUntilDeadline函数,下面展开它的执行过程:

js 复制代码
    scheduledHostCallback = = flushWork

这里的scheduledHostCallback为之前设置的flushWork方法,所以这里是有值的。进入performWorkUntilDeadline方法内部,主要是一个执行flushWork方法的try catch结构【这里没有使用catch,使用了finally】:

js 复制代码
    try {
        hasMoreWork = flushWork();
    } finally {
        if (hasMoreWork) {
            schedulePerformWorkUntilDeadline();
        } else {
            // 停止循环
        }
    }

调用flushWork方法,内部会循环执行任务。调用结束后会返回一个结果存储到变量hasMoreWork中,当进入到finally结构之后,如果hasMoreWork为真,即还存在工作,则调用schedulePerformWorkUntilDeadline方法,继续开启下一个宏任务来处理剩下的工作。

下面我们深入flushWork方法,查看任务执行的具体过程。

flushWork
js 复制代码
    // packages\react-reconciler\src\Scheduler.js
    ​
    // 冲刷回调工作
    function flushWork(hasTimeRemaining, initialTime) {
    ​
      # 设置isHostCallbackScheduled为false,
      # 意思是:下次执行任务时,需要重新调度一个host主机回调任务。其实就是保证每次更新需要调度一个Host主机回调任务
      isHostCallbackScheduled = false;
    ​
      // 设置执行状态为:正在执行中
      isPerformingWork = true;
      // 取出当前的task调度优先级
      const previousPriorityLevel = currentPriorityLevel;
      try {
        # 开始执行工作循环
        return workLoop(hasTimeRemaining, initialTime);
      } finally {
        // 清除当前任务
        currentTask = null;
        currentPriorityLevel = previousPriorityLevel;
        // 设置执行状态为:停止
        isPerformingWork = false;
      }
    }

flushWork的主要内容就是执行workLoop函数,进入task任务的循环处理,最后会返回执行结果给上一级:

js 复制代码
    hasMoreWork = flushWork()

执行完成后,即会清除当前任务currentTask,停止执行状态。

workLoop

下面继续查看workLoop

js 复制代码
    // packages\react-reconciler\src\Scheduler.js
    ​
    // 工作循环【可中断的循环过程】
    function workLoop(hasTimeRemaining, initialTime) {
      let currentTime = initialTime;
      // 将到期的task任务,从timerQueue取出,添加到taskQueue
      advanceTimers(currentTime);
      # 从任务队列中取出队列第一个任务【注意:taskQueue中是按任务的到期时间expirationTime排序的,越小越先执行】
      currentTask = peek(taskQueue);
    ​
      // 循环从taskQueue中取出任务
      while (currentTask !== null) {
        /**
         * 【重点判断】
         * 1,如果当前任务到期时间 大于 当前时间,说明任务还未过期
         * 2,hasTimeRemaining为false即没有剩余时间了 或者 shouldYieldToHost为true应该暂停
         * 总结:如果同时满足这两个条件,即任务还没过期,但是没有剩余可执行时间了,就应该跳出本次工作循环,
         * 让出主线程,交给渲染流水线,等待下一个宏任务执行task
         */
        if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost()) ) {
          // This currentTask hasn't expired, and we've reached the deadline.
          break;
        }
    ​
        # 说明:任务到期或者还有剩余执行时间,都可以执行任务
        // 取出当前任务的callback回调函数
        const callback = currentTask.callback;
        if (typeof callback === 'function') {
          currentTask.callback = null;
          // 取出当前任务的优先级
          currentPriorityLevel = currentTask.priorityLevel;
          // 判断任务是否已经过期【已经过期的任务,需要同步执行完成,无法中断】
          const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
          # 调用callback(),开始执行回调 【这里的callback就是异步并发更新的执行方法】
          const continuationCallback = callback(didUserCallbackTimeout);
          currentTime = getCurrentTime();
          if (typeof continuationCallback === 'function') {
            // 说明任务还未完成,将任务继续设置未当前任务的callback,等待下次继续执行
            // 这里没有删除这个任务,则下次取出的第一个任务,还是这个任务,
            currentTask.callback = continuationCallback;
          } else {
            // 说明任务已经执行完成
            if (currentTask === peek(taskQueue)) {
              // 从任务队列中删除已执行的任务
              pop(taskQueue);
            }
          }
          // 继续将到期的task任务,从timerQueue取出,添加到taskQueue
          advanceTimers(currentTime);
        } else {
          pop(taskQueue);
        }
        // 取出新的任务
        currentTask = peek(taskQueue);
      }
    ​
      // Return whether there's additional work
      // 结束本次工作循环时,根据当前任务判断还有没有任务还需要执行
      // 如果是通过break跳出的循环,可以在下次执行workLoop时,如果存在更高优先级的任务,则可以优先执行
      if (currentTask !== null) {
        // 还有工作,则会生成一个新的宏任务,在下次的宏任务中继续执行剩下的任务
        return true;
      } else {
        const firstTimer = peek(timerQueue);
        if (firstTimer !== null) {
          requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
        }
        // 本次宏任务执行结束,返回false,结束workLoop
        return false;
      }
    }

workLoop方法非常重要,它的核心内容是一个while循环,在循环体中从taskQueue队列中取出当前任务currentTask【优先级最高】,并执行currentTask中的callback回调函数。

下面我们详细解析workLoop方法的执行过程:

js 复制代码
    advanceTimers(currentTime);
    currentTask = peek(taskQueue);

首先调用advanceTimers方法,它的参数为currentTime,这个方法的作用是:将timerQueue延时队列中到期的任务取出,添加到taskQueue,然后从taskQueue队列中取出优先级最高的任务,设置为currentTask

taskQueue队列根据task任务的sortIndex【expirationTime】属性排序,sortIndex越小即到期时间越早,优先级越高。

然后查看while循环执行的条件:

js 复制代码
    while (currentTask !== null) {}

只要currentTask任务有值,即还存在任务,就需要继续执行循环。

下面进入循环体内部,循环体里面内容可以分为两个部分:

js 复制代码
    while (currentTask !== null) {
        
        // 1,执行前的条件判断
        if (currentTask.expirationTime > currentTime && (!hasTimeRemaining || shouldYieldToHost()) ) {
          break;
        }
        
        ...
        // 2,执行任务
    }

while循环体中,每次执行任务前,都需要经过一个条件判断,这个条件判断非常重要:

js 复制代码
    currentTask.expirationTime > currentTime &&
    !hasTimeRemaining || shouldYieldToHost()

条件一: 如果当前任务到期时间 大于 当前时间,说明任务还未过期。

条件二: hasTimeRemainingfalse即没有剩余可执行时间 或者 shouldYieldToHosttrue应该暂停执行。

总结: 如果同时满足这两个条件【即任务还没过期,但是没有剩余可执行时间了或者应该暂停执行】,则会触发break关键字,跳出本次工作循环,结束本次的宏任务执行,让出主线程【释放对主线程的占用】,让浏览器可以执行优先级更高的任务,暂停的任务则需要等待下一个宏任务再执行。

这里我们可以关注一下shouldYieldToHost方法,这个方法会返回一个布尔值状态:

js 复制代码
    function shouldYieldToHost() {
      // 当前程序运行时间
      const timeElapsed = getCurrentTime() - startTime;
      // 如果运行时间小于帧间隔时间5ms
      if (timeElapsed < frameInterval) {
        // 主线程只被阻塞了很短的时间;小于单个帧,则可以继续执行任务
        return false;
      }
        
      // 如果执行已经大于5ms,结束执行,让出主线程
      return true;
    }
  • 如果当前程序运行时间小于帧间隔时间frameInterval【默认5ms】,则返回false,不需要让出主线程,可以继续执行任务。
  • 如果运行时间已经大于5ms,则返回true,跳出任务循环,结束本次宏任务的执行,让出主线程。

如果没有满足上面的条件,则说明当前还有剩余可执行时间或者任务已经到期,需要立即执行任务。则进入到下面任务的具体执行过程:

js 复制代码
    while (currentTask !== null) {
       
      ...
      // 2,执行任务
      const callback = currentTask.callback;
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      const continuationCallback = callback(didUserCallbackTimeout);
      if (typeof continuationCallback === 'function') {
        currentTask.callback = continuationCallback;
      }
    }

取出任务中的callback回调函数,然后定义一个变量didUserCallbackTimeout存储当前任务的到期状态 ,最后调用callback回调函数,在执行完成后,根据返回值判断:

  • 如果continuationCallback为函数,则说明任务还没有执行完成,将未完成的continuationCallback回调任务重新赋值给当前任务的callback属性。下次循环判断时会跳出循环,暂停本次任务的执行。
  • 如果continuationCallback为null,代表当前任务执行完成,则取出下一个任务,继续循环处理。

以当前react应用初始加载的任务为例,它的callback属性对应的内容为performConcurrentWorkOnRoot方法,所以我们还得继续深入这个方法查看回调任务的执行。

调度流程图

这里补充绘制的调度流程图,可以进行参考:

4,performConcurrentWorkOnRoot

查看performConcurrentWorkOnRoot方法源码:

js 复制代码
    // packages\react-reconciler\src\ReactFiberWorkLoop.new.js
    ​
    function performConcurrentWorkOnRoot(root, didTimeout) {
    ​
      # 从root中取出回调任务callbackNode = newTask
      const originalCallbackNode = root.callbackNode;
      /**
       * 冲刷副作用,可能很重要?
       * 被动影响通常指的是那些不会直接触发组件重新渲染的操作,但是可能会改变组件的状态或者产生其他副作用。
       * 例如,事件处理程序、订阅函数或者定时器回调等都可以产生被动影响
       */
      const didFlushPassiveEffects = flushPassiveEffects();
      // false
      if (didFlushPassiveEffects) {
        if (root.callbackNode !== originalCallbackNode) {
          return null;
        } else {
          // Current task was not canceled. Continue.
        }
      }
    ​
      // 确定更新的lanes
      let lanes = getNextLanes(
        root,
        root === workInProgressRoot ? workInProgressRootRenderLanes : NoLanes,
      );
    ​
      # 时间切片处理: 是否应该开启时间切片【只有下面三种条件同时满足时,才会开启时间切片,不然render阶段就会走同步执行】
      const shouldTimeSlice =
        // 1,不包含阻塞的lane,
        !includesBlockingLane(root, lanes) &&
        // 2,不包含过期的lane
        !includesExpiredLane(root, lanes) &&
        // 3,调度的回调函数未过期
        (disableSchedulerTimeoutInWorkLoop || !didTimeout);
    ​
      #  定义一个status变量,来保存渲染状态结果
      # 【重点】在这里会执行很多内容,生成最终的Fiber树 返回渲染状态 5
      // 在没有开启时间切片的情况下,会使用同步渲染生成Fiber树
      let exitStatus = shouldTimeSlice ? renderRootConcurrent(root, lanes) : renderRootSync(root, lanes);
    ​
      // 根据渲染状态 继续执行逻辑 
      if (exitStatus !== RootInProgress) {
        // 1,如果渲染出错,则会尝试重新渲染【这次会以同步渲染模式】,如果还是失败,将放弃提交生成的FiberTree
        if (exitStatus === RootErrored) {
            ...
        }
            
        if (exitStatus === RootDidNotComplete) {
          # 2,如果渲染未完成,这只会在并发渲染模式中出现,即开启了时间切片。这种情况需要退出当前渲染,而不会生成FiberTreee和提交            commit。让出主线程,让浏览器执行优先级更高的任务
          // 为啥下次继续执行时能够知道上一次的节点,其实是因为每个执行reconciler流程的Fiber节点,都存储在全局变量workInProgress中,下次恢复执行时的节点,就是上次结束的节点。
          markRootSuspended(root, lanes);
        } else {
            
          // The render completed.
          # 3,渲染阶段完成,Fiber树创建完成
    ​
          // 是否为并发模式渲染: 这里为false
          const renderWasConcurrent = !includesBlockingLane(root, lanes);
          // 取出当前已完成的 hostFiber根节点,即一个Fiber树
          const finishedWork: Fiber = root.current.alternate;
    ​
          # Fiber树已经完成,进入commit阶段
          root.finishedWork = finishedWork;
          root.finishedLanes = lanes;
          # render阶段完成
          finishConcurrentRender(root, exitStatus, lanes);
        }
      }
      // 最终会返回null,表示任务执行结束
      return null;
    }

根据上面的源码就可以看出performConcurrentWorkOnRoot方法要处理的逻辑非常多,这里我们主要看它的重点逻辑处理。

首先查看shouldTimeSlice,这就是react时间切片的功能,开启时间切片功能需要满足以下几个条件:

  • 不包含阻塞的lane。
  • 不包含过期的lane。
  • 调度的回调函数没有过期。

只有同时满足这几个条件,才能开启时间切片功能: 即调用renderRootConcurrent方法,使用并发渲染创建FiberTree

js 复制代码
    let exitStatus = shouldTimeSlice ? renderRootConcurrent(root, lanes) : renderRootSync(root, lanes);

如果不满足时间切片的条件,就会调用renderRootSync方法,使用同步渲染创建FiberTree

renderRootXXX方法的作用是创建FiberTree【虚拟DOM树】,也是Fiber Reconciler流程。创建FiberTree的过程非常复杂,也非常重要,组件的创建和调用,组件优化等逻辑都在其中执行,我们将在《React18.2x源码解析(三)render阶段之reconciler协调流程》章节中展开讲解。

当前为react应用的初始加载,它不满足时间切片的条件,就会执行同步渲染renderRootSync方法,来创建FiberTree

在创建完成之后,会返回一个渲染状态exitStatus,根据这个状态的值,会进入不同的逻辑处理。

这里我们首先得了解react源码中定义的几个渲染状态:

js 复制代码
    type RootExitStatus = 0 | 1 | 2 | 3 | 4 | 5 | 6;
    const RootInProgress = 0; // 正在渲染中
    const RootFatalErrored = 1; // 渲染异常
    const RootErrored = 2; // 渲染异常
    const RootSuspended = 3; // 挂起
    const RootSuspendedWithDelay = 4;
    const RootCompleted = 5; // 默认:Fiber树创建完成
    const RootDidNotComplete = 6; // 未完成

这里同步创建FiberTree完成后,exitStatus的值为5,不等于RootInProgress,进入条件语句内部继续执行。

js 复制代码
    if (exitStatus !== RootInProgress) {
    	...
    }

在进入条件内部之后,继续针对exitStatus渲染状态进行判断,这里可以主要分为三种情况:

情况一: 如果渲染出现异常,则会尝试重新渲染【默认会以同步渲染模式】,如果还是失败则放弃提交生成的FiberTree,抛出异常。

情况二: 如果渲染状态为未完成,这种情况只会在并发渲染模式 中出现【即开启了时间切片】。遇见这种情况需要退出当前渲染,而不会继续生成FiberTree和提交commit。需要让出主线程,让浏览器可以执行优先级更高的任务,FiberTree的后续创建则需要等待下一个宏任务再执行。

情况三: 渲染完成,即FiberTree已经创建完成。

将创建完成的FiberTree取出,存储到应用根节点root上,方法后续的dom渲染工作。

js 复制代码
    // 取出workInProgress
    const finishedWork = root.current.alternate;
    // 挂载到root根节点对象之上
    root.finishedWork = finishedWork;
    # render阶段完成
    finishConcurrentRender();

最后调用一个finishConcurrentRender方法,标识着render阶段的完成。

finishConcurrentRender

最后我们再来查看一下finishConcurrentRender方法:

js 复制代码
    // packages\react-reconciler\src\ReactFiberWorkLoop.new.js
    ​
    # render阶段完成:下一步将渲染工作commit提交到renderer渲染器
    function finishConcurrentRender(root, exitStatus, lanes) {
      switch (exitStatus) {
        case RootInProgress:
        case RootFatalErrored: {
          throw new Error('Root did not complete. This is a bug in React.');
        }
              
        ...
        
        # 提交到renderer渲染器,开始真实的DOM渲染
        case RootCompleted: {
          // The work completed. Ready to commit.
          commitRoot(
            root,
            workInProgressRootRecoverableErrors,
            workInProgressTransitions,
          );
          break;
        }
        default: {
          throw new Error('Unknown root exit status.');
        }
      }
    }

当前为渲染完成状态,会匹配到RootCompleted,内部就是调用了一个commitRoot方法。到这里render阶段即为完成,下一步进入commitRoot方法,开始commit阶段的程序执行。

结束语

下一章节我们会先展开讲解FiberTree的具体创建过程,关于commit阶段的内容会在第四章讲解。

相关推荐
旧林84326 分钟前
第八章 利用CSS制作导航菜单
前端·css
yngsqq38 分钟前
c#使用高版本8.0步骤
java·前端·c#
Myli_ing1 小时前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风1 小时前
前端 vue 如何区分开发环境
前端·javascript·vue.js
PandaCave1 小时前
vue工程运行、构建、引用环境参数学习记录
javascript·vue.js·学习
软件小伟1 小时前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾2 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧2 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm2 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7012 小时前
第8章利用CSS制作导航菜单
前端·css