38

postMessage & Scheduler

写在前面

  • 本文包含了一定量的源码讲解,其中笔者写入了一些内容来替代官方注释(就是写了差不多等于没写那种),若读者更青睐于原始的代码,

可移步官方仓库,结合起来阅读。也正因为这个原因,横屏或 PC 的阅读体验也许会更佳(代码可能需要左右滑动)

  • 本文没有显式的涉及 React Fiber Reconciler 和 Algebraic Effects(代数效应)的内容,但其实它们是息息相关的,可以理解为本文的内容就是实现前两者的基石。

有兴趣的读者可移步《Fiber & Algebraic Effects》做一些前置阅读。

开始

在去年 2019 年 9 月 27 日的 release 中,React 在 Scheduler 中开启了新的调度任务方案试验:

  • 旧方案:通过 requestAnimationFrame(以下统称 cAF,相关的 requestIdleCallback 则简称 rIC)使任务调度与帧对齐
  • 新方案:通过高频(短间隔)的调用 postMessage 来调度任务

Emm x1... 突然有了好多问题
那么本文就来探索一下,在这次“小小的” release 中都发生了什么

契机

通过对这次 release 的 commit-message 的查看,我们总结出以下几点:

  1. 由于 rAF 仰仗显示器的刷新频率,因此使用 rAF 需要看 vsync cycle(指硬件设备的频率)的脸色
  2. 那么为了在每帧执行尽可能多的任务,采用了 5ms 间隔的消息事件 来发起调度,也就是 postMessage 的方式
  3. 这个方案的主要风险是:更加频繁的调度任务会加剧主线程与其他浏览器任务的资源争夺
  4. 相较于 rAF 和 setTimeout,浏览器在后台标签下对消息事件进行了什么程度的节流还需要进一步确定,该试验是假设它与定时器有相同的优先级

简单来说,就是放弃了由 rAF 和 rIC 两个 API 构成的帧对齐策略,转而人为的控制调度频率,提升任务处理速度,优化 React 运行时的性能

postMessage


那么,postMessage 又是什么呢?是指 iframe 通信机制中的 postMessage 吗?

不对,也对

Emm x2... 好吧,有点谜语了,那解谜吧

不对

说不对呢,是因为 postMessage 本身是使用的 MessageChannel 这个接口创建的对象发起的

Channel Message API 的 MessageChannel 接口允许我们创建一个新的消息通道,并通过该通道的两个 MessagePort 进行通信

这个通道同样适用于 Web Worker —— 所以,它挺有用的...
我们看看它到底是怎样通信的:

const ch = new MessageChannel()

ch.port1.onmessage = function(msgEvent) {
  console.log('port1 got ' + msgEvent.data)
  ch.port1.postMessage('Ok, r.i.p Floyd')
}

ch.port2.onmessage = function(msgEvent) {
  console.log(msgEvent.data)
}

ch.port2.postMessage('port2!')

// 输出:
// port1 got port2!
// Ok, r.i.p Floyd.

很简单,没什么特别的...
Emm x3...
啊... 平常很少直接用它,它的兼容性怎么样呢?
image.png


唔!尽管是 10,但 IE 竟然也可以全绿!

也对

害,兼容性这么好,其实就是因为现代浏览器中 iframe 与父文档之间的通信,就是使用的这个消息通道,你甚至可以:

// 假设 <iframe id="childFrame" src="XXX" />

const ch = new MessageChannel()
const childFrame = document.querySelector('#childFrame')

ch.port2.onmessage = function(msgEvent) {
  console.log(msgEvent.data)
  console.log('There\'s no father exists ever')
}

childFrame.contentWindow.postMessage('Father I can\'t breathe!', '*', [ch.port2])

// 输出:
// Father I can't breathe
// There's no father exists ever

好了,我们已经知道这个 postMessage 是个什么东西了,那接着看看它是怎么运作的吧

做事

在谈到 postMessage 的运作方式之前,先提一下 Scheduler

Scheduler

Scheduler 是 React 团队开发的一个用于事务调度的包,内置于 React 项目中。其团队的愿景是孵化完成后,使这个包独立于 React,成为一个能有更广泛使用的工具
我们接下来要探索的相关内容,都是在这个包的范畴之内

找到 MessageChannel

在 Scheduler 的源码中,通过搜索 postMessage 字眼,我们很容易的就将目光定位到了 SchedulerHostConfig.default.js 文件,我们截取部分内容:

在完整源码中,有一个 if-else 分支来实现了两套不同的 API。对于非 DOM 或是没有 MessageChannel 的 JavaScript 环境(如 JavaScriptCore),以下内容是采用 setTimeout 实现的,有兴趣的同学可以去看一下,相当简单的一段 Hack,本文不作赘述,仅专注于 else 分支下的源码。
以上也是为什么这个文件会叫 xxxConfig 的原因,它确实是带有配置性的逻辑的
const performWorkUntilDeadline = () => {
    if (scheduledHostCallback !== null) {
      const currentTime = getCurrentTime();
      // Yield after `yieldInterval` ms, regardless of where we are in the vsync
      // cycle. This means there's always time remaining at the beginning of
      // the message event.
      deadline = currentTime + yieldInterval;
      const hasTimeRemaining = true;
      try {
        const hasMoreWork = scheduledHostCallback(
          hasTimeRemaining,
          currentTime,
        );
        if (!hasMoreWork) {
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        } else {
          // If there's more work, schedule the next message event at the end
          // of the preceding one.
          port.postMessage(null);
        }
      } catch (error) {
        // If a scheduler task throws, exit the current browser task so the
        // error can be observed.
        port.postMessage(null);
        throw error;
      }
    } else {
      isMessageLoopRunning = false;
    }
    // Yielding to the browser will give it a chance to paint, so we can
    // reset this.
    needsPaint = false;
  };

  const channel = new MessageChannel();
  const port = channel.port2;
  channel.port1.onmessage = performWorkUntilDeadline;

  requestHostCallback = function(callback) {
    scheduledHostCallback = callback;
    if (!isMessageLoopRunning) {
      isMessageLoopRunning = true;
      port.postMessage(null);
    }
  };

这行代码的逻辑其实很简单:

  1. 定义一个名为 channel 的 MessageChannel,并定义一个 port 变量指向其 port2 端口
  2. 将预先定义好的 performWorkUntilDeadline 方法作为 channel 的 port1 端口的消息事件处理函数
  3. 在 requestHostCallback 中调用前面定义的 port 变量 —— 也就是 channel 的 port2 端口 —— 上的 postMessage 方法发送消息
  4. performWorkUntilDeadline 方法开始运作

好了,我们现在就来剖析一下这一小段代码中的各个元素

requestHostCallback(以下简称 rHC)

还记得 rAF 和 rIC 吗?他们前任调度机制的核心 API,那么既然 rHC 和他们长这么像,一定就是现在值班那位咯
确实,我们直接进入代码身体内部尝尝:

requestHostCallback = function(callback) {
    // 将传入的 callback 赋值给 scheduledHostCallback
    // 类比 `requestAnimationFrame(() => { /* doSomething */ })` 这样的使用方法,
    // 我们可以推断 scheduledHostCallback 就是当前要执行的任务(scheduled嘛)
    scheduledHostCallback = callback;
  
      // isMessageLoopRunning 标志当前消息循环是否开启
    // 消息循环干嘛用的呢?就是不断的检查有没有新的消息——即新的任务——嘛
    if (!isMessageLoopRunning) {
      // 如果当前消息循环是关闭的,则 rHC 有权力打开它
      isMessageLoopRunning = true;
      // 打开以后,channel 的 port2 端口将受到消息,也就是开始 performWorkUntilDeadline 了
      port.postMessage(null);
    } // else 会发生什么?
  };

好了,我们现在知道,rHC 的作用就是:

  • 准备好当前要执行的任务(scheduledHostCallback)
  • 开启消息循环调度
  • 调用 performWorkUntilDeadline

performWorkUntilDeadline

现在看来,rHC 是搞事的,performWorkUntilDealine 就是做事的咯
确实,我们又直接进入代码身体内部尝尝:

const performWorkUntilDeadline = () => {
      // [A]:先检查当前的 scheduledHostCallback 是否存在
    // 换句话说就是当前有没有事需要做
    if (scheduledHostCallback !== null) {
      const currentTime = getCurrentTime();
      // 啊,截止时间!
      // 看来就是截止到 yieldInterval 之后,是多少呢?
      // 按前文的内容,应该是 5ms 吧,我们之后再验证
      deadline = currentTime + yieldInterval;
      // 唔,新鲜的截止时间,换句话说就是还有多少时间呗
      // 有了显示的剩余时间定义,无论我们处于 vsync cycle 的什么节点,在收到消息(任务)的时候都有时间了
      const hasTimeRemaining = true; // timeRemaining 这个字眼让人想起了 rIC
      try {
        // 嗯,看来这个 scheduledHostCallback 中不简单,稍后研究它
        const hasMoreWork = scheduledHostCallback(
          hasTimeRemaining,
          currentTime,
        );
        if (!hasMoreWork) {
            // 如果完成了最后一个任务,就关闭消息循环,并清洗掉 scheduledHostCallback 的引用
          isMessageLoopRunning = false;
          scheduledHostCallback = null;
        } else {
          // [C]:如果还有任务要做,就用 port 继续向 channel 的 port2 端口发消息
          // 显然,这是一个类似于递归的操作
          // 那么,如果没有任务了,显然不会走到这儿,为什么还要判断 scheduledHostCallback 呢?往后看
          port.postMessage(null);
        }
      } catch (error) {
        // 如果当前的任务执行除了故障,则进入下一个任务,并抛出错误
        port.postMessage(null);
        throw error;
      }
    } else {
      // [B]:没事儿做了,那么就不用循环的检查消息了呗
      isMessageLoopRunning = false;
    }
    // Yielding to the browser will give it a chance to paint, so we can
    // reset this.
    needsPaint = false;
  };

