React Fiber 架构原理之2 —— 自底向上盘一盘 Scheduler

1,852 阅读14分钟

接上篇 React Fiber 架构原理:关于 Fiber 树的一切 - 知乎

这篇讨论 Fiber 架构调度部分的实现原理,你将看到:

  • Fiber 架构的调度能力的分层设计。
  • Scheduler 的分片原理,以及调度器如何基于浏览器能力实现“空闲回调”和“时间管理”。
  • Scheduler 中的任务是怎样注册管理、派发执行、定义优先级的。
  • React 如何利用 Scheduler 实现“可中断更新”。

Part 0 背景

Fiber 架构是 React 一次伟大的革新。

React Fiber 是 React 核心算法的重新实现。 它的主要特点是渐进式渲染: 能够将渲染工作分割成块,并将其分散到多个帧。 其他关键特性包括在新的更新到来时暂停、中止或重用工作的能力; 为不同类型的更新分配优先级的能力; 以及新的并发方式。 ——GitHub - acdlite/react-fiber-architecture: A description of React’s new core algorithm, React Fiber

为了实现上述特性,Fiber 架构加入了关键的 Scheduler(调度器)。有了 Scheduler 这个“大脑”,React 在 setState 后不再直接启动“协调”过程,而是把本次更新注册到 Scheduler,再由 Scheduler 根据浏览器剩余空闲时间、优先级等因素派发给 Reconciler(协调器),并通过中断查询控制协调的中断重启。(协调就是我们说的包含 Diffing 的虚拟 DOM 构建计算过程,参考上篇)

编辑切换为全宽

添加图片注释,不超过 140 字(可选)

所以这样的 Scheduler 要支持哪些能力呢?

  1. 要能维护一个“任务池”
  2. 要提供一系列“优先级”的定义,并派发高优任务
  3. 要能感知浏览器的空闲,并根据剩余时间,随时给出“能不能继续工作”的建议

而 Reconciler 也要通过对 Scheduler 能力的调用,管理协调过程的发起、暂停、终止。

Part 1 调度的分层实现和调用链路

为了进一步看清 Scheduler 在 React 中的角色,这里有一张图解释整个调度的分层实现和调用链路。

编辑切换为居中

添加图片注释,不超过 140 字(可选)

React 源码是分包组织的,packages 下面有若干个相对独立的包,react-reconciler、scheduler 就是其中两个,包下面是具体文件模块,这里列举了四个关键的。

  • 当我们调用 setState 之类的 api,组件会把状态入队后调 ReactFiberScheduler 注册本次修改。
  • ReactFiberScheduler,主要做 Fiber 调度、协调管理这些事。比如注册调度并提供回调函数发起协调、管理当前协调的节点。这一层并不直接依赖 Scheduler 的 API(可能觉得不优雅?),而是 react-reconciler 内部的一个封装模块。
  • SchedulerWithReactIntegration,就是那个封装模块,直接调 Scheduler,并把API改了改名字透传出来。
  • Scheduler,是调度器最核心的实现,实现了Part0 中说的第1、2点能力。这里做了优先级定义、任务池维护/注册/取消、任务调度执行、中断判断。这些能力又依赖对宿主时间片的判断,什么时候空闲、剩多少时间。在早些版本的 Fiber 中宿主时间片也做在 Scheduler,后来拆出去了。
  • SchedulerHostConfig,实现了宿主时间片部分,也就是 Part0 的第 3 点能力。

接下来我们按依赖顺序,自底向上,看看各层具体的实现方式。

Part 2 SchedulerHostConfig 宿主时间片判断

SchedulerHostConfig 要基于宿主(这里只谈浏览器)API,实现时间片管理。它要回答两个问题:

  1. 浏览器什么时候有空?空了叫我
  2. 此时此刻,我要不要让出线程给浏览器?

为什么要回答这些问题,怎么回答这些问题,就要从浏览器机制说起。

单线程JS 和浏览器帧

众所周知,浏览器里 JS 是单线程的。不但 JS 执行本身单线程,而且 JS 执行引擎和浏览器渲染引擎都挤在单个线程里。

