Electron實戰之程式間通訊

發表於2024-02-18

程式間通訊(IPC)並非僅限於 Electron,而是源自甚至早於 Unix 誕生的概念。儘管“程式間通訊”這個術語的確創造於何時並不清楚,但將資料傳遞給另一個程式或程式的理念可以追溯至 1964 年,當時 Douglas McIlroy 在 Unix 的第三版(1973 年)中描述了 Unix 管道的概念。

We should have some ways of coupling programs like garden hose--screw in another segment when it becomes when it becomes necessary to massage data in another way.

例如,我們可以透過使用管道運算子(|)將一個程式的輸出傳遞到另一個程式。

# 列出當前目錄下的所有.ts檔案
ls | grep .ts

在 Unix 系統中,管道只是 IPC 的一種形式,還有許多其他形式,比如訊號、訊息佇列、訊號量和共享記憶體。

一、ipcMain 和 ipcRenderer

與 Chromium 相同,Electron 使用程式間通訊(IPC)來在程式之間進行通訊,在介紹 Electron 程式間通訊前,我們必須先認識一下 Electron 的 2 個模組。

  • ipcMain 是一個僅在主程式中以非同步方式工作的模組,用於與渲染程式交換訊息。
  • ipcRenderer 是一個僅在渲染程式中以非同步方式工作的模組,用於與主程式交換訊息。

ipcMain 和 ipcRenderer 是 Electron 中負責通訊的兩個主要模組。它們繼承自 NodeJS 的 EventEmitter 模組。在 EventEmitter 中允許我們向指定 channel 傳送訊息。channel 是一個字串,在 Electron 中 ipcMain 和 ipcRenderer 使用它來發出和接收事件/資料。

// 接受訊息
// EventEmitter: ipcMain / ipcRenderer
EventEmitter.on("string", function callback(event, messsage) {});


// 傳送訊息
// EventEmitter: win.webContents / ipcRenderer
EventEmitter.send("string", "mydata");

二、渲染程式 -> 主程式

大多數情況下的通訊都是從渲染程式到主程式,渲染程式依賴 ipcRenderer 模組給主程式傳送訊息,官方提供了三個方法:

  • ipcRenderer.send(channel, ...args)
  • ipcRenderer.invoke(channel, ...args)
  • ipcRenderer.sendSync(channel, ...args)

channel 表示的就是事件名(訊息名稱), args 是引數。需要注意的是引數將使用結構化克隆演算法進行序列化,就像瀏覽器的 window.postMessage 一樣,因此不會包含原型鏈。傳送函式、Promise、Symbol、WeakMap 或 WeakSet 將會丟擲異常。

2.1 ipcRenderer.send

渲染程式透過 ipcRenderer.send 傳送訊息:

// render.js
import { ipcRenderer } from 'electron';


function sendMessageToMain() {
  ipcRenderer.send('my_channel', 'my_data');
}

主程式透過 ipcMain.on 來接收訊息:

// main.js
import { ipcMain } from 'electron';


ipcMain.on('my_channel', (event, message) => {
  console.log(`receive message from render: ${message}`) 
})

請注意,如果使用 send 來傳送資料,如果你的主程式需要回復訊息,那麼需要使用 event.replay 來進行回覆:

// main.js
import { ipcMain } from 'electron';


ipcMain.on('my_channel', (event, message) => {
  console.log(`receive message from render: ${message}`)
  event.reply('reply', 'main_data')
})

同時,渲染程式需要進行額外的監聽:

// renderer.js
ipcRenderer.on('reply', (event, message) => { 
  console.log('replyMessage', message);
})

2.2  ipcRenderer.invoke

渲染程式透過 ipcRenderer.invoke 傳送訊息:

// render.js
import { ipcRenderer } from 'electron';


async function invokeMessageToMain() {
  const replyMessage = await ipcRenderer.invoke('my_channel', 'my_data');
  console.log('replyMessage', replyMessage);
}

主程式透過 ipcMain.handle 來接收訊息:

// main.js
import { ipcMain } from 'electron';
ipcMain.handle('my_channel', async (event, message) => {
  console.log(`receive message from render: ${message}`);
  return 'replay';
});

注意,渲染程式透過 ipcRenderer.invoke 傳送訊息後,invoke 的返回值是一個 Promise<pending> 。主程式回覆訊息需要透過 return 的方式進行回覆,而 ipcRenderer 只需要等到 Promise resolve 即可獲取到返回的值。

 

2.3 ipcRender.sendSync

渲染程式透過 ipcRender.sendSync 來傳送訊息:

// render.js
import { ipcRenderer } from 'electron';


async function sendSyncMessageToMain() {
  const replyMessage = await ipcRenderer.sendSync('my_channel', 'my_data');
  console.log('replyMessage', replyMessage);
}

主程式透過 ipcMain.on 來接收訊息:

// main.js
import { ipcMain } from 'electron';
ipcMain.on('my_channel', async (event, message) => {
  console.log(`receive message from render: ${message}`);
  event.returnValue = 'replay';
});

注意,渲染程式透過 ipcRenderer.sendSync 傳送訊息後,主程式回覆訊息需要透過 e.returnValue 的方式進行回覆,如果 event.returnValue 不為 undefined 的話,渲染程式會等待 sendSync 的返回值才執行後面的程式碼。

2.4 小結

上面我們介紹了從渲染程式到主程式的幾個通訊方法,總結如下。

  • ipcRenderer.send: 這個方法是非同步的,用於從渲染程式向主程式傳送訊息。它傳送訊息後不會等待主程式的響應,而是立即返回,適合在不需要等待主程式響應的情況下傳送訊息。
  • ipcRenderer.sendSync: 與 ipcRenderer.send 不同,這個方法是同步的,也是用於從渲染程式向主程式傳送訊息,但是它會等待主程式返回響應。它會阻塞當前程式,直到收到主程式的返回值或者超時。
  • ipcRenderer.invoke: 這個方法也是用於從渲染程式向主程式傳送訊息,但是它是一個非同步的方法,可以方便地在渲染程式中等待主程式返回 Promise 結果。相對於 send 和 sendSync,它更適合處理非同步操作,例如主程式返回 Promise 的情況。

三、主程式 -> 渲染程式

主程式向渲染程式傳送訊息一種方式是當渲染程式透過 ipcRenderer.send、ipcRenderer.sendSync、ipcRenderer.invoke 向主程式傳送訊息時,主程式透過 event.replay、event.returnValue、return ... 的方式進行傳送。這種方式是被動的,需要等待渲染程式先建立訊息推送機制,主程式才能進行回覆。

其實除了上面說的幾種被動接收訊息的模式進行推送外,還可以透過 webContents 模組進行訊息通訊。

3.1 ipcMain 和 webContents

主程式使用 ipcMain 模組來監聽來自渲染程式的事件,透過 event.sender.send() 方法向渲染程式傳送訊息。

// 主程式
import { ipcMain, BrowserWindow } from 'electron';


ipcMain.on('messageFromMain', (event, arg) => {
  event.sender.send('messageToRenderer', 'Hello from Main!');
});

3.2 BrowserWindow.webContents.send

BrowserWindow.webContents.send 可以在主程式中直接使用 BrowserWindow 物件的 webContents.send() 方法向渲染程式傳送訊息。

// 主程式
import { BrowserWindow } from 'electron';


const mainWindow = new BrowserWindow();
mainWindow.loadFile('index.html');


// 在某個事件或條件下傳送訊息
mainWindow.webContents.send('messageToRenderer', 'Hello from Main!');

3.3 小結

不管是透過 event.sender.send() 還是 BrowserWindow.webContents.send 的方式,如果你只是單視窗的資料通訊,那麼本質上是沒什麼差異的。但是如果你想要傳送一些資料到特定的視窗,那麼你可以直接使用 BrowserWindow.webContents.send 這種方式。

四、渲染程式 -> 渲染程式

預設情況下,渲染程式和渲染程式之間是無法直接進行通訊的。

image.png

雖然說無法直接通訊,但是還是有一些“曲線救國”的方式。

4.1 利用主程式作為中間人

首先,需要在主程式註冊一個事件監聽程式,監聽來自渲染程式的事件:

// main.js


// window 1
function createWindow1 () {
  window1 = new BrowserWindow({width: 800,height: 600})
  window1.loadURL('window1.html')
  window1.on('closed', function () {
     window1 = null
  })
  return window1
}


// window 2
function createWindow2 () {
  window2 = new BrowserWindow({width: 800, height: 600})
  window2.loadURL('window2.html')
  window2.on('closed', function () {
    window2 = null
  })
  return window2
}


app.on('ready', () => {
  createWindow1();
  createWindow2();
  ipcMain.on('win1-msg', (event, arg) => {
    // 這條訊息來自 window 1
    console.log("name inside main process is: ", arg); 
    // 傳送給 window 2 的訊息.
    window2.webContents.send( 'forWin2', arg );
  });
})

然後,在 window2 視窗建立一個監聽事件:

ipcRenderer.on('forWin2', function (event, arg){
  console.log(arg);
});

這樣,window1 傳送的 win1-msg 事件,就可以傳輸到 window2:

ipcRenderer.send('win1-msg', 'msg from win1');

4.2 使用 MessagePort

上面的傳輸方式雖然可以實現渲染程式之間的通訊,但是非常依賴主程式,寫起來也比較麻煩,那有什麼不依賴於主程式的方式嘛?那當然也是有的,那就是 MessagePort。

MessagePort 並不是 Electron 提供的能力,而是基於 MDN 的 Web 標準 API,這意味著它可以在渲染程式直接建立。同時 Electron 提供了 node.js 側的實現,所以它也能在主程式建立。

接下來,我們將透過一個示例來描述如何透過 MessagePort 來實現渲染程式之間的通訊。

4.2.1 主程式中建立 MessagePort

import { BrowserWindow, app, MessageChannelMain } from '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'
    }
  })


  // 建立通道
  const { port1, port2 } = new MessageChannelMain()


  // webContents準備就緒後,使用postMessage向每個webContents傳送一個埠。
  mainWindow.once('ready-to-show', () => {
    mainWindow.webContents.postMessage('port', null, [port1])
  })


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

例項化 MessageChannel 類之後,就產生了兩個 port: port1 和 port2。接下來只要讓 渲染程式1 拿到 port1、渲染程式2 拿到 port2,那麼現在這兩個程式就可以透過 port.onmessage 和 port.postMessage 來收發彼此間的訊息了。如下:

// mainWindow
port1.onmessage = (event) => {
  console.log('received result:', event.data)
};
port1.postMessage('我是渲染程式一傳送的訊息');


// secondaryWindow
port2.onmessage = (event) => {
  console.log('received result:', event.data)
};
port2.postMessage('我是渲染程式二傳送的訊息');

4.2.2 渲染程式中獲取 port

有了上面的知識,我們最重要的任務就是需要獲取主程式中建立的 port 物件,要做的是在你的預載入指令碼(preload.js)中透過 IPC 接收 port,並設定相應的監聽器。

// preloadMain.js
// preloadSecondary.js
const { ipcRenderer } = require('electron')


ipcRenderer.on('port', e => {
  // 接收到埠,使其全域性可用。
  window.electronMessagePort = e.ports[0]


  window.electronMessagePort.onmessage = messageEvent => {
    // 處理訊息
  }
})

4.3 訊息通訊

透過上面的一些操作後,就可以在應用程式的任何地方呼叫 postMessage 方法向另一個渲染程式傳送訊息。

// mainWindow renderer.js
// 在 renderer 的任何地方都可以呼叫 postMessage 向另一個程式傳送訊息
window.electronMessagePort.postMessage('ping')

相關文章