现在就明朗许多了,我们用一个示意图进行表示:
how_postMessage_work.png
两个虚线箭头表示引用关系,那么根据代码中的分析现在可以知道,所有的任务调度,都是由 port —— 也就是 channel 的 port2 端口 —— 通过调用 postMessage 方法发起的,而这个任务是否要被执行,似乎与 yieldInterval 和 hasTimeRemaning 有关,来看看它们:

  • yieldInterval: 在完整源码中,有这两么两处:
// 直接定义为 5ms,根本没商量的
const yieldInterval = 5

// 但是
// 这个方法其实是 Scheduler 包提供给开发者的公共 API,
// 允许开发者根据不同的设备刷新率设置调度间隔
// 其实就是因地制宜的考虑

forceFrameRate = function(fps) {
      // 最高到 125 fps
    // 我的(假装有)144hz 电竞屏有被冒犯到
    if (fps < 0 || fps > 125) {
      // Using console['error'] to evade Babel and ESLint
      console['error'](
        'forceFrameRate takes a positive int between 0 and 125, ' +
          'forcing framerates higher than 125 fps is not unsupported',
      );
      return;
    }
    if (fps > 0) {
      yieldInterval = Math.floor(1000 / fps);
    } else {
      // 显然,如果没传或者传了个负的,就重置为 5ms,提升了一些鲁棒性
      // reset the framerate
      yieldInterval = 5;
    }
  };
  • hasTimeRemaning:参考 rIC 通常的使用方式:
function doWorks() {
  // todo
}

function doMoreWorks() {
     // todo more 
}

function todo() {
      requestIdleCallback(() => {
      // 做事嘛,最重要的就是还有没有时间
           if (e.timeRemaining()) {
        doMoreWorks()
      }
   })
   doWorks()
}

Emm x4... 上图中还有两处标红的疑问:

  • what happened?: 其实这个地方呢,就是为 performWorkUntilDeadline 提供新的 scheduledHostCallback。这样一来,performWorkUntilDeadline 就“一直有事做”,直到不再有任务通过 rHC 注册进来
  • But How?: 接下来,我们就来解答这个问题的答案,一切都要从 Scheduler 说起

Scheduler

啊哈,这次我们给 Scheduler 了一个更大的标题来表明它的主角身份 🐶...
我们这次直接从入口开始,一步一步地回归到 But How? 这个问题上去

又写在前面

  • 根据 Scheduler 的 README 文件可知,其当前的 API 尚非最终方案,因此其入口文件 Scheduler.js 所暴露出来的接口都带上了 unstable_ 前缀,为使篇幅简单,以下对接口名称的描述都省去该前缀
  • 源码中还包含了一些 profiling 相关的逻辑,它们主要是用于辅助调试和审计,与运作方式没有太大的关系,因此下文会忽略这些内容,专注于核心逻辑的阐释

scheduleCallback —— 把任务交给 Scheduler

我们旅程的起点就从这个接口开始,它是开启 Scheduler 魔法的钥匙🔑~
该接口用于将一个回调函数——也就是我们要执行的任务——按给定的优先级额外设置注册进 Scheduler 的任务队列中,并启动任务调度:

function unstable_scheduleCallback(priorityLevel, callback, options) {
  var currentTime = getCurrentTime(); // [A]:getCurrentTime 是怎样获取当前时间的?

  var startTime; // 给定回调函数一个开始时间,并根据 options 中定义的 delay 来延迟
  // 给定回调函数一个定时器,并根据 options 中的 timeout 定义来确定是直接使用自定义的还是用 timeoutForPriorityLevel 方法来产出定时时间
  // [B]:那么 timeoutForPriorityLevel 是怎么做的呢?
  var timeout;
  if (typeof options === 'object' && options !== null) {
    var delay = options.delay;
    if (typeof delay === 'number' && delay > 0) {
      startTime = currentTime + delay;
    } else {
      startTime = currentTime;
    }
    timeout =
      typeof options.timeout === 'number'
        ? options.timeout
        : timeoutForPriorityLevel(priorityLevel); // [C] 这个 priorityLevel 哪来的?
  } else {
    timeout = timeoutForPriorityLevel(priorityLevel);
    startTime = currentTime;
  }
  
  // 定义一个过期时间,之后还会遇到它
  var expirationTime = startTime + timeout;

  // 啊,从这里我们可以看到,在 Scheduler 中一个 task 到底长什么样了
  var newTask = {
    id: taskIdCounter++, // Scheduler.js 中全局定义了一个 taskIdCounter 作为 taskId 的生产器
    callback,
    priorityLevel,
    startTime,
    expirationTime,
    sortIndex: -1,  // [D]:前面的都见过了,这个 sortIndex 是排序用的吗?
  };
  if (enableProfiling) {
    newTask.isQueued = false;
  }

  if (startTime > currentTime) {
    // 还记得 options 中的 delay 属性吗,这就给予了该任务开始时间大于当前时间的可能
    // 唔,前面定义 sortIndex 又出现了,在这种情况下被赋值为了 startTime,
    newTask.sortIndex = startTime;
    // [E]:这里出现了一个定时器队列(timerQueue)
    // 如果开始时间大于当前时间,就将它 push 进这个定时器队列
    // 显然,对于要将来执行的任务,势必得将它放在一个“待激活”的队列中
    push(timerQueue, newTask);
    // 这里的逻辑稍后讨论,先进入 else 分支
    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 {
    // expirationTime 作为了 sortIndex 的值,从逻辑上基本可以确认 sortIndex 就是用于排序了
    newTask.sortIndex = expirationTime;
    // [F]: 这里又出现了 push 方法,这次是将任务 push 进任务队列(taskQueue),看来定时器队列和任务队列是同构的咯?
    push(taskQueue, newTask);
    if (enableProfiling) {
      markTaskStart(newTask, currentTime);
      newTask.isQueued = true;
    }
    // 从逻辑上看,这里就是判断当前是否正处于流程,即 performWorkUntilDeadline 是否正处于一个递归的执行状态中中,如果不在的话,就开启这个调度
    // [G]:Emm x5... 那这个 flushWork 是干什么的呢?
    if (!isHostCallbackScheduled && !isPerformingWork) {
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    }
  }

  return newTask;
}

ok,我们现在来分解一下上述注释中标记了 [X] 的几个问题,使函数作用更加立体一点:

  • A: getCurrentTime 是如何获取当前时间的呢?

    • 解:在之前提到的 schedulerHostConfig.default.js 文件中,根据 performance 对象及 performance.now 方法是否存在,区分了是用 Date.now 还是用 performance.now 来获取当前时间,原因是后者比前者更加精确切绝对,详情可参考这里
  • B C: 我们直接来看看 Scheduler.js 中 timeoutForPriorityLevel 方法的相关内容便知:
// ...other code
var maxSigned31BitInt = 1073741823;

/**
 * 以下几个变量是全局定义的,相当于系统常量(环境变量)
 */
// 立即执行
// 显然,如果不定义 deley,根据 [B] 注释处紧接的逻辑,expirationTime 就等于 currentTime - 1 了
var IMMEDIATE_PRIORITY_TIMEOUT = -1;
// 再往后就一定会进入 else 分支,并 push 到任务队列立即进入 performWorkUntilDealine
var USER_BLOCKING_PRIORITY_TIMEOUT = 250;
var NORMAL_PRIORITY_TIMEOUT = 5000;
var LOW_PRIORITY_TIMEOUT = 10000;
// 最低的优先级看起来是永远不会被 timeout 到的,稍后看看它会在什么时候执行
var IDLE_PRIORITY_TIMEOUT = maxSigned31BitInt;

// ...other code

// 可以看到,priorityLevel 显然也是被系统常量化了的
function timeoutForPriorityLevel(priorityLevel) {
  switch (priorityLevel) {
    case ImmediatePriority:
      return IMMEDIATE_PRIORITY_TIMEOUT;
    case UserBlockingPriority:
      return USER_BLOCKING_PRIORITY_TIMEOUT;
    case IdlePriority:
      return IDLE_PRIORITY_TIMEOUT;
    case LowPriority:
      return LOW_PRIORITY_TIMEOUT;
    case NormalPriority:
    default:
      return NORMAL_PRIORITY_TIMEOUT;
  }
}

// ...other code

其中 priorityLevel 定义在 schedulerPriorities.js 中,非常直观:

export type PriorityLevel = 0 | 1 | 2 | 3 | 4 | 5;

// 啊哈,将来可能用 symbols 来实现,
// 那样的话,大小的对比是不是又得抽象一个规则出来呢?
// TODO: Use symbols?
export const NoPriority = 0;
export const ImmediatePriority = 1;
export const UserBlockingPriority = 2;
export const NormalPriority = 3;
export const LowPriority = 4;
export const IdlePriority = 5;

看来,任务执行的时机就是由 当前时间(currentTime)+延时(delay)+优先级定时(XXX_PRIORITY_TIMEOUT) 来决定,而定时时长的增量则由 shedulerPriorities.js 中的各个值来决定

  • C D E: 这三个点是非常相关的,因此直接放在一起

    • sortIndex: 即排序索引,根据前面的内容和 [B] 的阐释,我们可以知道,该属性的值要么是 startTime,要么是 expirationTime,显然都是越小越早嘛——因此,用这个值来排序,势必也就将任务的优先级排出来了
    • timerQueue 和 taskQueue:害,sortIndex 肯定是用于在这两个同构队列中排序了嘛。_看到这里,熟悉数据结构的同学应该已经猜到,这两个队列的数据结构可能就是处理优先级事务的标准方案——最小优先队列。_

果然,我们溯源到 push 方法是在一个叫 schedulerMinHeap.js 的文件中,而最小优先队列就是基于最小堆(min-heap)来实现的。我们待会儿看看 push 到底对这个队列做了什么。

  • F: flushWork!听这个名字就很通畅对不对这个名字已经很好的告诉了我们,它就是要将当前所有的任务一一处理掉!它是怎么做的呢?留个悬念,先跳出 scheduleCallback

最小堆

最小堆本质上是一棵完全二叉树,经排序后,其所有非终端节点的元素值都不大于其左节点和右节点,即如下:
min-heap.png

原理

Sheduler 采用了数组对这个最小堆进行实现,现在我们简单的来解析一下它的工作原理

PUSH

我们向上面这个最小堆中 push 进一个值为 5 的元素,其工作流程如下所示:
min-heap-push.png
可以看到,在 push 的过程中,调用 siftUp 方法将值为 5 的元素排到了我们想要的位置,成了右边这棵树。相关代码如下:

type Heap = Array<Node>;
type Node = {|
  id: number,
  sortIndex: number,
|};

export function push(heap: Heap, node: Node): void {
  const index = heap.length;
  heap.push(node);
  siftUp(heap, node, index);
}

function siftUp(heap, node, i) {
  let index = i;
  while (true) {
    const parentIndex = (index - 1) >>> 1;
    const parent = heap[parentIndex];
    if (parent !== undefined && compare(parent, node) > 0) {
      // The parent is larger. Swap positions.
      heap[parentIndex] = node;
      heap[index] = parent;
      index = parentIndex;
    } else {
      // The parent is smaller. Exit.
      return;
    }
  }
}

function compare(a, b) {
  // Compare sort index first, then task id.
  const diff = a.sortIndex - b.sortIndex;
  return diff !== 0 ? diff : a.id - b.id;
}

可以看到,siftUp 中对于父节点位置的计算还使用了移位操作符>>>1 等价于除以 2 再去尾)进行优化,以提升计算效率