好在大部分情况下,JS 执行、浏览器渲染都足够快,所以浏览器只要在单位时间内交替执行 JS 引擎和渲染引擎就好。这个单位时间叫做“帧(frame)”,目前主流的是60fps,每秒60轮,快到用户肉眼根本看不出来交替执行。一帧的生命周期如下:

编辑切换为居中

添加图片注释,不超过 140 字(可选)

\

但不排除某一帧下,触发了一个巨复杂的 js 逻辑,把当前帧事件耗完了还没跑完,甚至跨了几个帧都没跑完。那浏览器就拿他没办法,必须等他跑完才能做渲染,这样用户就发现:“哎,刚刚有段时间页面卡住不动了”。巧的是,一个很庞大的虚拟DOM树的 Diffing 就可能是这种卡帧的逻辑,所以必须在需要的时候“暂时”退出来,让浏览器先把这帧的渲染跑了,回头再继续跑。

再回到大多数情况。当 js 不那么复杂时,这一帧的 js 和渲染跑完后,是有剩余时间的。这时候浏览器就会通过某种方式通知出来。让我们知道:“浏览器现在有空闲了”。

浏览器的空闲回调

这时候大名鼎鼎的 requestIdleCallback 出场了。

window.requestIdleCallback()方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应。 —— requestIdleCallback - Web API 接口参考 | MDN

看起来完美对不对,但它的兼容性堪忧:

编辑切换为居中

添加图片注释,不超过 140 字(可选)

\

所以 React 找了个替代品 —— MessageChannel。

Channel Messaging API 的MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据。 —— MessageChannel - Web API 接口参考 | MDN

这是个 Full Support 的 API。

编辑切换为居中

添加图片注释,不超过 140 字(可选)

\

原理很简单。自己给自己发消息,相当于把球踢出去,等闲了再接回来。

基于 MessageChannel 的空闲回调实现

SchedulerHostConfig 实现了两个方法:

export let requestHostCallback; // 注册空闲回调
export let cancelHostCallback;  // 取消空闲回调

首先全局定义了一个“回调池”,一个消息通道:

let scheduledHostCallback = null;
const channel = new MessageChannel();
const port = channel.port2;

那球怎么踢出去呢?把回调挂到“回调池”上,再发个空消息通知接球就好:

requestHostCallback = function(callback, absoluteTimeout) {
  scheduledHostCallback = callback;
  port.postMessage(undefined);
};

空闲的时候,消息通道的另一端去“回调池”捞出来执行,并传入当前回调是否已过期(didTimeout):

channel.port1.onmessage = function(event) {
  const prevScheduledCallback = scheduledHostCallback;
  if (prevTimeoutTime <= currentTime) {
    didTimeout = true;
  }
  if (prevScheduledCallback !== null) {
    prevScheduledCallback(didTimeout);
  }
};

取消回调也很简单,清空“回调池”就好:

cancelHostCallback = function() {
  scheduledHostCallback = null;
};

这里只列出了最关键的利用 MessageChannel 做回调的方式,实际情况要比这复杂得多:

  • 可能当前帧已经不够执行回调,就需要挪到下一帧。为此上述代码引入了 requestAnimationFrame API,并声明了几个变量标记当前回调函数的执行方式。
  • 考虑到时间,就要考虑“等不起”的回调,也就是满足 requestHostCallback 第二个参数 absoluteTimeout 的配置。及时计算回调的剩余时间(比如你挪到下一帧了就要减去浪费的时间),然后在拖无可拖的时候强制执行。所以又需要几个变量标记当前回调的时间信息。

总之都是围绕 MessageChannel 的,比如我们把 requestHostCallback 再丰富下:

requestHostCallback = function(callback, absoluteTimeout) {
  scheduledHostCallback = callback;
  if (isFlushingHostCallback || absoluteTimeout < 0) {
    port.postMessage(undefined);
  } else if (!isAnimationFrameScheduled) {
    isAnimationFrameScheduled = true;
    requestAnimationFrameWithTimeout(animationTick);
  }
};
const animationTick = function(rafTime) {
  if (!isMessageEventScheduled) {
    isMessageEventScheduled = true;
    port.postMessage(undefined);
  }
};

