首页
/ Electron MessagePort 完全指南:Channel Messaging 在主进程、渲染进程与 Worker 间的实战运用

Electron MessagePort 完全指南:Channel Messaging 在主进程、渲染进程与 Worker 间的实战运用

2026-09-07 15:11:18作者:薛曦旖Francesca

导读

本文围绕 MessagePorts in Electron 一文展开,系统讲解 Electron 如何扩展 Web 标准的 Channel Messaging 模型:包括 MessagePortMessageChannel 在渲染进程中的用法、专为主进程设计的 MessagePortMain / MessageChannelMain,以及 close 事件这一 Electron 独有的扩展。读完本文你将掌握四条可直接落地的通信架构方案:两个渲染进程直连、隐藏窗口 Worker 进程通信、以"响应流"取代 invoke 的一次请求多次回包、以及绕过 context isolation 隔离世界向页面主世界直达消息。配套的 API 参考见 MessagePortMainMessageChannelMain

MessagePort 是什么

MessagePort 是 Web 标准提供的一项能力,允许在不同上下文之间传递消息。你可以把它理解为"分不同信道(channel)的 window.postMessage"——与全局广播式的窗口消息不同,每个 MessagePort 只属于一条由成对端口构成的专用通道,消息只在这对端口之间流动。

在渲染进程中,MessagePort 的行为与浏览器完全一致:端口成对创建、一端的消息会到达另一端、消息在对方注册监听器之前会先排队。

// MessagePorts 总是成对创建,一对相连的端口即构成一个 channel。
const channel = new MessageChannel()

// port1 与 port2 本身没有区别,用法取决于你:发往 port1 的消息由 port2 接收,反之亦然。
const port1 = channel.port1
const port2 = channel.port2

// 在另一端注册监听之前就发送消息是合法的,消息会一直排队直到对方注册监听器。
port2.postMessage({ answer: 42 })

// 把 channel 的另一端 port1 交给主进程。
// MessagePort 同样可以被发送给其他 frame、Web Worker 等上下文。
ipcRenderer.postMessage('port', null, [port1])

对应的主进程侧接收代码:

// 在主进程中接收该端口。
ipcMain.on('port', (event) => {
  // 主进程收到的 MessagePort 会被包装成 MessagePortMain。
  const port = event.ports[0]

  // MessagePortMain 采用 Node.js 风格的事件 API,
  // 即 .on('message', ...),而非 Web 风格的 .onmessage = ...
  port.on('message', (event) => {
    // data 即 { answer: 42 }
    const data = event.data
  })

  // MessagePortMain 在调用 .start() 之前会一直把消息排队。
  port.start()
})

需要注意,用 ipcRenderer.postMessage 携带 [port1] 作为 transfer list 发送端口时,收到的 IPC 事件对象 event.ports 中即为对应的 MessagePort / MessagePortMain,其用法在渲染进程 IPC 文档主进程 IPC 文档中都有说明。

渲染进程内:与 Web 完全一致

由于渲染进程内嵌了完整的 Blink 引擎,其中的 MessagePortMessageChannel 就是浏览器原生的 Channel Messaging 实现,没有任何 Electron 包装层。发送消息、接收消息、转移(transfer)端口等行为均遵循 [Channel Messaging API] 规范。

主进程内:没有 Blink,但有 MessagePortMain

主进程并不是一个网页,它没有 Blink 集成,因此不存在原生的 MessagePortMessageChannel 类。为了让主进程能够创建、接收和转发消息端口,Electron 新增了两个类:

  • MessagePortMain:主进程侧的 MessagePort 等价物,与 DOM 版本行为类似,但事件系统改用 Node.js 的 EventEmitter,即 port.on('message', ...),而不是 port.onmessage = ...port.addEventListener('message', ...)
  • MessageChannelMain:主进程侧的 MessageChannel 等价物,唯一职责就是创建一对相连的 MessagePortMain(属性 port1port2)。

从源码看,这两个类都有清晰的 JS 侧实现与 C++ 侧支撑。MessageChannelMain 实现 通过 process._linkedBinding('electron_browser_message_port') 获取 createPair() 创建一对底层端口,再用 MessagePortMain 包装为对外可用的 port1 / port2MessagePortMain 实现 则继承 EventEmitter,并将收到的 message 事件里的端口逐个重新包装成 MessagePortMain 后向上分发。底层 C++ 实现位于 shell/browser/api/message_port.cc

MessagePortMain 的实例方法 API 归纳如下(完整定义见 message-port-main.md):

方法 / 事件 签名 说明
postMessage port.postMessage(message, [transfer]) 从端口发送消息,可选地转移 MessagePortMain[] 的所有权
start port.start() 开始投递队列中的消息;调用前消息一直排队
close port.close() 断开端口,使其不再活跃
事件 'message' messageEvent(含 dataports 字段) 端口收到消息时触发
事件 'close' 对端端口断开时触发

跨上下文投递的唯一通道:postMessage 系列方法

MessagePort 可以在渲染进程或主进程任意一侧创建,并通过以下两个方法跨进程投递:

关键限制:常规 IPC 方法(sendinvokehandle 等)不能转移 MessagePort,只有上述两个 postMessage 方法可以随消息携带并转移端口。这一差异在 Worker 进程示例中会直接体现——你无法用 ipcMain.handle() 的返回值去携带一个端口。

通过主进程中转来投递 MessagePort,还可以把两个原本无法互相通信的页面连接起来(例如受同源策略限制的页面),这正是下文第一个用例的核心。

Electron 扩展:close 事件

为了让 MessagePort 在 Electron 中更实用,Electron 增加了一个 Web 标准里没有的事件——close。当通道的另一端被关闭时会触发该事件;端口也可能因为被垃圾回收而隐式关闭。

  • 在渲染进程中:通过 port.onclose = ... 赋值,或 port.addEventListener('close', ...) 监听;
  • 在主进程中:通过 port.on('close', ...) 监听。

这一事件在实际工程中非常有用:无论是"响应流"用例中主进程发完数据主动 close(),还是接收端窗口被销毁、端口被回收,对端都能立即感知通道结束,进而清理资源或改变 UI 状态。从源码结构看,message_port.cc 承担底层端口生命周期与事件派发,JS 侧再以各自进程的事件系统向上暴露。

典型用例一:打通两个渲染进程

假设应用有两个 BrowserWindow,希望它们能直接互发消息而不经过主进程中转(例如绕开同源限制或减少主进程负载)。做法是:主进程创建 MessageChannelMain,把两个端口分别投递给两个渲染进程。

const { BrowserWindow, app, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
  // 创建两个窗口。
  const mainWindow = new BrowserWindow({
    show: false,
    webPreferences: {
      contextIsolation: false,
      preload: 'preloadMain.js'
    }
  })

  const secondaryWindow = new BrowserWindow({
    show: false,
    webPreferences: {
      contextIsolation: false,
      preload: 'preloadSecondary.js'
    }
  })

  // 建立 channel。
  const { port1, port2 } = new MessageChannelMain()

  // 等两个 webContents 就绪后,用 postMessage 分别投递一个端口。
  mainWindow.once('ready-to-show', () => {
    mainWindow.webContents.postMessage('port', null, [port1])
  })

  secondaryWindow.once('ready-to-show', () => {
    secondaryWindow.webContents.postMessage('port', null, [port2])
  })
})

preload 脚本通过 IPC 收到端口并挂到全局,同时注册消息处理:

const { ipcRenderer } = require('electron')

ipcRenderer.on('port', e => {
  // 收到端口,挂为全局可用。
  window.electronMessagePort = e.ports[0]

  window.electronMessagePort.onmessage = messageEvent => {
    // 处理消息
  }
})

此后,任意页面代码都可以直接向对方窗口发消息:

// 在应用任意位置调用,把消息发给另一个渲染进程的处理函数。
window.electronMessagePort.postMessage('ping')

该示例为了简洁把端口直接绑定在 window 对象上。更推荐的做法是开启 contextIsolation,并针对每条预期消息用 contextBridge 暴露专门 API;相关内容可参考文末"主进程直连 context-isolated 页面主世界"一节及 context isolation 指南

典型用例二:Worker 进程(隐藏窗口)直连

有些计算密集或需要完整 Blink 能力(如 <canvas>、audio、fetch())的任务,可以用一个 show: false 的隐藏 BrowserWindow 充当 Worker 进程。如果让主进程逐条转发任务与结果,会有不必要的性能开销;用 MessageChannelMain 直接连起业务窗口与 Worker 窗口即可消除中转。

const { BrowserWindow, app, ipcMain, MessageChannelMain } = require('electron')

app.whenReady().then(async () => {
  // Worker 进程是一个隐藏 BrowserWindow,
  // 以便获得完整 Blink 上下文(包括 <canvas>、audio、fetch() 等)。
  const worker = new BrowserWindow({
    show: false,
    webPreferences: { nodeIntegration: true }
  })
  await worker.loadFile('worker.html')

  // 主窗口把任务发给 Worker,并通过 MessagePort 拿回结果。
  const mainWindow = new BrowserWindow({
    webPreferences: { nodeIntegration: true }
  })
  mainWindow.loadFile('app.html')

  // 这里不能用 ipcMain.handle(),因为回复需要转移一个 MessagePort。
  // 监听顶层 frame 发来的消息。
  mainWindow.webContents.mainFrame.ipc.on('request-worker-channel', (event) => {
    // 新建一条 channel ...
    const { port1, port2 } = new MessageChannelMain()
    // ... 把一端发给 Worker ...
    worker.webContents.postMessage('new-client', null, [port1])
    // ... 把另一端发给主窗口。
    event.senderFrame.postMessage('provide-worker-channel', null, [port2])
    // 现在主窗口与 Worker 可以直接通信,无需经过主进程!
  })
})

注意这里使用了 mainWindow.webContents.mainFrame.ipc.onevent.senderFrame.postMessage——即 WebFrameMain 级的 IPC 监听与带端口回传(WebFrameMain 的 IPC 能力详见 web-frame-main)。这样做能精确定位到发起请求的那个 frame 并回传端口。

Worker 窗口内的处理逻辑:

<script>
const { ipcRenderer } = require('electron')

const doWork = (input) => {
  // 一些 CPU 密集型运算。
  return input * 2
}

// 可能会有多个客户端,例如存在多个窗口,或主窗口被刷新。
ipcRenderer.on('new-client', (event) => {
  const [ port ] = event.ports
  port.onmessage = (event) => {
    // event.data 可以是任意可序列化对象
    // (事件甚至还可以携带其他 MessagePort!)
    const result = doWork(event.data)
    port.postMessage(result)
  }
})
</script>

业务窗口发起请求并接收结果:

<script>
const { ipcRenderer } = require('electron')

// 请求主进程为我们建立一条可用于与 Worker 通信的 channel。
ipcRenderer.send('request-worker-channel')

ipcRenderer.once('provide-worker-channel', (event) => {
  // 收到回复后取出端口 ...
  const [ port ] = event.ports
  // ... 注册结果处理 ...
  port.onmessage = (event) => {
    console.log('received result:', event.data)
  }
  // ... 然后开始派发任务!
  port.postMessage(21)
})
</script>

典型用例三:响应流(Reply streams)

Electron 内置 IPC 只支持两种模式:即发即弃(fire-and-forget,如 send)与请求-响应(request-response,如 invoke)。借助 MessageChannel,可以轻易实现"一个请求、持续回包"的响应流:客户端为每次请求新建一条 channel,把一端交给主进程,自己留一端持续收取数据,主进程处理完毕或流结束时关闭端口。

const makeStreamingRequest = (element, callback) => {
  // MessageChannel 非常轻量,为每个请求新建一条成本很低。
  const { port1, port2 } = new MessageChannel()

  // 把端口一端发给主进程 ...
  ipcRenderer.postMessage(
    'give-me-a-stream',
    { element, count: 10 },
    [port2]
  )

  // ... 自己保留另一端。主进程会向其端口持续发消息,并在结束时关闭。
  port1.onmessage = (event) => {
    callback(event.data)
  }
  port1.onclose = () => {
    console.log('stream ended')
  }
}

makeStreamingRequest(42, (data) => {
  console.log('got response data:', data)
})
// 上面会连续打印 10 次 "got response data: 42"

主进程侧:

ipcMain.on('give-me-a-stream', (event, msg) => {
  // 渲染进程已经把一个希望我们回包用的 MessagePort 送了过来。
  const [replyPort] = event.ports

  // 这里同步地发送消息;我们也可以把端口存起来,改为异步发送。
  for (let i = 0; i < msg.count; i++) {
    replyPort.postMessage(msg.element)
  }

  // 发送完毕后显式关闭端口,向对端表明不会再发送更多消息。
  // 这一步并非必须——如果不显式 close,端口最终会被垃圾回收,
  // 同样会触发渲染进程侧的 'close' 事件。
  replyPort.close()
})

这个模式把"回应"从单个返回值扩展成可关闭的数据流,可用于进度上报、日志推送、批量结果返回等场景;渲染进程端依靠 Electron 独有的 onclose 回调感知流的自然结束。

典型用例四:主进程直连 context-isolated 页面的主世界

当开启 context isolation 后,主进程发往渲染进程的 IPC 消息默认投递到隔离世界(isolated world),而非页面的主世界(main world)。若想让消息直达页面主世界,通常要经由 preload 的隔离世界转发。利用 MessagePort 的 transfer 能力,可以做到几乎无感的直连:preload 只负责"递端口",随后的业务消息走专用 channel,无需反复穿越隔离世界。

主进程侧创建端口并投递给 preload:

const { BrowserWindow, app, MessageChannelMain } = require('electron')

const path = require('node:path')

app.whenReady().then(async () => {
  // 创建开启 contextIsolation 的 BrowserWindow。
  const bw = new BrowserWindow({
    webPreferences: {
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
    }
  })
  bw.loadURL('index.html')

  // 这条 channel 的一端将交给 context-isolated 页面的主世界。
  const { port1, port2 } = new MessageChannelMain()

  // 在对方注册监听前发消息是合法的,消息会排队直到监听器注册。
  port2.postMessage({ test: 21 })

  // 我们也能从渲染进程主世界接收消息。
  port2.on('message', (event) => {
    console.log('from renderer main world:', event.data)
  })
  port2.start()

  // preload 脚本会收到这条 IPC 消息并把端口转交给主世界。
  bw.webContents.postMessage('main-world-port', null, [port1])
})

preload 在页面 load 完成后再用普通 window.postMessage 把端口从隔离世界转交到主世界:

const { ipcRenderer } = require('electron')

// 需要等待主世界就绪后才能发送端口。在 preload 中创建该 Promise,
// 可以确保 onload 监听器一定在 load 事件触发前注册。
const windowLoaded = new Promise(resolve => {
  window.onload = resolve
})

ipcRenderer.on('main-world-port', async (event) => {
  await windowLoaded
  // 用常规的 window.postMessage 把端口从隔离世界转移到主世界。
  window.postMessage('main-world-port', '*', event.ports)
})

页面主世界的脚本拿到端口后即可与主进程双向通信:

<script>
window.onmessage = (event) => {
  // event.source === window 表示消息来自 preload 脚本,
  // 而非来自 <iframe> 等其他来源。
  if (event.source === window && event.data === 'main-world-port') {
    const [ port ] = event.ports
    // 拿到端口后,即可与主进程直接通信。
    port.onmessage = (event) => {
      console.log('from main process:', event.data)
      port.postMessage(event.data.test * 2)
    }
  }
}
</script>

主进程侧的 port2.on('message', ...) 会打印 from renderer main world: 42。整个链路中,业务数据始终走专用 MessageChannel,隔离世界仅在建立阶段扮演一次"递送员",后续不再介入,兼顾了安全隔离与通信直连。

小结与选型建议

综合上述内容,MessagePort 在 Electron 中的定位可以概括为:

  1. 跨上下文、跨进程的信道复用:端口可以在渲染进程或主进程创建,经 ipcRenderer.postMessage / WebContents.postMessage 转移;常规 send / invoke 无法转移端口。
  2. 点对点直连:通过主进程"接线"后,两个渲染进程、隐藏窗口 Worker 与业务窗口之间可绕开主进程中转,直接通信。
  3. 主进程侧一等公民MessageChannelMainMessagePortMainAPI)把标准 Channel Messaging 平移到 Node.js 事件体系;MessagePortMainlib/browser/message-port-main.ts 中继承自 EventEmitter,底层配对创建由 electron_browser_message_port 原生绑定提供(见 lib/browser/api/message-channel.tsshell/browser/api/message_port.cc)。
  4. 独有增强close 事件让通道的关闭可被对端感知,是实现"响应流"、资源清理与状态同步的关键设施。

选型时可按场景判断:轻量一问一答继续用 invoke;需要持续回包选"响应流";两个页面 / Worker 需要高频直连选"渲染进程直连 / Worker 直连";需要兼顾 context isolation 与性能时,用 preload 一次性转移端口直达主世界。

登录后查看全文
热门项目推荐
相关项目推荐