在 Electron 应用中处理主进程与渲染进程通信的生命周期
先把 IPC 的注册时机定下来
前阵子改一个 Electron 项目,窗口开开关关,消息有时候能收到,有时候像石沉大海。查了一圈才发现,问题不在 IPC 本身,而在我没把主进程和渲染进程的通信当成一个有生命周期的东西来看。
最开始的坑是 ipcMain.handle 的注册位置。我图省事,把它塞进了 createWindow 里面,想着每次开窗口就注册一次。结果第二个窗口一开,控制台直接报 Attempted to register a second handler for 'xxx'。后来才反应过来,ipcMain.handle 是挂在主进程上的,不是挂在某个窗口上的,同一个 channel 只能注册一次。现在我的做法是:主进程启动后、创建窗口之前,把 ipcMain.handle 和 ipcMain.on 统一注册好。窗口可以开很多个,但 handler 只留一份,消息里用 event.sender 去区分是谁发来的。
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
let mainWindow = null
// 全局只注册一次
ipcMain.handle('app:get-state', async () => {
return { startedAt: app.getStartTime() }
})
ipcMain.on('renderer:ready', (event) => {
if (!event.sender.isDestroyed()) {
event.sender.send('app:state', { ready: true })
}
})
function createWindow () {
mainWindow = new BrowserWindow({
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
})
mainWindow.loadFile('index.html')
mainWindow.on('closed', () => {
mainWindow = null
})
}
app.whenReady().then(createWindow)
页面加载完,不代表你的前端准备好了
我一开始很喜欢在 did-finish-load 里直接 webContents.send 给渲染进程推数据。后来发现这事不太靠谱:did-finish-load 只能说明页面加载完了,Vue 或 React 那边的组件可能还没挂载,监听器还没注册。消息发过去,对面根本没人接,于是就“丢”了。
现在我不赌这个时机了。preload 里暴露一个 ready(),渲染进程在业务入口处主动发一次 renderer:ready,主进程收到后再推初始状态。这样虽然多了一步,但顺序是明确的。主进程不用猜渲染进程有没有准备好,渲染进程也不用猜主进程会不会提前发消息。
preload 也不是万能胶,它和页面是绑在一起的。页面一刷新,preload 会重新执行,渲染进程里的监听器也会重来一遍。所以我在 preload 里暴露 onState 时,会让它返回一个取消监听的函数,渲染进程组件卸载时调一下,免得开发环境热更新几次之后,同一条消息被处理三四遍。
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('api', {
getState: () => ipcRenderer.invoke('app:get-state'),
ready: () => ipcRenderer.send('renderer:ready'),
onState: (callback) => {
const listener = (_event, state) => callback(state)
ipcRenderer.on('app:state', listener)
return () => ipcRenderer.removeListener('app:state', listener)
}
})
运行时消息:发之前先看看对面还在不在
窗口关闭以后,BrowserWindow 和它的 webContents 会被销毁。如果主进程里还有定时器、托盘菜单或者后台任务在往这个窗口发消息,就会遇到 Object has been destroyed。所以我现在发消息前会先检查一遍:
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.webContents.isDestroyed()) {
mainWindow.webContents.send('app:state', state)
}
另外,invoke 和 send 的用法最好分开。需要请求响应的就用 ipcRenderer.invoke 配 ipcMain.handle,单向通知就用 send 配 on。别把 IPC 当成全局变量到处塞,channel 名字也尽量统一管理,不然过两个月自己都记不清哪个消息是谁发的。
关窗和退出不是一回事
窗口关闭会触发 closed,这时候要把主进程里持有的窗口引用置空。应用退出则走 before-quit、will-quit 这一套。macOS 上把所有窗口关了,应用不一定退出,所以清理逻辑不能只写在 window-all-closed 里。我的习惯是:定时器、托盘、全局快捷键这些跟着应用生命周期走的东西,在 before-quit 里清掉;跟窗口走的监听,在 closed 里处理。别嫌土,这样至少不会出现窗口都关了,主进程还在往一个不存在的 webContents 发消息的情况。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。