在经过 requestAnimationFrame 调度后,还是回到 postMessage。

时间计算和 shouldYieldToHost

前面提到的时间变量还有个重要作用,就是标记帧结束时间,这对 shouldYieldToHost(是否让路宿主)至关重要。

let frameDeadline = 0;
let previousFrameTime = 33;
let activeFrameTime = 33;
const animationTick = function(rafTime) {
    let nextFrameTime = rafTime - frameDeadline + activeFrameTime;
    if ( nextFrameTime < activeFrameTime && previousFrameTime < activeFrameTime) {
    activeFrameTime = nextFrameTime < previousFrameTime ? previousFrameTime : nextFrameTime;
  }
  frameDeadline = rafTime + activeFrameTime;
    // postMessage ...
};

这里的 frameDeadline 是一个具体的时间戳,表示当前帧的结束时间;activeFrameTime 是一个时间段,表示当前帧的长度,默认是 33(30fps)。

每次执行 animationTick,都会更新 frameDeadline 为 rafTime + activeFrameTime,其中 rafTime 就是 requestAnimationFrame 传入的,加上帧长,正好是下一帧的结束时间。同时 animationTick 也会根据两次 requestAnimationFrame 调用时间差,计算真实的帧长,修正 activeFrameTime。

到此为止,React 能相对准确获取到当前帧的结束时间戳。那判断 shouldYieldToHost 就顺理成章了:如果当前时间超过帧结束时间,说明已经卡到帧了,需要让出。

shouldYieldToHost = function() {
  return frameDeadline <= getCurrentTime();
};
getCurrentTime = function() {
  return Date.now();
};

小结

SchedulerHostConfig 实现了两个关键方法:

  • requestHostCallback:注册一个在帧间空闲时间执行的回调函数(及其过期时间),并可以通过 cancelHostCallback 取消它。
  • shouldYieldToHost:随时判断是否需要让出线程(避免卡帧)

Part 3 Scheduler 调度实现

SchedulerHostConfig 提供了空闲时回调和是否让出查询的能力,在 Scheduler 这一层,要做的事如下:

  1. 维护一个任务池
  2. 定义、应用优先级决定任务池的调用顺序
  3. 派发任务(调 requestHostCallback)
  4. 及时中断(在 shouldYieldToHost 时终止派发)

优先级定义

优先级对任务池结构、任务派发顺序至关重要,所以先从优先级说起,Scheduler 定义了五种优先级,以及它们对应的过期时间:

var ImmediatePriority = 1;
var UserBlockingPriority = 2;
var NormalPriority = 3;
var LowPriority = 4;
var IdlePriority = 5;

var maxSigned31BitInt = 1073741823;

var IMMEDIATE_PRIORITY_TIMEOUT = -1;
var USER_BLOCKING_PRIORITY = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
var IDLE_PRIORITY = maxSigned31BitInt;

其中优先级定义会作为变量导出,而过期时间为内部计算任务派发顺序的参考。

任务池数据结构

接下来看任务池。Scheduler 任务池数据结构如下:

编辑切换为全宽

添加图片注释,不超过 140 字(可选)

  • 每个任务都是一个节点,里面维护着三条信息:回调函数、优先级、过期时间
  • 和 Fiber 树、effectList 等实现一样,任务池节点间也是通过链表(双向循环链表)组织的,next 指向下一个任务,previous 指向上一个任务
  • 外界访问任务池的唯一入口,是一个全局指针 firstCallbackNode,指向任务池中第一个任务节点。无论任务注册时向任务池添加,还是派发任务时从任务池取,都要走这个指针。
  • 链表从头到尾是按过期时间排序的,这个后面细说。

scheduleCallback:任务注册

我们打开 unstable_scheduleCallback 实现,这个函数传入一个回调函数和优先级,返回对应的任务节点。

任务注册过程也是根据新任务优先级插入任务池的过程:计算过期时间 —> 构造节点对象 —> 插入任务池。先看前两步:

var startTime = getCurrentTime();
var expirationTime;
switch (priorityLevel) {
  case ImmediatePriority:
    expirationTime = startTime + IMMEDIATE_PRIORITY_TIMEOUT;
    break;
  case UserBlockingPriority:
    expirationTime = startTime + USER_BLOCKING_PRIORITY;
    break;
  case IdlePriority:
    expirationTime = startTime + IDLE_PRIORITY;
    break;
  case LowPriority:
    expirationTime = startTime + LOW_PRIORITY_TIMEOUT;
    break;
  case NormalPriority:
  default:
    expirationTime = startTime + NORMAL_PRIORITY_TIMEOUT;
}
var newNode = {
  callback,
  priorityLevel: priorityLevel,
  expirationTime,
  next: null,
  previous: null,
};

比较简单,根据优先级在当前时间上加一个预设时间作为过期时间。然后构造任务节点。

接下来是把任务节点插到任务池里,这里是个链表的操作:

if (firstCallbackNode === null) {
  firstCallbackNode = newNode.next = newNode.previous = newNode;
  scheduleHostCallbackIfNeeded();
} else {
  var next = null;
  var node = firstCallbackNode;
  do {
    if (node.expirationTime > expirationTime) {
      next = node;
      break;
    }
    node = node.next;
  } while (node !== firstCallbackNode);

  if (next === null) {
    next = firstCallbackNode;
  } else if (next === firstCallbackNode) {
    firstCallbackNode = newNode;
    scheduleHostCallbackIfNeeded();
  }

  var previous = next.previous;
  previous.next = next.previous = newNode;
  newNode.next = next;
  newNode.previous = previous;
}

如果任务池空,当前任务作为 firstCallbackNode,注意如果只有一个节点,它的 next、previous 都是自己,这也是双向链表的一部分。

如果任务池已有内容,则要计算下插入位置。前面说任务池是按过期时间排序的,所以从链表头出发,一直 do while 找到一个过期时间比自己长的作为 next 就好。如下图,假设绿色是新任务,节点上的数字表示过期时间(仅示意,实际是时间戳)。

编辑切换为居中

添加图片注释,不超过 140 字(可选)

\

但无论插入的是不是空任务池,都要执行一句 scheduleHostCallbackIfNeeded,看意思是调 Host 回调执行任务。但“if need”什么意思?具体又是怎么执行的呢?

scheduleHostCallbackIfNeeded:任务池跑起来

显然,scheduleHostCallbackIfNeeded 尝试在浏览器空闲的时候执行任务池里的任务。

那每次浏览器空闲就取出一个任务执行吗?显然不够高效。我们想做到的是“每次浏览器空闲执行尽可能多的任务”。

看代码(有简化):

function scheduleHostCallbackIfNeeded() {
  if (firstCallbackNode !== null) {
    requestHostCallback(flushWork, firstCallbackNode.expirationTime);
  }
}

function flushWork(didUserCallbackTimeout) {
  try {
    if (didUserCallbackTimeout) {
      do {
        flushFirstCallback();
      } while (firstCallbackNode !== null && firstCallbackNode.expirationTime <= currentTime);
    } else {
      do {
        flushFirstCallback();
      } while (firstCallbackNode !== null && !shouldYieldToHost());
    }
  } finally {
    scheduleHostCallbackIfNeeded();
  }
}

按照 “每次浏览器空闲执行尽可能多的任务”,跑任务池的方式不是往 host callback 里塞一个最优先任务,而是一个 “尽可能多跑任务” 的 flushWork 函数。那 “尽可能多” 的终点是哪呢?

  • 如果头里的任务已过期,说明至少有一个任务过期了,就要一直刷到不过期的任务,顾不得浏览器卡帧了。
  • 如果头里的任务没过期,那就保证浏览器不卡帧(shouldYieldToHost)的前提下尽量多跑

当一次 flushWork 跑完,finally 还是主动去 scheduleHostCallbackIfNeeded 看看有没有别的活可以干。

但来来回回我们发现,scheduleHostCallbackIfNeeded 调的很随意,或者说在任意想要查看有没有任务的时候都会调。这就需要 scheduleHostCallbackIfNeeded 自己控制要不要执行,也就是“锁”。我们再看下这俩函数的执行时序:

编辑切换为居中

添加图片注释,不超过 140 字(可选)

\