POP

那么,我们要从其中取出一个元素来用(在 Scheduler 中即调度一个任务出来执行),工作流程如下所示:
min-heap-pop.png
当我们取出第一个元素——即值最小,优先级最高——后,树失去了顶端,势必需要重新组织其枝叶结构,而 siftDown 方法就是用于重新梳理剩余的元素,使其仍然保持为一个最小堆,相关代码如下:

export function pop(heap: Heap): Node | null {
  const first = heap[0];
  if (first !== undefined) {
    const last = heap.pop();
    if (last !== first) {
      heap[0] = last;
      siftDown(heap, last, 0);
    }
    return first;
  } else {
    return null;
  }
}

function siftDown(heap, node, i) {
  let index = i;
  const length = heap.length;
  while (index < length) {
    const leftIndex = (index + 1) * 2 - 1;
    const left = heap[leftIndex];
    const rightIndex = leftIndex + 1;
    const right = heap[rightIndex];

    // If the left or right node is smaller, swap with the smaller of those.
    if (left !== undefined && compare(left, node) < 0) {
      if (right !== undefined && compare(right, left) < 0) {
        heap[index] = right;
        heap[rightIndex] = node;
        index = rightIndex;
      } else {
        heap[index] = left;
        heap[leftIndex] = node;
        index = leftIndex;
      }
    } else if (right !== undefined && compare(right, node) < 0) {
      heap[index] = right;
      heap[rightIndex] = node;
      index = rightIndex;
    } else {
      // Neither child is smaller. Exit.
      return;
    }
  }
}

Emm x5... 和 PUSH 部分的代码合并一下,就是一个最小堆的标准实现了
剩下地,SchedulerMinHeap.js 源码中还提供了一个 peek(看一下) 方法,用于查看顶端元素:

export function peek(heap: Heap): Node | null {
  const first = heap[0];
  return first === undefined ? null : first;
}

其作用显然就是取第一个元素出来 peek peek 咯~ 我们马上就会遇到它

flushWork

现在,我们来看看 Scheduler 是如何将任务都 flush 掉的:

function flushWork(hasTimeRemaining, initialTime) {
  if (enableProfiling) {
    markSchedulerUnsuspended(initialTime);
  }

  // [A]:为什么要重置这些状态呢?
  isHostCallbackScheduled = false;
  if (isHostTimeoutScheduled) {
    // We scheduled a timeout but it's no longer needed. Cancel it.
    isHostTimeoutScheduled = false;
    cancelHostTimeout();
  }

  // [B]:从逻辑上看,在任务本身没有抛出错误的情况下,flushWork 就是返回 workLoop 的结果,那么 workLoop 做了些什么呢?
  isPerformingWork = true;
  const previousPriorityLevel = currentPriorityLevel;
  try {
    if (enableProfiling) {
      try {
        return workLoop(hasTimeRemaining, initialTime);
      } catch (error) {
        if (currentTask !== null) {
          const currentTime = getCurrentTime();
          markTaskErrored(currentTask, currentTime);
          currentTask.isQueued = false;
        }
        throw error;
      }
    } else {
      // 特意留下了这条官方注释,它告诉我们在生产环境下,flushWork 不会去 catch workLoop 中抛出的错误的,
           // 因为在开发模式下或调试过程中,这种错误一般会造成白页并给予开发者一个提示,显然这个功能不能影响到用户
      // No catch in prod codepath.
      return workLoop(hasTimeRemaining, initialTime);
    }
  } finally {
    // 如果任务执行出错,则终结当前的调度工作
    currentTask = null;
    currentPriorityLevel = previousPriorityLevel;
    isPerformingWork = false;
    if (enableProfiling) {
      const currentTime = getCurrentTime();
      markSchedulerSuspended(currentTime);
    }
  }
}

现在来分析一下这段代码中的 ABC~

  • A: 为什么要重置这些状态呢?

由于 rHC 并不一定立即执行传入的回调函数,所以 isHostCallbackScheduled 状态可能会维持一段时间;等到 flushWork 开始处理任务时,则需要释放该状态以支持其他的任务被 schedule 进来;isHostTimeoutScheduled 也是同样的道理,关于这是个什么 timeout,我们很快就会遇到

  • B: workLoop,Emm x6... 快要到这段旅程的终点了。就像连载小说的填坑一样,这个方法将会解答很多问题

workLoop

顾名思义,该方法一定会包含一个用于处理任务的循环,那么这个循环里都发生了什么呢?

function workLoop(hasTimeRemaining, initialTime) {
  let currentTime = initialTime;
  // [A]:这个方法是干嘛的?
  advanceTimers(currentTime);
  // 将任务队列最顶端的任务 peek 一下
  currentTask = peek(taskQueue);
  // 只要 currentTask 存在,这个 loop 就会继续下去
  while (
    currentTask !== null &&
    !(enableSchedulerDebugging && isSchedulerPaused)
  ) {
    if (
      currentTask.expirationTime > currentTime &&
      (!hasTimeRemaining || shouldYieldToHost())
    ) {
      // dealine 到了,但是当前任务尚未过期,因此让它在下次调度周期内再执行
      // [B]:shouldYieldToHost 是怎么做判断的呢?
      break;
    }
    const callback = currentTask.callback;
    if (callback !== null) {
      // callback 不为 null,则说明当前任务是可用的
      currentTask.callback = null;
      currentPriorityLevel = currentTask.priorityLevel;
      // 判断当前任务是否过期
      const didUserCallbackTimeout = currentTask.expirationTime <= currentTime;
      markTaskRun(currentTask, currentTime);
      // [C]:continuationCallback?这是什么意思?让任务继续执行?
      const continuationCallback = callback(didUserCallbackTimeout);
      currentTime = getCurrentTime();
      if (typeof continuationCallback === 'function') {
          // 看来,如果 continuationCallback 成立,则用它来取代当前的 callback
        currentTask.callback = continuationCallback;
        markTaskYield(currentTask, currentTime);
      } else {
        if (enableProfiling) {
          markTaskCompleted(currentTask, currentTime);
          currentTask.isQueued = false;
        }
        // 如果 continuationCallback 不成立,就会 pop 掉当前任务,
        // 逻辑上则应该是判定当前任务已经完成
        // Emm x7... 那么 schedule 进来的任务,实际上应该是要遵循这个规则的
        // [D]:我们待会儿再强调一下这个问题
        if (currentTask === peek(taskQueue)) {
          pop(taskQueue);
        }
      }
      // advanceTimers 又来了...
      advanceTimers(currentTime);
    } else {
      // 如果当前的任务已经不可用,则将它 pop 掉
      pop(taskQueue);
    }
    // 再次从 taskQueue 中 peek 一个任务出来
    // 注意,如果前面的 continuationCallback 成立,taskQueue 则不会发生 pop 行为,
    // 因此 peek 出的任务依然是当前的任务,只是 callback 已经是 continuationCallback 了
    currentTask = peek(taskQueue);
  }
  // Bingo!这不就是检查还有没有更多的任务吗?
  // 终于回归到 performWorkUntilDealine 中的 hasMoreWork 逻辑上了!
  if (currentTask !== null) {
    return true;
  } else {
    // [E]:诶,这儿好像不太单纯,干了点儿啥呢?
    const firstTimer = peek(timerQueue);
    if (firstTimer !== null) {
      requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
    }
    return false;
  }
}

我们终于解答了前面的 But How 问题
现在,我们解析一下上述代码中的 ABC,看看这个循环是怎么运作起来的

  • A:上述代码两次出现了 advanceTimers,它究竟是用来干嘛的呢?上代码一看便知:
function advanceTimers(currentTime) {
  // 其实下面的官方注解已经很明确了,就是把 timerQueue 中排队的任务根据需要转移到 taskQueue 中去
  // Check for tasks that are no longer delayed and add them to the queue.
  let timer = peek(timerQueue);
  while (timer !== null) {
    if (timer.callback === null) {
      // Timer was cancelled.
      pop(timerQueue);
    } else if (timer.startTime <= currentTime) {
      // Timer fired. Transfer to the task queue.
      pop(timerQueue);
      timer.sortIndex = timer.expirationTime;
      push(taskQueue, timer);
      if (enableProfiling) {
        markTaskStart(timer, currentTime);
        timer.isQueued = true;
      }
    } else {
      // Remaining timers are pending.
      return;
    }
    timer = peek(timerQueue);
  }
}

其实这段代码相当的简单,就是根据 startTimecurrentTime 来判断某个 timer 是否到了该执行的时间,然后将它转移到 taskQueue 中,大致可以总结为以下示意:
advanceTimers.png
因此,workLoop 中第一次调用它的作用就是将当前需要执行的任务重新梳理一下;
那么第二次调用则是由于 while 语句中的任务执行完后,已经消耗掉一定时间,再次进入 while 的时候当然也需要重新梳理 taskQueue 了

  • B:shouldYieldToHosthasTimeRemaning 一起判定了是否还有时间来执行任务,如果没有的话,break 出 while 循环,由此 保持了一个以 5ms 为周期的循环调度 ——啊,又解决一个疑问;其中 shouldYieldToHost 的源码有点儿料的,可以看看:
if (
    enableIsInputPending &&
    navigator !== undefined &&
    navigator.scheduling !== undefined &&
    navigator.scheduling.isInputPending !== undefined
  ) {
    const scheduling = navigator.scheduling;
    shouldYieldToHost = function() {
      const currentTime = getCurrentTime();
      if (currentTime >= deadline) {
        // There's no time left. We may want to yield control of the main
        // thread, so the browser can perform high priority tasks. The main ones
        // are painting and user input. If there's a pending paint or a pending
        // input, then we should yield. But if there's neither, then we can
        // yield less often while remaining responsive. We'll eventually yield
        // regardless, since there could be a pending paint that wasn't
        // accompanied by a call to `requestPaint`, or other main thread tasks
        // like network events.
        // 译:没空了。我们可能需要将主线程的控制权暂时交出去,因此浏览器能够执行高优先级的任务。
        // 所谓的高优先级的任务主要是”绘制“及”用户输入”。如果当前有执行中的绘制或者输入,那么
        // 我们就应该让出资源来让它们优先的执行;如果没有,我们则可以让出更少的资源来保持响应。
        // 但是,毕竟存在非 `requestPaint` 发起的绘制状态更新,及其他的主线程任务——如网络请求等事件,
        // 我们最终也会在某个临界点一定地让出资源来
        if (needsPaint || scheduling.isInputPending()) {
          // There is either a pending paint or a pending input.
          return true;
        }
        // There's no pending input. Only yield if we've reached the max
        // yield interval.
        return currentTime >= maxYieldInterval;
      } else {
        // There's still time left in the frame.
        return false;
      }
    };

    requestPaint = function() {
      needsPaint = true;
    };
  } else {
    // `isInputPending` is not available. Since we have no way of knowing if
    // there's pending input, always yield at the end of the frame.
    shouldYieldToHost = function() {
      return getCurrentTime() >= deadline;
    };

    // Since we yield every frame regardless, `requestPaint` has no effect.
    requestPaint = function() {};
  }

可以看到,对于支持 navigator.scheduling 属性的环境,React 有更进一步的考虑,也就是 浏览器绘制 和 用户输入 要优先进行,这其实就是 React 设计理念中的 Scheduling 部分所阐释的内涵
当然了,由于这个属性并非普遍支持,因此也 else 分支里的定义则是单纯的判断是否超过了 deadline
考虑到 API 的健壮性,requestPaint 也根据情况有了不同的定义

  • C: 我们仔细看看 continuationCallback 的赋值—— continuationCallback = callback(didUserCallbackTimeout) ,它将任务是否已经过期的状态传给了任务本身,如果该任务支持根据过期状态有不同的行为——例如在过期状态下,将当前的执行结果缓存起来,等到下次调度未过期的时候再复用缓存的结果继续执行后面的逻辑,那么则返回新的处理方式并赋值到 continuationCallback 上。这就是 React 中的 Fiber Reconciler 实现联系最紧密的地方了;而 callback 本身若并没有对过期状态进行处理,则返回的东西从逻辑上来讲,需要控制为非函数类型的值,也就是使得 typeof continuationCallback === 'function' 判断为假。也正因为 callback 不一定会对过期状态有特别待遇,所以它的执行时间可能会大大超出预料,就更需要在之后再执行一次 advanceTimers 了。
  • D: 前面说到了,我们传入的 callback 一定要遵循与 continuationCallback 相关逻辑一致的规则。由于 Scheduler 现在尚未正式的独立于 React 做推广,所以也没有相关文档来显式的做讲解,因此我们在直接使用 Scheduler 的时候一定要注意这点
  • E: 其实这里就是将 timer 中剩下的任务再进行一次梳理,我们看看 requestHostTimeouthandleTimeout 都做了什么就知道了:

现在,看 requestHostTimeout 个名字就知道他一定来自于 SchedulerHostConfig.default.js 这个文件🙂:

// 很简单,就是在下一轮浏览器 eventloop 的定时器阶段执行回调,如果传入了具体时间则另说  
requestHostTimeout = function(callback, ms) {
    taskTimeoutID = setTimeout(() => {
      callback(getCurrentTime());
    }, ms);
  };

// 相关的 cancel 方法则是直接 clear 掉定时器并重置 taskTimoutID
cancelHostTimeout = function() {
  clearTimeout(taskTimeoutID);
  taskTimeoutID = -1;
};

再看 handleTimeout,它的定义就在 Scheduler.js 中:

function handleTimeout(currentTime) {
  isHostTimeoutScheduled = false;
  // 这里再次重新梳理了 task
  advanceTimers(currentTime);

  // 如果这时候 isHostCallbackScheduled 再次被设为 true
  // 说明有新的任务注册了进来
  // 从逻辑上来看,这些任务将再次被滞后
  if (!isHostCallbackScheduled) {
    // flush 新进入 taskQueue 的任务
    if (peek(taskQueue) !== null) {
      // 如果本方法中的 advanceTimer 有对 taskQueue push 进任务
      // 则直接开始 flush 它们
      isHostCallbackScheduled = true;
      requestHostCallback(flushWork);
    } else {
      // 如果 taskQueue 仍然为空,就开始递归的调用该方法
      // 直到清理掉 timerQueue 中所有的任务
      // (我想,对于交互频繁的应用,这个递归应该不太会有停止的机会)
      const firstTimer = peek(timerQueue);
      if (firstTimer !== null) {
        // startTime - currentTime,不就是 XXX_PRIORITY_TIMEOUT 的值嘛!
        requestHostTimeout(handleTimeout, firstTimer.startTime - currentTime);
      }
    }
  }
}

可以概括为是 workLoop 的善后工作...
现在,我们可以总结出一个大致的 workLoop 示意图了:
workLoop.png
Emm x7... 拉得挺长,其实也没多少内容
至此,Scheduler 的核心运作方式就剖开了
而源码中还有一些其他的方法,有些是用于 cancel 掉当前的调度循环(即递归过程),有些是提供给开发者使用的工具接口,有兴趣的同学可以戳这里进行进一步地了解

总结

由于贴入了大量的源码,因此本文篇幅也比较长,但其实总得来说就是解释了两个问题

postMessage 如何运作?

主要就是通过 performWorkUntilDeadline 这个方法来实现一个递归的消息 发送-接收-处理 流程,来实现任务的处理

任务如何被处理?

一切都围绕着两个最小优先队列进行:

  • taskQueue
  • timerQueue

任务被按照一定的优先级规则进行预设,而这些预设的主要目的就是确认执行时机(timeoutForPriorityLevel)。
没当开始处理一系列任务的时候(flushWork),会产生一个 while 循环(workLoop)来不断地对队列中的内容进行处理,这期间还会逐步的将被递延任务从 timerQueue 中梳理(advanceTimers)到 taskQueue 中,使得任务能按预设的优先级有序的执行。甚至,对于更高阶的任务回调实现,还可以将任务“分段进行”(continuationCallback)。
而穿插在这整个过程中的一个原则是所有的任务都尽量不占用与用户感知最密切的浏览器任务(needsPainiting & isInputPending),当然,这一点能做得多极致也与浏览器的实现(navigator.scheduling)有关

总览

现在,我们将前面的示意图都整合起来,并加上两个队列的示意,可以得到一张大大的运作原理总览:
scheduler.png
啊,真的很大... 其实主要是空白多...
总的来说,相比旧的实现(rIC 和 rAF),postMessage 的方式更加独立,对设备本身的运作流程有了更少的依赖,这不仅提升了任务处理的效率,也减少了因不可控因素导致应用出错的风险,是相当不错的尝试。尽管它没有显式地对各个 React 应用产生影响,甚至也无须开发者对它有深刻的理解,但也许我们知道了它的运作原理,也就增添了代码优化及排错查误的思路。
然而,前面也提到了,这个实现的一些东西目前也正处于试验阶段,因此我们如果要直接使用 Scheduler 来实现一些东西,也是需要慎重考虑的。
Emm x8... 是不是可以用它来做一些弹幕应用的渲染管理呢——毕竟飞机礼物的通知比纯文字的吹水优先级要高吧,贵的礼物要比……哎,有点讨打了,拜托忘记破折号后的内容。
有兴趣的同学可以实践一下,也是帮助 Scheduler 的试验了~


最后,如果有什么本文理解有误的地方,还望指出🙏


shockw4ver
1k 声望117 粉丝

未经审视的代码是不值得写的。