2024 年了,你还不知道 MessageChannel 吗?

大家好,我是雷布斯。

如果大家有阅读过 Vue 或者 React 的源码,会发现它们都用到过 MessageChannel

MessageChannel 究竟是什么,有什么用?我们今天一块来进行学习。

简单介绍

先把 MessageChannel 需要重点关注信息给大家列出来:

  • MessageChannel 可以帮助我们创建一个消息通道,并通过它的两个 MessagePort 属性发送数据。这个管道有两个端口,每个端口都可以通过postMessage发送数据,其中一个端口只要绑定了onmessage回调方法,就可以接收从另一个端口传过来的数据
  • React 源码或其他一些前端库中,MessageChannel 通常被用于实现一些并发模式、多线程或在不同上下文中的通信。例如,ReactConcurrent Mode 使用 MessageChannel 实现了一种任务切片和协同工作的机制,以提高应用的响应性。
  • MessageChannel是一个宏任务

基本用法

MessageChannel API 提供了一个双向通信的通道,使得两个上下文能够通过发送和接收消息进行交流。

在使用 MessageChannel 时,你可以创建一个 MessageChannel 对象,该对象包含两个端口,分别是 port1port2

你可以将其中一个端口发送到另一个上下文,从而建立通信通道。

通过端口,你可以发送消息,并监听接收到的消息。这在多窗口多框架Web Worker 中进行数据传递时非常有用。

// 创建 MessageChannel
const channel = new MessageChannel();
const port1 = channel.port1;
const port2 = channel.port2;

port1.onmessage = function(event{
    console.log("port1收到来自port2的数据:" + event.data);
}
port2.onmessage = function(event{
    console.log("port2收到来自port1的数据:" + event.data);
}

port1.postMessage("发送给port2");
port2.postMessage("发送给port1");

使用场景

我们接着来看下 MessageChannel 的使用场景。

深拷贝

大家知道最简单的深拷贝是用 JSON.parse(JSON.stringify(object)) 实现,但这种方法有一些局限性:

  • 会忽略 undefined
  • 不能序列化函数
  • 不能解决循环引用的对象

undefined和函数会被忽略,而尝试拷贝循环引用的对象则会报错:

MessageChannelpostMessage传递的数据也是深拷贝的,这和web workerpostMessage一样,而且还可以拷贝undefined和循环引用的对象。

// 有undefined + 循环引用
let obj = {
  a1,
  b: {
    c2,
    d3,
  },
  fundefined
}
obj.c = obj.b;
obj.e = obj.a
obj.b.c = obj.c
obj.b.d = obj.b
obj.b.e = obj.b.c

function deepCopy(obj{
  return new Promise((resolve) => {
    const {port1, port2} = new MessageChannel();
    port2.onmessage = ev => resolve(ev.data);
    port1.postMessage(obj);
  });
}

deepCopy(obj).then((copy) => {           // 请记住`MessageChannel`是异步的这个前提!
    let copyObj = copy;
    console.log(copyObj, obj)
    console.log(copyObj == obj)
});

但拷贝有函数、Symbol等不可序列化的值时,还是会报无法克隆的错误:

iframe 通信

window与单个iframe或者多个iframe之间的通信可以使用 MessageChannel,通过只暴露有限的能力从而保证安全性。

//主页面
<iframe id="iframe1" src="./iframe1.html"></iframe>
<iframe id="iframe2" src="./iframe2.html"></iframe>
<script>
    window.onload = function(){
        var {port1,port2} = new MessageChannel();
        var iframe1 = document.getElementById('iframe1');
        iframe1.contentWindow.postMessage('main','*',[port1]);
        var iframe2 = document.getElementById('iframe2');
        iframe2.contentWindow.postMessage('main','*',[port2]);
    }
</script>

//iframe1
<div id="message"></div>
<script>
    window.addEventListener('message',function(event){
        console.log(event);
        let messageDom = document.getElementById('message');
        messageDom.innerHTML = "收到"  + event.origin + "消息:" + event.data;

        let port = event.ports[0];
        port.onmessage = function(e){
            messageDom.innerHTML += '<br/>收到' + e.origin + '消息: ' + e.data;
        }
        port.postMessage('from iframe1');
  }, false);
</script>

//iframe2
<div id="message"></div>
<script>
    window.addEventListener('message',function(event){
        console.log(event);
        let messageDom = document.getElementById('message');
        messageDom.innerHTML = "收到"  + event.origin + "消息:" + event.data;

        let port = event.ports[0];
        port.onmessage = function(e){
            messageDom.innerHTML += '<br/>收到' + e.origin + '消息: ' + e.data;
        }
        port.postMessage('from iframe2');
    }, false);
</script>

Web Worker 通信

const worker1 = new Worker('./worker1.js');
const worker2 = new Worker('./worker2.js');
const ms = new MessageChannel();

// 把 port1 分配给 worker1
worker1.postMessage('main', [ms.port1]);
// 把 port2 分配给 worker2
worker2.postMessage('main', [ms.port2]);

//worker1.js
self.onmessage = function(e{
    console.log('worker1', e.ports);
    if (e.data === 'main') {
        const port = e.ports[0];
        port.postMessage(`worker1: Hi! I'm worker1`);
        port.onmessage = function(ev){
            console.log('reveice: ',ev.data,ev.origin);
        }
    }       
}

//worker2.js
self.onmessage = function(e{
    if (e.data === 'main') {
        const port = e.ports[0];
        port.onmessage = function(e{
            console.log('receive: ', e.data);
            port.postMessage('worker2: ' + e.data);
        }
    }
}

除了以上几种场景,MessageChannel还是在事件循环的应用上出现得比较多。

Event Loop中的执行顺序

下面的例子,打印顺序会是怎样?

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

const { port1, port2 } = new MessageChannel()
port2.onmessage = function ({
    console.log('MessageChannel')
}
port1.postMessage('ping')

Promise.resolve().then(() => {
    console.log('Promise')
})

新版本的 Chrome 中,输出是

Promise
setTimeout
MessageChannel

MessageChannelsetTimeout 一样,是一个宏任务。

在Vue中的使用

Vue的nextTick的实现经过了多次的调整。在 Vue2.5 以前,nextTick优先使用微任务Promise来实现。到了2.5版本,Vue引入MessageChannel,nextTick的实现优先使用setImmediate,平台不支持则使用MessageChannel,再不支持才使用Promise,最后用setTimeout兜底。

if (typeof setImmediate !== 'undefined' && isNative(setImmediate)) {
  timerFunc = () => {
    setImmediate(nextTickHandler)
  }
else if (typeof MessageChannel !== 'undefined' && (
  isNative(MessageChannel) ||
  MessageChannel.toString() === '[object MessageChannelConstructor]'
)) {
  const channel = new MessageChannel()
  const port = channel.port2
  channel.port1.onmessage = nextTickHandler
  timerFunc = () => {
    port.postMessage(1)
  }
else
  if (typeof Promise !== 'undefined' && isNative(Promise)) {
    const p = Promise.resolve()
    timerFunc = () => {
      p.then(nextTickHandler)
    }
  } else {
    timerFunc = () => {
      setTimeout(nextTickHandler, 0)
    }
  }

不过到了2.6版本以后,nextTick又改回原来的Promise实现。虽然MessageChannel只存在了一个minor版本,但是我们从Vue的使用上知道它可以用来控制异步任务的执行时机。

在React中的使用

众所周知,React为了保证一帧内有足够的时间渲染ui,使用了requestIdleCallback这个API。但实际上,由于requestIdleCallback工作帧率低,只有20FPS,还有兼容问题,React并没有使用它,而是用requestAnimationFrameMessageChannel进行polyfill。

// SchedulerHostConfig.default.js
...
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;
...

兼容性

主流浏览器都对MessageChannel支持良好。

最后

还没有使用过我们刷题网站(https://fe.ecool.fun/)或者刷题小程序的同学,如果近期准备或者正在找工作,千万不要错过,题库主打无广告和更新快哦~。

老规矩,也给我们团队的辅导服务打个广告。