likes
comments
collection
share

由React引出的MessageChannel执行时机问题🌝

作者站长头像
站长
· 阅读数 2

前言

大家都了解 React 为了避免长时间占用主线程,结合 fiber 和调度的时间切片实现更新颗粒度的细化。浏览器环境下,React 实际上使用了 MessageChannel 模拟 requestIdleCallback,源码如下:

if (typeof MessageChannel !== 'undefined') {
  // DOM and Worker environments.
  // We prefer MessageChannel because of the 4ms setTimeout clamping.
  var channel = new MessageChannel();
  var port = channel.port2;
  // 接收到更新消息,执行更新任务
  channel.port1.onmessage = performWorkUntilDeadline;

  // 调度更新任务
  schedulePerformWorkUntilDeadline = function () {
    port.postMessage(null);
  };
}

var performWorkUntilDeadline = function () {
  // .... 省略代码
  if (scheduledHostCallback !== null) {
    try {
      hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime);
    } finally {
      if (hasMoreWork) {
        // If there's more work, schedule the next message event at the end
        // of the preceding one.
        schedulePerformWorkUntilDeadline();
      } else {
        isMessageLoopRunning = false;
        scheduledHostCallback = null;
      }
    }
  } else {
    isMessageLoopRunning = false;
  } // Yielding to the browser will give it a chance to paint, so we can
};

看到以上陈述及代码,首先会一脸懵,产生一堆为什么:

  1. 为什么要模拟requestIdleCallback,好好地直接用不就行了?
  2. 诶,为什么要使用requestIdleCallback?直接执行更新任务不好么
  3. 模拟requestIdleCallback为啥不用 setTimeout?

虽然有些问题不是本文的重点,但我们先把基础点捎带解释一下: 首先 GUI 渲染线程和 JS 引擎线程是相互排斥的,在旧版 React 中一次性遍历全部虚拟 DOM 会持续占用js线程,会阻塞浏览器渲染。因此 React 才更新为 fiber 架构,目的就是解决 diff 阶段遍历无法停止的问题。这里注意,不是在更新dom阶段时间切片,而是作用于从 root 开始遍历查找需要更新的dom的阶段。

接下来解答上述问题,之所以模拟 requestIdleCallback 的原因是其只在 chrome 中实现,目的是为了兼容其他浏览器。

那么为什么不使用 setTimeout 模拟,其中一个原因是 setTimeout 在嵌套5次以上判定为存在阻塞,Chrome 在之后的每次调用会使用最小时间间隔 4ms,造成不必要延迟。另外一个原因是看完本文对 MessageChannel 执行时机有了进一步了解,就会明白 MessageChannel 更符合 requestIdleCallback 的低优先级设定。

好了,带着疑问我们马上进入正文,一起来探讨为什么 requestIdleCallback 是低优先级。

产生这个疑问的原因是一直把 MessageChannel 当做简单的宏任务对待,直到今天突然想到测试一波执行时机,才发现这个 MessageChannel 不简单。本文探讨两个问题,MessageChannel 执行时机 和一个小扩展 Transferable object(可转移对象)是什么。

MessageChannel 执行时机

先来看一波测试,对比 MessageChannelsetTimeout 的执行时机有什么差异或关联

// 创建 channel 实例
const channel = new MessageChannel();
channel.port2.onmessage = function (event) {
    console.log("Received message from 1:", event.data);
};
// 1. setTimeout1 - MessageChannel1 - setTimeout2 - MessageChannel2
setTimeout(() => { console.log('setTimeout1') }, 0);

channel.port1.postMessage("test1");

setTimeout(() => { console.log('setTimeout2') }, 0);

channel.port1.postMessage("test2");

VM2877:1 setTimeout1
VM2877:15 Received message from 1: test1
VM2877:10 setTimeout2
VM2877:15 Received message from 1: test2
----------------------------------------------------------
// 2. MessageChannel1 - setTimeout1 - MessageChannel2 - setTimeout2
channel.port1.postMessage("test1");

setTimeout(() => { console.log('setTimeout1') }, 0);

channel.port1.postMessage("test2");

setTimeout(() => { console.log('setTimeout2') }, 0);

VM3052:15 Received message from 1: test1
VM3052:10 setTimeout1
VM3052:18 setTimeout2
VM3052:15 Received message from 1: test2

打印结果得出

1- 先 seTimeout 时,按普通宏任务顺序执行(为什么只说执行,不敢讲添加到任务队列,下文会讲)

2- 否则 MessageChannel 先执行一次,其次,按顺序执行 seTimeout,执行结束后执行剩下的 MessageChannel。🌚🌚🌚

这和我们理解的事件循环中的宏任务似乎不一样,当前场景下,反而 setTimeout 像微任务,MessageChannel 是宏任务??

由React引出的MessageChannel执行时机问题🌝

那么我们在第二步基础上加入Promise进行尝试,👇🏻 得出:MessageChannel 不是微任务

// 3. MessageChannel1 - MessageChannel2 - setTimeout1 - setTimeout2 - Promise
channel.port2.onmessage = function (event) {
    // 添加微任务 Promise
    Promise.resolve().then(() => {
      console.log("Promise - ", event.data);
    });
    console.log("Received message from 1:", event.data);
};

channel.port1.postMessage("test1");
channel.port1.postMessage("test2");

setTimeout(() => { console.log('setTimeout1') }, 0);
setTimeout(() => { console.log('setTimeout2') }, 0);

Promise.resolve().then(() => {
    console.log("outer Promise");
});

