本文介绍浏览器的 MessageChannel,并单独说明 Electron 主进程 API 的差异。
MessageChannel 创建一条双向、异步的消息通道。创建后会得到两个互相连接的 MessagePort:port1 和 port2。两端地位相同,都能发送和接收。
markdown port1.postMessage(data) ─────→ port2 的 message 回调 port1 的 message 回调 ←───── port2.postMessage(data)
端口可以交给其他执行环境,例如 iframe 或 Web Worker。通道本身不创建线程,也不负责通过网络连接远程设备。MessageChannel 文档
| API | 用途 |
|---|---|
new MessageChannel() | 创建一对相互连接的端口 |
channel.port1、channel.port2 | 取得两端的 MessagePort |
port.postMessage(message, transfer?) | 发送消息;浏览器 API 可附带受支持对象的转移列表 |
port.onmessage = handler | 接收消息,并自动启用该端口的消息派发 |
port.addEventListener('message', handler) | 添加接收监听;还需调用 port.start() |
port.start() | 启用本端队列中消息的派发 |
port.close() | 断开本端连接 |
接收数据位于 event.data。转移得到的端口也可以通过 event.ports 获取。MessagePort 文档
| 概念 | 实际含义 |
|---|---|
| 通道 | 决定消息发给谁,由哪个端口接收 |
| 结构化克隆 | 按数据类型序列化、重建消息;不是简单的 JSON stringify/parse |
| transfer | 转移受支持资源的所有权;不等于所有路径都保证物理零拷贝 |
例如,浏览器中普通对象可以作为消息发送,ArrayBuffer 也可以作为其中的属性。函数和 DOM 节点不能直接作为普通结构化克隆数据发送。转移端口与转移数据缓冲区是两个不同操作。postMessage 文档
以下是结合 API 特性给出的设计建议,是否值得采用仍取决于通信关系和业务生命周期。
| 场景 | 示例 | 使用价值 |
|---|---|---|
| 页面与 iframe 通信 | 编辑器与预览区、宿主与嵌入组件 | 为组件建立专用连接,减少公共 window 消息监听中的分发逻辑 |
| 页面与 Worker 通信 | 数据分析、图片处理 | 为不同任务或客户端分配端口 |
| 两个 Worker 直接通信 | A 生成数据,B 处理数据 | 主线程建立连接后,无须通过自己的 JS 回调逐条转发 |
| 有联系的窗口或标签页通信 | 主页面打开编辑窗口 | 初次交换端口后持续双向交流 |
| 一次请求返回多批结果 | 搜索、历史记录加载、流式结果 | 同一条连接承载数据、进度、取消和完成 |
| 插件与模块协作 | 宿主为每个插件分配连接 | 把端口作为接口传入模块,分别管理消息和生命周期 |
| Electron 两个渲染进程通信 | 主窗口与辅助窗口 | 主进程分发端口,减少应用层转发 |
| 安排后续任务 | 将工作拆成多个事件循环任务 | 在后续 task 中运行回调;不会自动并行 |
浏览器通道可以连接独立执行环境;Electron 官方另外提供了渲染进程互联、后台窗口和响应流示例。HTML 通道规范、Electron 使用场景
当你需要持续双向交流、为每个任务建立独立连接、把通信端点交给另一个环境,或者减少应用层消息中转时,优先考虑 MessageChannel。
它不会自动提供请求与响应匹配、超时、重试或背压。例如,处理方消费较慢时,可以自行约定“最多允许若干批次在途,收到 ACK 后再继续发送”。
| 需求 | 通常更简单的做法 |
|---|---|
| 同一线程中的普通模块调用 | 函数调用、Promise 或业务事件机制 |
| 页面和一个 Worker 的简单任务往返 | 直接使用 worker.postMessage() |
| Electron 渲染进程请求一个结果 | ipcRenderer.invoke() + ipcMain.handle() |
| Electron 主进程偶尔通知页面 | webContents.send() |
| 同源页面间广播消息 | 按需求考虑 BroadcastChannel;它不是任意跨源通道 |
| 不同设备或完全无联系的网页 | 采用网络通信或另外设计建连机制 |
Electron 内置 IPC 已覆盖单向通知和一次请求响应,不必为每个简单消息都新建端口。Electron IPC 指南
下面代码可直接在浏览器控制台执行。
jsconst { port1, port2 } = new MessageChannel();
port1.onmessage = (event) => {
console.log('port1 收到:', event.data);
port1.close();
port2.close();
};
port2.onmessage = (event) => {
console.log('port2 收到:', event.data);
port2.postMessage('收到!');
};
port1.postMessage({ text: '你好' });
console.log('同步代码执行结束');
预期顺序:
markdown 同步代码执行结束 port2 收到: { text: '你好' } port1 收到: 收到!
如果使用另一种监听方式,需要显式启动:
jsconst channel = new MessageChannel();
channel.port1.addEventListener('message', (event) => {
console.log(event.data);
channel.port1.close();
channel.port2.close();
});
channel.port1.start();
channel.port2.postMessage('hello');
这个例子使用浏览器 Web Worker。
markdown主线程:创建 Worker A、Worker B,分别交付 port1、port2
Worker A ── [1, 2, 3, 4] ──→ Worker B
Worker A ←─ [1, 4, 9, 16] ─── Worker B
│
└── 最终结果 → 主线程展示
“直接通信”是指 A、B 的数据交换不需要主线程的 JavaScript 消息处理函数中转,不表示底层没有浏览器调度或 IPC。端口使用 transfer 列表交付给 Worker。Worker.postMessage 文档
markdown worker-demo/ index.html worker-a.js worker-b.js
主线程只负责创建环境、发起运行、展示最终结果。按钮在两个 Worker 都准备好后启用。
html<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>两个 Worker 直接通信</title>
</head>
<body>
<button id="run" disabled>运行</button>
<pre id="output">正在连接两个 Worker……</pre>
<script>
const output = document.querySelector('#output');
const runButton = document.querySelector('#run');
const workerA = new Worker('./worker-a.js');
const workerB = new Worker('./worker-b.js');
const ready = new Set();
function handleMessage(event) {
const message = event.data;
if (message.type === 'ready') {
ready.add(message.worker);
if (ready.size === 2) {
runButton.disabled = false;
output.textContent = '连接完成,点击运行';
}
} else if (message.type === 'result') {
output.textContent = JSON.stringify(message, null, 2);
runButton.disabled = false;
}
}
workerA.onmessage = handleMessage;
workerB.onmessage = handleMessage;
workerA.onerror = workerB.onerror = (event) => {
output.textContent = `错误:${event.message}`;
runButton.disabled = true;
};
const { port1, port2 } = new MessageChannel();
workerA.postMessage({ type: 'connect', port: port1 }, [port1]);
workerB.postMessage({ type: 'connect', port: port2 }, [port2]);
runButton.onclick = () => {
runButton.disabled = true;
output.textContent = '计算中……';
workerA.postMessage({ type: 'run' });
};
// 页面退出时,演示中的两个 Worker 一起结束。
window.addEventListener('pagehide', () => {
workerA.terminate();
workerB.terminate();
});
</script>
</body>
</html>
jslet portToB;
self.onmessage = (event) => {
const message = event.data;
if (message.type === 'connect') {
portToB = message.port;
// 这里接收的是 B 发来的消息。
portToB.onmessage = (event) => {
self.postMessage({
type: 'result',
from: 'Worker A',
result: event.data.result
});
};
self.postMessage({ type: 'ready', worker: 'A' });
} else if (message.type === 'run' && portToB) {
// 数据在 A 内生成,直接通过端口发给 B。
const numbers = [1, 2, 3, 4];
portToB.postMessage({ numbers });
}
};
jsself.onmessage = (event) => {
if (event.data.type !== 'connect') return;
const portToA = event.data.port;
portToA.onmessage = (event) => {
const { numbers } = event.data;
const result = numbers.map((number) => number * number);
// 直接回复 A。
portToA.postMessage({ result });
};
self.postMessage({ type: 'ready', worker: 'B' });
};
json{
"type": "result",
"from": "Worker A",
"result": [1, 4, 9, 16]
}
关键区别:
jsportToB.postMessage(data); // Worker A → Worker B
self.postMessage(data); // Worker A → 创建它的主线程
假设项目 A 位于 https://a.example,项目 B 位于 https://b.example。A 用新窗口打开 B;B 注册监听器后通知 A 已就绪,A 再交付端口。
示例域名需要替换为实际项目地址。新窗口由用户点击打开,且双方的窗口策略需允许保留 opener 关系。
jsconst B_ORIGIN = 'https://b.example';
let bWindow = null;
let portToB = null;
window.addEventListener('message', (event) => {
if (event.origin !== B_ORIGIN) return;
if (!bWindow || event.source !== bWindow) return;
if (event.data?.type !== 'ready') return;
// B 重载后重新建连。
portToB?.close();
const { port1, port2 } = new MessageChannel();
portToB = port1;
port1.onmessage = (event) => {
console.log('A 收到 B:', event.data);
};
bWindow.postMessage({ type: 'connect' }, B_ORIGIN, [port2]);
port1.postMessage({ type: 'hello', text: '来自 A' });
});
document.querySelector('#open-b').onclick = () => {
bWindow = window.open(B_ORIGIN, 'project-b');
};
jsconst A_ORIGIN = 'https://a.example';
let portToA = null;
window.addEventListener('message', (event) => {
if (event.origin !== A_ORIGIN) return;
if (!window.opener || event.source !== window.opener) return;
if (event.data?.type !== 'connect') return;
const port = event.ports[0];
if (!port) return;
portToA?.close();
portToA = port;
port.onmessage = (event) => {
console.log('B 收到 A:', event.data);
port.postMessage({ type: 'reply', text: 'B 已收到' });
};
});
// 先安装监听器,再通知 A 可以传入端口。
window.opener?.postMessage({ type: 'ready' }, A_ORIGIN);
MessageChannel 不要求双方同源或同站,但它没有“按 URL 找标签页”的功能。独立手动打开、又没有任何窗口关系或端口转交机制的两个页面,不能仅靠它直接连接。CORS 不负责批准这类窗口消息;noopener、COOP 等设置则可能阻止保留所需的窗口联系。window.postMessage 文档、COOP 文档
下面代码可以在浏览器控制台运行。
jsconst { port1, port2 } = new MessageChannel();
port1.onmessage = () => {
console.log('MessageChannel');
port1.close();
port2.close();
};
console.log('同步开始');
port2.postMessage(null);
Promise.resolve().then(() => {
console.log('Promise 微任务');
});
queueMicrotask(() => {
console.log('queueMicrotask 微任务');
});
console.log('同步结束');
预期顺序:
markdown 同步开始 同步结束 Promise 微任务 queueMicrotask 微任务 MessageChannel
浏览器将消息派发安排为接收端事件循环中的 task。当前同步代码结束后,会先执行微任务检查点;本例中的两个微任务完成后,消息任务才有机会执行。与 setTimeout(..., 0) 的先后顺序不能一概保证,它们来自不同任务源。
两个 Worker 有各自的事件循环。B 的消息任务不需要等待 A 清空自己的微任务队列。不要把这个浏览器事件循环说明直接等同于 Electron 主进程的 Node.js 事件循环阶段。消息端口规范、事件循环规范
以下是浏览器 DOM MessagePort 的写法:
jsport.postMessage({ buffer }); // 克隆普通 ArrayBuffer 数据
port.postMessage({ buffer }, [buffer]); // 转移 ArrayBuffer 所有权
转移后发送方原 buffer 会被 detach。接收方得到新的 JavaScript 包装对象,包含相应字节内容。规范允许在能直接访问原内存时接管其底层存储,但不能由 transfer 推断所有实现路径均没有物理复制。
尤其要注意:buffer.byteLength === 0 只能表明这个普通 buffer 已被 detach,不能作为零拷贝证明。结构化序列化与转移规范
不同进程不能直接使用彼此的普通虚拟地址,但操作系统可以让两个进程映射同一块共享物理内存。是否能让接收方直接使用这块内存,取决于实现和资源类型。
Chromium 当前普通 ArrayBuffer 的相关 Mojo 传输路径中,发送端可以把字节复制到 BigBuffer,较大数据可使用共享内存;接收端还会分配新的 ArrayBufferContents,再复制字节。因此,中间使用共享内存不等于端到端零拷贝。此结论针对所链接路径,不推广到所有可转移媒体或 GPU 资源。发送端 BigBuffer 实现、接收端实现
对于原本就是二进制的数据,直接发送 { id, buffer } 通常比转换为数字数组或 Base64 更合适。V8 对普通 ArrayBuffer 写入类型、长度和原始字节,使用批量内存复制;不会将其内容展开成许多独立 JavaScript 数字。
外层只有两个字段的对象不会破坏这种专门路径。不过复制成本仍随字节数增长,接收端还涉及内存分配。Base64 会增加约三分之一的数据量,并引入编码、解码成本;将复杂对象先 JSON 编码再变成 ArrayBuffer,也未必能减少端到端时间。
这些是基于表示形式和实现的性能判断,不是实际项目的测速结果。应比较“生成或编码数据 → 发送 → 接收与解码 → 可使用”的总耗时。V8 序列化实现、Electron 序列化实现
浏览器 DOM MessagePort、Electron MessagePortMain 与 Node.js worker_threads 的 MessagePort 是不同接口,不能仅凭相似的方法名混用能力。
| 主进程调用 | 普通 ArrayBuffer 消息 | transfer 列表 |
|---|---|---|
webContents.send(channel, buffer) | 可以发送,序列化复制 | 无此专用参数 |
webContents.postMessage(channel, buffer, transfer) | 可以发送,序列化复制 | 只接收 MessagePortMain |
MessagePortMain.postMessage(buffer, transfer) | 可以发送,序列化复制 | 只接收 MessagePortMain |
因此,主进程通过这组 API 向渲染进程发送普通 ArrayBuffer,不能用 [buffer] 实现转移或零拷贝。webContents 文档、MessagePortMain 文档