因为主线程和渲染线程之间的隔离(安全策略)我们想调用原生的一些方法比如读写文件、打开对话框等等是无法在渲染线程中调用的,于是我们可以利用Electron提供的IPC通道的方式进行通信。
首先我们可以将通信按通信的对象划分为四类:渲染->主线程、渲染->主线程(需要返回值)、主线程->渲染线程和渲染线程->渲染线程
PS:最佳实践是需要在preload.js中暴露electron的api的,为了减少篇幅只展示一次示例。
js1//preload.js2 3import { contextBridge } from 'electron' // 导入contextBridge模块4 5import { electronAPI } from '@electron-toolkit/preload' // 导入electronAPI模块6 7 8 9// Custom APIs for renderer10 11const api = {} // 定义一个空对象,用于存储自定义的API12 13 14 15// Use `contextBridge` APIs to expose Electron APIs to16 17// renderer only if context isolation is enabled, otherwise18 19// just add to the DOM global.20 21if (process.contextIsolated) { // 如果启用了上下文隔离22 23 try {24 25 contextBridge.exposeInMainWorld('electron', electronAPI) // 将electronAPI暴露给渲染进程26 27 contextBridge.exposeInMainWorld('api', api) // 将api暴露给渲染进程28 29 } catch (error) {30 31 console.error(error) // 如果出现错误,打印错误信息32 33 }34 35} else {36 37 window.electron = electronAPI // 如果没有启用上下文隔离,将electronAPI添加到DOM全局38 39 window.api = api // 如果没有启用上下文隔离,将api添加到DOM全局40 41}
渲染->主线程
如果要向主线程发送消息,需要在渲染线程使用ipcRenderer.send这个API发送消息,然后在主线程使用ipcMain.on来接收
示例:
vue1//changeTitle.vue2 3//......4 5<script setup>6//......7const changeTitle = async () => {8 9 await window.electron.ipcRenderer.send('change-title', data.title)10}11</script>
js1//main.js2 3//......4 5ipcMain.on('change-title',()=>{6const webContents = event.sender 7const win = BrowserWindow.fromWebContents(webContents) 8win.setTitle(title)9})
渲染线程->主线程(需要返回值)
如果当我们需要向主线程发送消息,等待主线程返回一个结果,这个时候我们就可以使用ipcRenderer.invoke与 ipcMain.handle搭配使用来完成。除了调用的方法不同和需要传值以外,和不需要返回值的非常相似。
在接下来的示例中会展示一个从渲染器进程打开一个原生的对话框并返回符合条件的文件路径。
示例:
vue1// ScriptList.vue2 3//......4 5<script setup>6//传送open-directory-dialog事件获取脚本文件夹路径7 8const openDialog = () => {9 10 window.electron.ipcRenderer.invoke('open-directory-dialog').then(async (res) => {11 12 console.log(res)13 14 if (res == '') {15 16 return17 18 }19 20 data.scriptDirectory = res21 22 scriptStore.updateScriptDirectory(res)23 24 await getScriptList()25 26 })27 28}29 30 31 32//通过ipcRenderer向主进程发送消息,获取脚本列表33 34const getScriptList = async () => {35 36 if (scriptStore.scriptDirectory === '') return37 38 const scriptList = await window.electron.ipcRenderer.invoke(39 40 'get-script-list',41 42 scriptStore.scriptDirectory43 44 )45 46 47 48 scriptStore.updateScripts(scriptList)49 50 51 52 await getScriptStatus(scriptList)53 54}55</script>
js1//main.js2 3//......4 5ipcMain.handle('open-directory-dialog', async (event) => {6 7 console.log('open-directory-dialog')8 9 const { dialog } = require('electron')10 11 try {12 13 const result = await dialog.showOpenDialog({14 15 properties: ['openDirectory']16 17 })18 19 return result.filePaths[0]20 21 } catch (error) {22 23 console.log(error)24 25 }26 27})28 29 30 31//监听渲染进程的事件,从渲染进程中获取到本地文件夹路径,遍历获取文件夹中的js文件名称(过滤node_modules、.git文件夹)32 33//组成文件名称,文件路径对象数组 ,返回给渲染进程34 35 36 37ipcMain.handle('get-script-list', async (event, dirPath) => {38 39 console.log('get-script-list')40 41 42 43 const getJSFiles = (dirPath) => {44 45 let jsFiles = []46 47 48 49 // 读取目录内容50 51 const files = fs.readdirSync(dirPath)52 53 54 55 for (const file of files) {56 57 const filePath = path.join(dirPath, file)58 59 const stat = fs.statSync(filePath)60 61 62 63 if (stat.isDirectory()) {64 65 // 如果是目录,递归调用66 67 if (file !== 'node_modules' && file !== '.git') {68 69 if (file !== 'dist') {70 71 jsFiles = jsFiles.concat(getJSFiles(filePath))72 73 }74 75 }76 77 } else if (path.extname(file).toLowerCase() === '.js') {78 79 // 如果是 JS 文件,添加包含路径和名称的对象到结果数组80 81 jsFiles.push({82 83 path: filePath,84 85 name: file86 87 })88 89 }90 91 }92 93 94 95 return jsFiles96 97 }98 99 try {100 101 // const files = fs.readdirSync(dirPath)102 103 // 深度查找所有的js文件104 105 const scriptList = getJSFiles(dirPath)106 107 return scriptList108 109 } catch (error) {110 111 console.log(error)112 113 }114 115})
主线程->渲染线程
将消息从主进程发送到渲染器进程时,需要指定是哪一个渲染器接收消息。 消息需要通过其 WebContents实例发送到渲染器进程。 此 WebContents实例包含一个send方法,其使用方式与 ipcRenderer.send 相同。
示例:
js1//main.js2 3function createWindow () { 4const mainWindow = new BrowserWindow({ 5webPreferences: { 6preload: path.join(__dirname, 'preload.js') 7} 8}) 9 10const menu = Menu.buildFromTemplate([ 11{ 12label: app.name, 13submenu: [ 14{ 15click: () => mainWindow.webContents.send('update-counter', 1), 16label: 'Increment' 17}, 18{ 19click: () => mainWindow.webContents.send('update-counter', -1), 20label: 'Decrement' 21} 22] 23} 24 25]) 26 27Menu.setApplicationMenu(menu) 28mainWindow.loadFile('index.html') 29
HTML1//index.html2<!DOCTYPE html> 3<html> 4<head> 5<meta charset="UTF-8"> 6<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> 7<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'"> 8<title>Menu Counter</title> 9</head> 10<body> 11Current value: <strong id="counter">0</strong> 12<script>13const counter = document.getElementById('counter') 14 15window.electronAPI.onUpdateCounter((value) => { 16const oldValue = Number(counter.innerText) 17const newValue = oldValue + value 18counter.innerText = newValue.toString() 19window.electronAPI.counterValue(newValue) 20})21</script> 22</body> 23</html>
PS:对于从主进程到渲染器进程的 IPC,没有与 ipcRenderer.invoke 等效的 API,所以我们可以通过添加一个回调的方式来实现
示例:
HTML1//index.html2 3<!DOCTYPE html> 4<html> 5<head> 6<meta charset="UTF-8"> 7<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP --> 8<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'"> 9<title>Menu Counter</title> 10</head> 11<body> 12Current value: <strong id="counter">0</strong> 13<script>14const counter = document.getElementById('counter') 15 16window.electronAPI.onUpdateCounter((value) => { 17const oldValue = Number(counter.innerText) 18const newValue = oldValue + value 19counter.innerText = newValue.toString() 20window.electronAPI.counterValue(newValue) 21})22</script> 23</body> 24</html>
js1//main.js2 3//...4ipcMain.on('counter-value', (_event, value) => { 5console.log(value) // will print value to Node console 6})7//...
渲染进程->渲染进程
一般遇上这种情况,推荐使用主进程中转的方式实现,但是实际使用中会出现主进程负载压力过大的问题影响性能,所以我们可以使用electron提供的消息端口的方式进行通信。
消息代理方式:
主进程设置监听器监听消息,然后在preload.js中设置发送信息的api以实现转发消息。
最佳实践:
js1//main.js2 3// main.js (主进程,基本相同)4const { app, BrowserWindow, ipcMain } = require('electron')5const path = require('path')6 7let window1 = null8let window2 = null9 10function createWindows() {11 // 创建第一个窗口12 window1 = new BrowserWindow({13 width: 800,14 height: 600,15 webPreferences: {16 contextIsolation: true,17 preload: path.join(__dirname, 'preload.js')18 }19 })20 21 // 创建第二个窗口22 window2 = new BrowserWindow({23 width: 800,24 height: 600,25 webPreferences: {26 contextIsolation: true,27 preload: path.join(__dirname, 'preload.js')28 }29 })30 31 window1.loadFile('window1.html')32 window2.loadFile('window2.html')33 34 // 处理窗口间通信35 ipcMain.on('window1-to-window2', (event, data) => {36 window2.webContents.send('message-from-window1', data)37 })38 39 ipcMain.on('window2-to-window1', (event, data) => {40 window1.webContents.send('message-from-window2', data)41 })42}43 44app.whenReady().then(createWindows)
js1 2// preload.js3const { contextBridge, ipcRenderer } = require('electron')4 5contextBridge.exposeInMainWorld('electronAPI', {6 // 发送消息到其他窗口7 sendToOtherWindow: (channel, data) => {8 ipcRenderer.send(channel, data)9 },10 // 接收来自其他窗口的消息11 onReceiveMessage: (channel, callback) => {12 ipcRenderer.on(channel, (event, data) => callback(data))13 }14})
如果需要更复杂功能可以参考下述最佳实践(使用ts)
ts1// types.ts2interface WindowMessage {3 type: string4 payload: any5 timestamp: string6 sender: string7}8 9// messageManager.ts10class WindowMessageManager {11 private static instance: WindowMessageManager12 private messageQueue: Map<string, WindowMessage[]>13 14 private constructor() {15 this.messageQueue = new Map()16 }17 18 static getInstance() {19 if (!WindowMessageManager.instance) {20 WindowMessageManager.instance = new WindowMessageManager()21 }22 return WindowMessageManager.instance23 }24 25 queueMessage(targetWindow: string, message: WindowMessage) {26 if (!this.messageQueue.has(targetWindow)) {27 this.messageQueue.set(targetWindow, [])28 }29 this.messageQueue.get(targetWindow)?.push(message)30 }31 32 getQueuedMessages(windowId: string): WindowMessage[] {33 return this.messageQueue.get(windowId) || []34 }35 36 clearQueue(windowId: string) {37 this.messageQueue.delete(windowId)38 }39}
如果使用Messageport的话可以参考以下的最佳实践。
最佳实践:
js1// main.js2const { app, BrowserWindow, MessageChannelMain } = require('electron')3const path = require('path')4 5let window1 = null6let window2 = null7 8async function createWindows() {9 // 创建两个窗口10 window1 = new BrowserWindow({11 width: 800,12 height: 600,13 webPreferences: {14 contextIsolation: true,15 preload: path.join(__dirname, 'preload.js')16 },17 title: 'Window 1'18 })19 20 window2 = new BrowserWindow({21 width: 800,22 height: 600,23 webPreferences: {24 contextIsolation: true,25 preload: path.join(__dirname, 'preload.js')26 },27 title: 'Window 2'28 })29 30 // 加载HTML文件31 await window1.loadFile('window1.html')32 await window2.loadFile('window2.html')33 34 // 创建消息通道35 const { port1, port2 } = new MessageChannelMain()36 37 // 将端口发送到各个渲染器38 window1.webContents.postMessage('port-setup', null, [port1])39 window2.webContents.postMessage('port-setup', null, [port2])40 41 // 错误处理42 window1.webContents.on('destroyed', () => {43 port1.close()44 })45 46 window2.webContents.on('destroyed', () => {47 port2.close()48 })49}50 51app.whenReady().then(createWindows)
js1// preload.js2const { contextBridge, ipcRenderer } = require('electron')3 4let messagePort = null5 6contextBridge.exposeInMainWorld('electronMessagePort', {7 // 初始化端口8 onPortSetup: (callback) => {9 ipcRenderer.once('port-setup', (event) => {10 messagePort = event.ports[0]11 callback()12 })13 },14 15 // 发送消息16 postMessage: (message) => {17 if (messagePort) {18 messagePort.postMessage(message)19 } else {20 console.error('MessagePort not initialized')21 }22 },23 24 // 接收消息25 onMessage: (callback) => {26 if (messagePort) {27 messagePort.onmessage = (event) => callback(event.data)28 } else {29 console.error('MessagePort not initialized')30 }31 },32 33 // 开始监听34 start: () => {35 if (messagePort) {36 messagePort.start()37 }38 }39})
如果使用ts进行开发可以参考以下的最佳实践
最佳实践:
ts1// types.ts2interface MessageData {3 id: number4 from: string5 content: string6 timestamp: string7 type?: string8 metadata?: Record<string, unknown>9}10 11// messagePortManager.ts12class MessagePortManager {13 private static instance: MessagePortManager14 private port: MessagePort | null = null15 private messageQueue: MessageData[] = []16 private isConnected: boolean = false17 private listeners: Set<(message: MessageData) => void> = new Set()18 19 private constructor() {}20 21 static getInstance() {22 if (!MessagePortManager.instance) {23 MessagePortManager.instance = new MessagePortManager()24 }25 return MessagePortManager.instance26 }27 28 setPort(port: MessagePort) {29 this.port = port30 this.setupPort()31 }32 33 private setupPort() {34 if (!this.port) return35 36 this.port.onmessage = (event) => {37 const message = event.data as MessageData38 this.notifyListeners(message)39 }40 41 this.port.onmessageerror = (event) => {42 console.error('MessagePort error:', event)43 }44 45 this.port.start()46 this.isConnected = true47 this.flushMessageQueue()48 }49 50 private flushMessageQueue() {51 while (this.messageQueue.length > 0) {52 const message = this.messageQueue.shift()53 if (message) {54 this.sendMessage(message)55 }56 }57 }58 59 sendMessage(message: MessageData) {60 if (this.isConnected && this.port) {61 try {62 this.port.postMessage(message)63 return true64 } catch (error) {65 console.error('Error sending message:', error)66 return false67 }68 } else {69 this.messageQueue.push(message)70 return false71 }72 }73 74 addListener(callback: (message: MessageData) => void) {75 this.listeners.add(callback)76 return () => this.listeners.delete(callback)77 }78 79 private notifyListeners(message: MessageData) {80 this.listeners.forEach(listener => {81 try {82 listener(message)83 } catch (error) {84 console.error('Error in message listener:', error)85 }86 })87 }88 89 close() {90 if (this.port) {91 this.port.close()92 this.port = null93 this.isConnected = false94 }95 }96}
ts1// 在预加载脚本中2const manager = MessagePortManager.getInstance()3 4contextBridge.exposeInMainWorld('electronMessagePort', {5 onPortSetup: (callback) => {6 ipcRenderer.once('port-setup', (event) => {7 manager.setPort(event.ports[0])8 callback()9 })10 },11 12 sendMessage: (message) => {13 return manager.sendMessage(message)14 },15 16 onMessage: (callback) => {17 return manager.addListener(callback)18 }19})