VM18724:18 outer Promise
VM18724:6 Received message from 1: test1
VM18724:4 Promise -  test1
VM18724:22 setTimeout1
VM18724:25 setTimeout2
VM18724:6 Received message from 1: test2
VM18724:4 Promise -  test2

综上分析,既然不是微任务,那MessageChannel也不是像普通宏任务一样直接加入到任务队列中,否则第二步的顺序应该和代码顺序一致。再次结合第二步进行猜想,是否有一个单独的队列维护 meaage,且这个队列的优先级比较低,事件循环在调度过程中会优先执行主队列的任务,当主队列为空时才会执行 meaage 队列。验证猜想👇🏻

// 4. setTimeout1 - MessageChannel1 - setTimeout2 - MessageChannel2 - setTimeout3 - MessageChannel3
setTimeout(() => {
    console.log("setTimeout1");
}, 0);
channel.port1.postMessage("test1");

setTimeout(() => {
    console.log("setTimeout2");
}, 0);
channel.port1.postMessage("test2");

setTimeout(() => {
    console.log("setTimeout3");
}, 0);
channel.port1.postMessage("test3");

VM304:7 setTimeout1
VM304:3 Received message from 1: test1
VM304:11 setTimeout2
VM304:15 setTimeout3
VM304:3 Received message from 1: test2
VM304:3 Received message from 1: test3

结果和猜想一点点一致,但是根据第1、4步测试结果,继续懵:为啥不是 setTimeout 全部执行完毕后再执行低优先级的 MessageChannel 💆💆💆 资料很少,查了一通还好最终找到官方描述,终于找到解释也佐证了一部分猜想:

【📢:不要问GPT 不要问GPT 不要问GPT !!!! 语料太少疯狂胡说 】

由React引出的MessageChannel执行时机问题🌝

总结一下,每个 MessagePort 对象有一个名为 port message queue 的任务源,初始化是 disabled 状态,当该队列处于 enabled 状态时,event loop 必须执行一个port message queue 中的任务,这也就解释了为什么第 1 步和第 4 步中为什么第一条 message 的执行时机和普通宏任务行为一致。规范给出的唯一保证是,来自相同任务源的任务将按照它们排队的顺序执行。除此之外,事件循环处理模型的第一步允许用户代理从任何任务队列中选择任务,只要它有可运行的任务,也就是说用户代理也可以选择忽略来自某一个任务队列的任务,只要另一个队列中有可运行的任务,所以我们看到的低优先级就是一个选择结果。

如果有对事件循环研究透彻的大佬们看到此文,欢迎赐教🐣

扩展 - Transferable object

在探讨为什么要写扩展之前,我们先来看一下 MessageChannel 的常规用法:

// worker content
const content = `
  // 接收来自主线程的消息
  onmessage = function(event) {
    const port = event.ports[0];

    if (event.data === 'start') {
      // 监听来自主线程通道的消息
      port.onmessage = function(event) {
        console.log('Received message from Main Thread:', event.data);
        // 在 Worker 中处理消息
        const processedMessage = JSON.stringify(event.data);

        // 将处理后的消息发送回主线程
        port.postMessage(processedMessage);
      };
    }
  };
`;

// 避免再启服务,模拟一个 worker 文件地址
const blob = new Blob([content], {
  type: "application/javascript",
});
const url = URL.createObjectURL(blob);

const worker = new Worker(url);

// 创建一个新的 MessageChannel
const channel = new MessageChannel();

// 将通道的一个端口传递给 Worker
worker.postMessage("start", [channel.port1]);
channel.port1.postMessage("test");

// 监听来自 Worker 的消息
channel.port2.onmessage = function (event) {
  console.log("Received message from Worker:", event.data);
};

// 向 Worker 发送消息
const message = { name: "port1.postMessage" };
channel.port2.postMessage(message);

代码中可以看到在 postMessage 相关示例中传递 port 会用到 worker.postMessage("start", [channel.port1]),其实二参还可以写其他Transferable object,我们先来看 MDN 中的定义:

由React引出的MessageChannel执行时机问题🌝

由React引出的MessageChannel执行时机问题🌝

不难理解可转移对象能够在不同线程间转移内存资源,这将直接抹去不同线程间复制数据带来的开销,从而使用更加高效的资源移动代替。

我们常说避免占用主线程可以将一些计算在 worker 中处理,结果在主线程消费,但 worker 的缺点是不仅频繁创建实例有成本,默认的序列化与反序列化耗时也太长。如果消息数据不在二参范围内,在结构化克隆算法复制时就会深拷贝一份数据【developer.mozilla.org/zh-CN/docs/… 】 反之数据中是Transferable object,并且在二参中让出主权,则可以在消息接收方线程中直接使用。这个特性在处理gl等场景下,可以直接使用 worker 中计算出的 buffer,以此优化性能。

以下是 MDN 中描述的可被转移的不同规范的对象:

  • ArrayBuffer
  • MessagePort
  • ReadableStream
  • WritableStream
  • TransformStream
  • AudioData (en-US)
  • ImageBitmap
  • VideoFrame (en-US)
  • OffscreenCanvas
  • RTCDataChannel

关于测试例子,控制台 【top】下打印转让出去的 port1,可以发现主线程虽然还可以打印 port1,但实例中 onmessage = null,该对象已经是个空壳。如果继续使用其原型上的 postMessage,会发现无报错但无效,感兴趣的可以试试。

由React引出的MessageChannel执行时机问题🌝

参考: