众所周知Electron是基于Chromium,所以某种程度上可以算是一个小浏览器(?),这就形成了我们现在需要了解的一个多线程模型。
首先,我们讲讲Chrome里的多线程模型: 早期的浏览器都是使用单个进程来处理每一个标签页的所有功能,导致当一个标签页出现崩溃或者无响应的情况时,整个浏览器都面临崩溃或无响应。为了解决这个问题,Chrome开发团队使用多线程来处理每一个标签标签页,同时利用一个主进程(浏览器进程)来处理整个浏览器的生命周期。
Electron也继承了这一思想(还是Chromium 一直是Chromium),每个 Electron 应用都有一个单一的主进程,作为应用程序的入口点。 主进程在 Node.js 环境中运行,这意味着它具有 require 模块和使用所有 Node.js API 的能力。而主线程主要起到的就是创建应用程序窗口和与原生api进行交互。
而我们渲染则调用的是渲染进程(类似与标签页),这就给我们一个使用框架进行开发的机会,不过有一个非常大的问题,在设计上渲染进程是无权访问require或者其他的nodeapi,所以这就引出下一个我们需要了解的概念:Preload脚本
在Preload脚本中包含了那些执行于渲染器进程中,且先于网页内容开始加载的代码 。 这些脚本虽运行于渲染器的环境中,却因能访问 Node.js API 而拥有了更多的权限。所以我们是能在Preload脚本中启动vite、webpack或者是node(Production)以实现框架热更新和部署的。
在项目中,我们一般的最佳实践是分为两个js文件,分别是主线程的main.js和调用node api的preload.js。渲染线程也就是页面专门放在renderer文件夹中。
主线程:
js1//main.js2 3import { app, shell, BrowserWindow, ipcMain, ipcRenderer, utilityProcess } from 'electron'4 5import { join } from 'path'6 7import { electronApp, optimizer, is } from '@electron-toolkit/utils'8 9import icon from '../../resources/icon.svg'10 11import { dir } from 'console'12 13const fs = require('fs')14 15const path = require('path')16 17function createWindow() {18 19 // Create the browser window.20 21 const mainWindow = new BrowserWindow({22 23 width: 900,24 25 height: 670,26 27 show: false,28 29 autoHideMenuBar: true,30 31 ...(process.platform === 'linux' ? { icon } : {}),32 33 webPreferences: {34 35 preload: join(__dirname, '../preload/index.js'),36 37 sandbox: false38 39 }40 41 })42 43 44 45 mainWindow.on('ready-to-show', () => {46 47 mainWindow.show()48 49 })50 51 52 53 mainWindow.webContents.setWindowOpenHandler((details) => {54 55 shell.openExternal(details.url)56 57 return { action: 'deny' }58 59 })60 61 62 63 // HMR for renderer base on electron-vite cli.64 65 // Load the remote URL for development or the local html file for production.66 67 if (is.dev && process.env['ELECTRON_RENDERER_URL']) {68 69 mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])70 71 } else {72 73 mainWindow.loadFile(join(__dirname, '../renderer/index.html'))74 75 }76 77 78 79 //启动新的子线程来运行本地js脚本80 81 //捕获脚本的log并保存在脚本路径82 83 // 捕获脚本的运行状态包括错误84 85 ipcMain.handle('run-script', (event, scriptPath) => {86 87 const { fork } = utilityProcess88 89 const child = fork(scriptPath, [], { silent: true })90 91 //将运行状态返回给渲染进程92 93 mainWindow.webContents.send('script-running', true)94 95 console.log('成功将运行状态返回给渲染进程')96 97 // 将子进程的输出日志流式保存至文件98 99 // 文件路径为脚本路径加上时间戳加上.log后缀100 101 const logPath = scriptPath + '-' + Date.now() + '.log'102 103 const logStream = fs.createWriteStream(logPath, { flags: 'a' })104 105 child.stdout.pipe(logStream)106 107 child.stderr.pipe(logStream)108 109 110 111 // 将子进程的运行状态返回给渲染进程112 113 child.on('message', (message) => {114 115 mainWindow.webContents.send('script-message', message)116 117 console.log(message)118 119 })120 121 122 123 child.on('error', (error) => {124 125 mainWindow.webContents.send('script-error', error)126 127 // 将错误保存到脚本路径128 129 const errorPath = scriptPath + '-' + Date.now() + '.error'130 131 fs.appendFile(errorPath, error, (err) => {132 133 if (err) {134 135 console.error('Failed to save error:', err)136 137 } else {138 139 console.log('Error saved to', errorPath)140 141 }142 143 })144 145 console.error(error)146 147 })148 149 child.on('exit', (code) => {150 151 mainWindow.webContents.send('script-exit', false)152 153 console.log(`子进程退出,退出码 ${code}`)154 155 })156 157 })158 159}160 161 162 163// This method will be called when Electron has finished164 165// initialization and is ready to create browser windows.166 167// Some APIs can only be used after this event occurs.168 169app.whenReady().then(() => {170 171 // Set app user model id for windows172 173 electronApp.setAppUserModelId('com.electron')174 175 176 177 // Default open or close DevTools by F12 in development178 179 // and ignore CommandOrControl + R in production.180 181 // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils182 183 app.on('browser-window-created', (_, window) => {184 185 optimizer.watchWindowShortcuts(window)186 187 })188 189 190 191 // IPC test192 193 ipcMain.on('ping', () => console.log('pong'))194 195 196 197 createWindow()198 199 200 201 app.on('activate', function () {202 203 // On macOS it's common to re-create a window in the app when the204 205 // dock icon is clicked and there are no other windows open.206 207 if (BrowserWindow.getAllWindows().length === 0) createWindow()208 209 })210 211})212 213 214 215// Quit when all windows are closed, except on macOS. There, it's common216 217// for applications and their menu bar to stay active until the user quits218 219// explicitly with Cmd + Q.220 221app.on('window-all-closed', () => {222 223 if (process.platform !== 'darwin') {224 225 app.quit()226 227 }228 229})230 231 232 233// In this file you can include the rest of your app"s specific main process234 235// code. You can also put them in separate files and require them here.236 237 238 239//监听渲染进程的事件,调用dialog获取文件夹信息240 241ipcMain.handle('open-directory-dialog', async (event) => {242 243 console.log('open-directory-dialog')244 245 const { dialog } = require('electron')246 247 try {248 249 const result = await dialog.showOpenDialog({250 251 properties: ['openDirectory']252 253 })254 255 return result.filePaths[0]256 257 } catch (error) {258 259 console.log(error)260 261 }262 263})264 265 266 267//监听渲染进程的事件,从渲染进程中获取到本地文件夹路径,遍历获取文件夹中的js文件名称(过滤node_modules、.git文件夹)268 269//组成文件名称,文件路径对象数组 ,返回给渲染进程270 271 272 273ipcMain.handle('get-script-list', async (event, dirPath) => {274 275 console.log('get-script-list')276 277 278 279 const getJSFiles = (dirPath) => {280 281 let jsFiles = []282 283 284 285 // 读取目录内容286 287 const files = fs.readdirSync(dirPath)288 289 290 291 for (const file of files) {292 293 const filePath = path.join(dirPath, file)294 295 const stat = fs.statSync(filePath)296 297 298 299 if (stat.isDirectory()) {300 301 // 如果是目录,递归调用302 303 if (file !== 'node_modules' && file !== '.git') {304 305 if (file !== 'dist') {306 307 jsFiles = jsFiles.concat(getJSFiles(filePath))308 309 }310 311 }312 313 } else if (path.extname(file).toLowerCase() === '.js') {314 315 // 如果是 JS 文件,添加包含路径和名称的对象到结果数组316 317 jsFiles.push({318 319 path: filePath,320 321 name: file322 323 })324 325 }326 327 }328 329 330 331 return jsFiles332 333 }334 335 try {336 337 // const files = fs.readdirSync(dirPath)338 339 // 深度查找所有的js文件340 341 const scriptList = getJSFiles(dirPath)342 343 return scriptList344 345 } catch (error) {346 347 console.log(error)348 349 }350 351})352 353ipcMain.handle('read-file', async (event, filePath) => {354 355 try {356 357 let fs = require('fs').promises // 确保使用 fs.promises358 359 const bufferContent = await fs.readFile(filePath, () => {}, { encoding: 'utf8' })360 361 const content = bufferContent.toString('utf-8')362 363 return content364 365 } catch (error) {366 367 console.error('Error reading file:', error)368 369 throw error // 处理错误370 371 }372 373})374 375//修改后的脚本内容保存到本地376 377 378 379ipcMain.handle('save-script', async (event, filePath, content) => {380 381 try {382 383 let fs = require('fs').promises // 确保使用 fs.promises384 385 await fs.writeFile(filePath, content, 'utf8')386 387 return true388 389 } catch (error) {390 391 console.error('Error writing file:', error)392 393 394 395 return false396 397 }398 399})400 401 402 403//监听渲染进程的事件,获取传入的脚本路径中文件夹的最新log文件,返回给渲染进程404 405// @params scriptPath 脚本路径406 407ipcMain.handle('get-log-file', async (event, scriptPath) => {408 409 const dirPath = path.dirname(scriptPath)410 411 //获取文件夹中的所有log文件412 413 const files = fs.readdirSync(dirPath)414 415 // 利用修改时间筛选出最新的log文件416 417 const logFiles = files.filter((file) => path.extname(file).toLowerCase() === '.log')418 419 const latestLogFile = logFiles.sort(420 421 (a, b) =>422 423 fs.statSync(path.join(dirPath, b)).mtime.getTime() -424 425 fs.statSync(path.join(dirPath, a)).mtime.getTime()426 427 )[0]428 429 // 返回文件内容430 431 return latestLogFile432 433})
渲染线程:
js1//preload.js2 3import { contextBridge } from 'electron'4 5import { electronAPI } from '@electron-toolkit/preload'6 7 8 9// Custom APIs for renderer10 11const api = {}12 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)26 27 contextBridge.exposeInMainWorld('api', api)28 29 } catch (error) {30 31 console.error(error)32 33 }34 35} else {36 37 window.electron = electronAPI38 39 window.api = api40 41}
最后,我们来讲讲效率进程,作为主进程生成多个子进程UtilityProcess API。 主进程在 Node.js 环境中运行,这意味着它具有 require 模块和使用所有 Node.js API 的能力。 效率进程可用于托管,例如:不受信任的服务, CPU 密集型任务或以前容易崩溃的组件 托管在主进程或使用Node.jschild_process.fork API 生成的进程中。 效率进程和 Node 生成的进程之间的主要区别.js child_process模块是实用程序进程可以建立通信 通道与使用MessagePort的渲染器进程。 当需要从主进程派生一个子进程时,Electron 应用程序可以总是优先使用 效率进程API 而不是Node.js child_process.fork API。
最佳实践:
js1// 主进程2const { app, utilityProcess } = require('electron')3 4app.whenReady().then(() => {5 // 创建进程池管理多个utility process6 const processPool = []7 8 function createUtilityProcess() {9 const utility = utilityProcess.fork('worker.js', [], {10 stdio: 'pipe',11 serviceName: `worker-${processPool.length}`12 })13 14 // 错误处理15 utility.on('error', (err) => {16 console.error('Utility process error:', err)17 })18 19 // 退出处理20 utility.on('exit', (code) => {21 console.log(`Worker exited with code ${code}`)22 // 从进程池中移除23 const index = processPool.indexOf(utility)24 if (index > -1) {25 processPool.splice(index, 1)26 }27 })28 29 processPool.push(utility)30 return utility31 }32 33 // 使用示例34 const worker = createUtilityProcess()35 worker.postMessage({ type: 'START_TASK', data: {...} })36})