探索 React 的内在 —— postMessage & Scheduler

postMessage & Scheduler

写在前面

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

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

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

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

开始

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

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

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

契机

经过对此次 release 的 commit-message 的查看,咱们总结出如下几点:react

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

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

postMessage


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

不对,也对

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

不对

说不对呢,是由于 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 的试验了~最后,若是有什么本文理解有误的地方,还望指出🙏

相关文章
相关标签/搜索