scheduleHostCallbackIfNeeded 把任务通过 requestHostCallback 注册到宿主,等宿主回调了才能到 flushWork。这个过程是一个不知道多久的异步过程,而且任务池和宿主回调都是单例(参考上一 Part)。所以当我们调 scheduleHostCallbackIfNeeded 的时候有几种可能:

  1. 没有已经注册但未回调的 flushWork,也没有正在执行的 flushWork:这是理想情况,不会造成任何资源冲突。
  2. 有已经注册但未回调的 flushWork:这时候我很可能往任务池加了任务才调的 scheduleHostCallbackIfNeeded,所以已发起的那次 flushWork 回调是过期的,就要取消掉重新注册最新回调。
  3. 上次注册的 flushWork 已经回调,正在执行:这已经尽到“同步”逻辑里了,不能取消了,但要避免重复发起。

所以需要两个锁,来锁 “是否有注册但未回调的 flushWork”、“是否正在 flushWork”。具体实现:

var isPerformingWork = false;
var isHostCallbackScheduled = false;

function scheduleHostCallbackIfNeeded() {
  if (isPerformingWork) {
    return;
  }
  if (isHostCallbackScheduled) {
    cancelHostCallback();
  } else {
    isHostCallbackScheduled = true;
  }
  // requestHostCallback(flushWork, expirationTime);
}

function flushWork(didUserCallbackTimeout) {
  isHostCallbackScheduled = false;
  isPerformingWork = true;
  try {
    // 尽可能多的执行任务池
  } finally {
    isPerformingWork = false;
    // scheduleHostCallbackIfNeeded();
  }
}

编辑切换为居中

添加图片注释,不超过 140 字(可选)

\

小结

小结一下 Scheduler 的调度实现:

  • Scheduler 做了个单例的任务池,双向循环链表结构,通过一个链表指针存取。
  • Scheduler 定义了五级优先级,对应的是过期时间,作为任务执行顺序的依据。
  • Scheduler 实现了 scheduleCallback,就是把传入的回调函数添加到任务池链表里。
  • 添加任务的时候会尝试执行任务池,把 flushWork 注册到宿主回调,这是一个“尽可能多跑任务”的方法。这里会用两个锁避免资源混乱。

到此为止,Scheduler 的实现就算完事了,核心提供了几个接口:

XXXPriority: number;    // 五级优先级定义
scheduleCallback: (priorityLevel: number, callback) => CallbackNode;  // 按优先级调度回调方法
cancelCallback: (callbackNode: CallbackNode) => void;  // 取消调度
shouldYield: () => boolean;  // 随时查询是否要暂停任务交还控制权的函数

Part 4 React 对 Scheduler 的应用

前面我们已经看到了一个非常完善的调度器,React Fiber 正是在这个基建上实现的。

setState 后发生了什么?

我们看下 setState 后的主要函数调用:

编辑切换为居中

添加图片注释,不超过 140 字(可选)

\

setState 会创建 Update 对象,Update 对象加入当前 fiber 节点的 updateQueue(也是个链表),这部分不展开。

然后调用 scheduleWork 发起调度,从这里开始进入 ReactFiberScheduler 模块范围,并最终走到 workLoop。另一篇讲 Fiber 树构建的文章里( React Fiber 架构原理:关于 Fiber 树的一切 - 知乎),正是从 workLoop 开始的,这就算接上了。

发起 scheduleCallback

scheduleWork 传入的是当前发起 setState 的 fiber 节点和过期时间。

scheduleWork(fiber, expirationTime);

在 ReactFiberScheduler 中 scheduleWork 定义为 scheduleUpdateOnFiber 函数。这个函数无非做了两件事:1、向上找到根节点;2、把根节点发起调度。(这也说明 React 一个重要特点,每次 Diffing 都是整棵树的 Diffing)。关键代码如下:

export const scheduleWork = scheduleUpdateOnFiber;
export function scheduleUpdateOnFiber( fiber: Fiber, expirationTime: ExpirationTime ) {
  const root = markUpdateTimeFromFiberToRoot(fiber, expirationTime);
  scheduleCallbackForRoot(root, priorityLevel, expirationTime);
}
function scheduleCallbackForRoot( root, priorityLevel, expirationTime) {
    root.callbackExpirationTime = expirationTime;
  const existingCallbackNode = root.callbackNode;
  if (existingCallbackNode !== null) {
    cancelCallback(existingCallbackNode);
  }
  root.callbackNode = scheduleCallback( priorityLevel,
    runRootCallback.bind( null, root,
      renderRoot.bind(null, root, expirationTime),
    ),
  );
}

markUpdateTimeFromFiberToRoot 是一个沿着当前 fiber 节点的 return 向上获取根节点的方法。此外还会一路更新节点过期时间。根节点、优先级、过期时间会被拿来调用 scheduleCallbackForRoot(为根节点发起调度)。

在 scheduleCallbackForRoot 中,root 被绑上了 callbackNode 和 callbackExpirationTime,保证当前只有一个 scheduleCallback 在跑,如果调度之前 root 上有别的 callbackNode,会先被取消掉再创建新的。

再接下去,就接上了调度的基建「scheduleCallback」。但是这个入参有点意思,连用两个 bind,不过既然 bind 的 this 都是 null,其实我们可以改写得更通俗点,像这样:

root.callbackNode = scheduleCallback(
  priorityLevel,
  () => runRootCallback(root, () => renderRoot(root, expirationTime)),
);

字面意思,scheduleCallback 回调后会先执行 runRootCallback,再在里面回调 renderRoot。

runRootCallback:“断点续跑” 的精髓

打开 runRootCallback 看看,这里隐藏着 fiber “断点续跑”的精髓。

function runRootCallback(root, callback) {
  const prevCallbackNode = root.callbackNode;
  let continuation = null;
  try {
    continuation = callback();
    if (continuation !== null) {
      return runRootCallback.bind(null, root, continuation);
    } else {
      return null;
    }
  } finally {
    if (continuation === null && prevCallbackNode === root.callbackNode) {
      root.callbackNode = null;
      root.callbackExpirationTime = NoWork;
    }
  }
}
  • callback 进来的是 renderRoot(root, expirationTime)。
  • 如果 renderRoot 没跑完就退出了,会返回一个可继续跑的 continuation,这个 continuation 会抛回给 scheduleCallback 下次继续跑。
  • 如果 renderRoot 跑完了退出,就返回null,重置 root 的 callbackNode。

那么什么情况下 renderRoot 会返回 continuation?又会返回什么样的 continuation 呢?这就要从 Fiber 的更新流程说起。

renderRoot Fiber 的更新流程

先来看这张经典的图:(出自:React lifecycle methods diagram

编辑切换为居中

添加图片注释,不超过 140 字(可选)

\

Fiber 的更新分为两个阶段:render 阶段和 commit 阶段。render 阶段可中断,就是我们说的 Diffing 过程,详细内容参考 React Fiber 架构原理:关于 Fiber 树的一切 - 知乎;commit 阶段不可中断。

打开 renderRoot,我们发现它有很多分支,16个 return,但总的来说只有三种方法:renderRoot、commitRoot、null。很容易对应上面两个阶段:返回 renderRoot 表示 render 阶段被中断,要继续 render;返回 commitRoot 表示 render 阶段走完,但还没 commit;返回 null 表示本次更新已完成。

因此进入 renderRoot 也可能有两种场景:1、完全从新发起 render 阶段;2、上次 render 被中断,这次进来要继续 render。

renderRoot 是一个传入 root、expirationTime 的函数。作为辅助,模块顶层还给了 workInProgress 、workInProgressRoot 指针,既可以和别的方法共享指针,也可以保证 render 阶段被中断重启后能恢复现场,还可以保证“全局只有一个在跑的任务”:

// The fiber we're working on
let workInProgress: Fiber | null = null;
// The root we're working on
let workInProgressRoot: FiberRoot | null = null;
// The expiration time we're rendering
let renderExpirationTime: ExpirationTime = NoWork;

function renderRoot( root, expirationTime ): SchedulerCallback | null;

从新发起 render

如何判断这次 renderRoot 是从新发起的呢?我们有全局的 workInProgressRoot、renderExpirationTime 变量,记着上次 renderRoot 的 root 和 renderExpirationTime,如果这两个任意一个变了,就说明不是上次的任务了。

这时候就要把指针都更新为新任务的指针,是通过 prepareFreshStack 方法实现的:

if (root !== workInProgressRoot || expirationTime !== renderExpirationTime) {
  prepareFreshStack(root, expirationTime);
}
function prepareFreshStack(root, expirationTime) {
  workInProgressRoot = root;
  workInProgress = createWorkInProgress(root.current, null, expirationTime);
}

此时 workInProgress 由 createWorkInProgress 创建返回,是新 workInProgress 树的根节点。

在React中最多会同时存在两棵Fiber树: 当前屏幕上显示内容对应的Fiber树称为 current Fiber 树 正在构建的Fiber树称为 workInProgress Fiber 树,我们这里讨论的所有遍历都在这棵树上 当一次协调发起,首先会开一棵新 workInProgress Fiber 树,然后从根节点开始构建并遍历 workInProgress Fiber 树。 如果构建到一半被打断,current 树还在。如果构建并提交完成,直接把 current 树丢掉,让 workInProgress Fiber 树成为新的 current 树。 —— React Fiber 架构原理:关于 Fiber 树的一切 - 知乎

render 阶段的完成或中断

无论从新还是”断点续跑”,到这里我们都会从 workInProgress 发起 workLoop(代码省了一大堆,看关键行):

if (workInProgress !== null) {
  workLoop();
}

workLoop 可能跑完,把 workInProgress 置 null。也可能根据 shouldYield 中断,让出资源给宿主,这样 workInProgress 就停在了某个节点上。

function workLoop() {
  while (workInProgress !== null && !shouldYield()) {
    workInProgress = performUnitOfWork(workInProgress);
  }
}

如果是后者,就可以直接返回 renderRoot 套娃了:

if (workInProgress !== null) {
  return renderRoot.bind(null, root, expirationTime);
}

如果是前者,会先判断一下 workInProgressRootExitStatus,这个状态在 workLoop 中更新,非异常情况下会走到 RootCompleted,然后返回 commitRoot:

switch (workInProgressRootExitStatus) {
  // ...
  case RootCompleted: {
    // The work completed. Ready to commit.
    return commitRoot.bind(null, root, expirationTime);
  }
}

到 commitRoot,Fiber 基于调度器的异步部分走完,进到同步部分。

小结

  • setState 会创建更新对象并插入 fiber 节点队列,然后发起对这个 fiber 的更新。但随后会沿着 fiber 找到根节点,变成对整棵树的更新
  • 树上带着更新信息,请求 scheduleCallback 回调,接上调度器的接口
  • 回调后的 renderRoot 反映了整个更新流程,包括可中断的 render 阶段和不可中断的 commit 阶段,render 阶段的中断是 workLoop 查询 shouldYield 接口发起的
  • 为了实现“断点续跑”,未完成的工作会以函数形式返回,下一轮回调继续。而断点信息保存在像 workInProgress 这样的模块全局变量中

Part Z 总结

这篇我们自底向上盘清楚了 Fiber 架构中,Scheduler 的主要实现和应用思路,总结如下:

  • 调度能力分为四层实现:对接浏览器的 SchedulerHostConfig、实现核心任务管理的 Scheduler、抹平接口的 SchedulerWithReactIntegration、应用 Scheduler 的 ReactFiberScheduler
  • 为了避免卡住渲染线程,SchedulerHostConfig 通过 MessageChannel 利用浏览器的帧间空闲时间,实现空闲回调和时间计算,成为 Fiber 调度的基础
  • Scheduler 基于空闲回调做了完整的任务调度方案。定义了五级优先级;通过双向循环链表实现任务池,并将任务池的增删包装成任务注册/取消;并在浏览器空闲时“尽可能多跑任务”。
  • React 中对类似 setState 的调用会通过 Scheduler 调度整棵树的更新过程,并在 render 阶段随时查 Scheduler 中断任务。中断的现场保留在模块全局变量中,并在下一轮回调中重启。

React Fiber 原理系列

  1. React Fiber 架构原理:关于 Fiber 树的一切
  2. React Fiber 架构原理:自底向上盘一盘 Scheduler