Electron:核心概念、性能优化与兼容问题

什么是Eletron

Electron = Chromiun(浏览器内核) + Node.js。我们可以利用网页技术来开发桌面应用,但是也同时拥有了操作系统底层能力(文件、通知、系统托盘等)。

如何在electron集成vue3,可以看这篇文章

双进程架构

Electron采用的是主进程+渲染进程的双进程架构:

  • 主进程:一个应用只有一个主进程(入口一般定义为main.js文件),可以直接使用Node.js Api,主要负责创建窗口、管理生命周期、操作系统底层;
  • 渲染进程:每个窗口对应一个独立的渲染进程,默认情况下是不直接使用Node.js的,主要负责页面的显示和处理用户交互。
    这样设计的好处可以避免单个渲染进程卡死的时候不会影响整个应用;又因为渲染进程无法直接操作文件系统,降低了系统风险。

进程通信

前面提到过两种进程各有各的特点,且独立互不干预,两者之间需要合作的时候就需要借助IPC通信。IPC 就是这两个进程之间传递消息的通道。

渲染进程向主进程发送消息

  • 首先我们需要在主进程中注册处理器,当主进程监听到渲染进程发来的消息时候,就会根据名字触发相应的逻辑:
js 复制代码
// 如果监听之后需要给渲染进程回调消息,则使用handle
ipcMain.handle('read-file', async (event, filePath) => {
  try {
    const content = await fs.readFile(filePath, 'utf-8')
    return { success: true, content }
  } catch (error) {
    return { success: false, error: error.message }
  }
})
// 如果不需要回调信息,则使用on
ipcMain.on('log-message', (event, message) => {
  console.log('[来自渲染进程]', message)
})
  • 由于渲染进程发送消息需要借助ipcRenderer,但是实际上渲染进程是不能使用Node.js的,所以ipcRenderer是不可以直接从require('electron')中获取。这时候就要通过preload.js去注册一些API给渲染进程用:
js 复制代码
// preload.js
const { contextBridge, ipcRenderer } = require('electron')
// 暴露给渲染进程的 API(即将electronAPI挂载在渲染进程的window上)
contextBridge.exposeInMainWorld('electronAPI', {
  // 注意:方法名与主进程 handle 的第一个参数对应
  // 如果需要主进程返回消息,则使用invoke
  readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
  // 如果是单向消息则使用send
  log: (msg) => ipcRenderer.send('log-message', msg)
})
  • 接下来就是在渲染进程中进行调用:
js 复制代码
const result = await window.electronAPI.readFile(selectedFilePath)
window.electronAPI.log('用户点击了按钮')

主进程主动向渲染进程发送消息

  • 先在主进程中调用发送消息:
js 复制代码
// 向某个窗口发送
mainWin.webContents.send('update-progress', { percent: 50 })
  • 渲染进程还是需要借助preload.js提供一些函数帮助,来监听:
js 复制代码
contextBridge.exposeInMainWorld('electronAPI', {
  onUpdateProgress: (callback) => {
    // 移除旧的监听器,避免重复注册(可选)
    ipcRenderer.removeAllListeners('update-progress')
    ipcRenderer.on('update-progress', (event, data) => callback(data))
  }
})
  • 在渲染进程中进行监听:
js 复制代码
window.electronAPI.onUpdateProgress((data) => {
  console.log('进度:', data.percent)
  document.getElementById('progressBar').style.width = data.percent + '%'
})

核心模块

主进程和渲染进程都有属于自己的核心模块,每个模块都有自己的职责。渲染进程的模块没有主进程的多,一般用的是ipcRenderer,前面在进程通信的时候已经聊到过,现在介绍主进程中的几个核心模块。

app

主要控制整个应用的生命周期、获取系统信息、监听系统事件。

js 复制代码
// main.js
const { app } = require('electron');

// package.json 中的 name
const appName = app.getName();
// package.json 中的 version
const version = app.getVersion();
// 用户数据目录(存放配置、本地数据库等)
const userDataPath = app.getPath('userData');
// 是否已打包(开发环境 false,生产 true)
console.log(`当前是否为打包环境: ${app.isPackaged}`)

// Electron 初始化完成,可以创建窗口
app.whenReady().then(() => {})
// 关闭所有窗口时退出应用(Mac 除外)
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

注意

  • app.getPath('userData') 在开发环境和打包后路径不同,但都是可读写的目录,用于存储用户配置、数据库文件;
    • app.whenReady() 必须等待,否则创建窗口会失败。
BrowserWindow

主要用于创建和控制浏览器窗口。

js 复制代码
import { app, BrowserWindow } from 'electron'
import path from 'path'

const createWindow = () => {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    // 是否创建无边框
    frame: false
    // 如果是创建子窗口,可以指定父窗口
    // parent: parentWin   
    // 是否创建模态窗口
    // modal: true,       
    webPreferences: {
      // 如果你不需要在页面里用 Node.js API,建议保持默认(更安全)
      // 默认情况(nodeIntegration: false + contextIsolation: true)下,渲染进程:不能 require('fs')、不能 require('electron')、不能直接调用主进程模块(dialog, shell, 等)
      // nodeIntegration: true,
      // contextIsolation: false,
    },
  })

  // 开发环境加载 Vite 服务器,生产环境加载打包文件
  if (process.env.VITE_DEV_SERVER_URL) {
    win.loadURL(process.env.VITE_DEV_SERVER_URL)
    win.webContents.openDevTools() // 开发时自动打开调试工具
  } else {
    win.loadFile(path.join(__dirname, '../dist/index.html'))
  }
  
  // Electron 就绪后创建窗口
  app.whenReady().then(() => {
	createWindow()
  })
  
  // 还有其他一些常用的api
  win.close()                  // 关闭窗口
  win.minimize() / maximize()  // 最小化/最大化
  win.isFullScreen()           // 判断是否全屏
  win.webContents.send('from-main', 'data')  // 主动向渲染进程发送消息
}

注意

  • frame: true:如果创建无边框,记得兼容mac的样式,window和mac的样式区别还是有点大的,另外记得要实现拖拽功能,主要用到css中的 -webkit-app-region: drag
  • parent: parentWin:如果在创建子窗口的时候选中了父窗口,那么可以保证父窗口关闭时子窗口也会自动关闭;
  • modal: true:如果在创建子窗口的时候同时指定其为模态窗口,那么当它显示的时候,除非自身被关闭,否则都会一直组织用户操作父窗口。通常用于打开文件、确认对话框、表单填写等;
  • 默认情况(nodeIntegration: false + contextIsolation: true)下,渲染进程:不能 require('fs')、不能 require('electron'),不能直接调用主进程模块(dialog, shell, 等),建议永远不要在渲染进程中启用 nodeIntegration,永远保持 contextIsolation: true

ipcMain

监听来自渲染进程(通过 ipcRenderer)的消息。

js 复制代码
ipcMain.handle('read-file', async (event, filePath) => {
  // ...
})

注意 :Electron 主进程里永远不要用同步版本的 I/O 操作readFileSyncwriteFileSyncexecSync 等),除非文件极小且确定不会影响体验。一律使用 fs.promises 或回调/流式 API。如果使用这种同步类型的API,会导致主线程卡死,无法在响应该消息的的同时响应其他 IPC 调用、刷新窗口、拖拽窗口等。

dialog

弹出系统模态框用于文件选择、保存等。

js 复制代码
const { dialog } = require('electron')

// 打开文件选择框
const result = await dialog.showOpenDialog(win, {
  properties: ['openFile', 'multiSelections'],
  filters: [{ name: 'Images', extensions: ['jpg', 'png'] }]
})
console.log(result.canceled ? '取消' : result.filePaths)

// 保存文件对话框
const { filePath } = await dialog.showSaveDialog(win, {
  defaultPath: 'untitled.txt'
})

// 错误提示框
await dialog.showErrorBox('标题', '内容')

注意dialog 必须在主进程中使用。如果需要从渲染进程打开,通过 IPC(通信) 调用。

其他

其他模块我基本很少用到,简单概括一下,有需要再去了解:

  • Menu:支持用户创建自定义菜单,不过mac的菜单第一项永远都是会自动加上app的名称;
  • Tray:在系统通知区域显示一个图标,并支持右键菜单。Windows 托盘图标尺寸建议 16x16 或 32x32,macOS 建议 22x22;
  • shell:用系统默认程序打开文件、目录或外部链接。一般在渲染经常中直接用a标签打开外部连接会出现问题,可以改用shell.openExternal('https://electronjs.org')。如果一定要用内部窗口打开该连接,一定要做好安全限制。

安全性配置

由于桌面应用可以调用操作系统底层,所以安全问题也是重中之重。

  1. 在创建 BrowserWindow 时,webPreferences 必须包含以下配置:
js 复制代码
const win = new BrowserWindow({
  webPreferences: {
    // 1. 让渲染进程无法访问 Node.js API
    nodeIntegration: false,
    // 2. 隔离预加载脚本和渲染进程的 JS 上下文,防止渲染进程修改预加载暴露的对象
    contextIsolation: true,
    // 3. 可选:开启沙盒模式(推荐)
    // 由于浏览器本身存在漏洞,攻击者可以之间执行恶意的机器码,打开之后可以拦截保护系统
    sandbox: true,
    // 4. 如果不需要某些功能,明确关闭
    allowRunningInsecureContent: false,
    // 默认 true,不要关闭
    webSecurity: true,    
    // ...      
  }
})
  1. 不要直接将整个ipcRenderer挂载在window上,只暴露必要的方法;
js 复制代码
// preload.js -- 不要这样做!
const { ipcRenderer } = require('electron')
window.ipcRenderer = ipcRenderer  // 将整个 ipcRenderer 暴露给渲染进程

优化启动速度

Electron常见的性能问题是:启动慢、运行卡断、内存占用高。接下来就了解一下怎么优化:

优化启动速度与体验

  1. 不要在创建窗口完成之后立马显示页面,会导致短暂的白屏,等页面渲染完成之后再显示窗口:
js 复制代码
const win = new BrowserWindow({ 
  // 先隐藏
  show: false,   
  width: 800, 
  height: 600 
})
win.loadFile('index.html')
// 页面渲染完成后再显示
win.once('ready-to-show', () => {
  win.show()     
})
  1. 由于Electron主进程开启时会 require 大量模块,同步的 require 会阻塞事件循环,所以要按需加载node_module。
js 复制代码
ipcMain.handle('open-dialog', () => { 
	const { dialog } = require('electron'); 
	... 
})
  1. 把复杂的 Node.js 模块提前编译并"拍成"一张内存快照。应用启动时,直接加载这张快照,省去了原本读取、解析、编译所有 JS 文件的时间。这就是V8 快照。不过我没实际应用过,就先不做讨论了。

优化运行性能

  1. 主进程永远不要做同步/阻塞操作,可以使用异步API,将CPU 密集型任务交给 Web Worker子进程
  2. 在渲染进程中,减少重绘、DOM操作,图片使用懒加载等;
  3. 避免频繁的IPC通信,建议批量放或者使用debouncethrottle

优化打包体积

  1. 使用 electron-builderfiles 字段排除无用文件;
  2. 根据构建工具进行优化(webpack和vite一般是怎么优化就怎么优化);
  3. 移除不必要的node_modules或者放到devDependencies;
  4. 开启asar, 将代码打包成单文件,能减少小文件数量,但随机读取大文件可能略慢。

自动更新机制

我一般使用electron-updater来实现自动更新,打包之后的新版本会连用latest.yml一起丢到线上服务器去。主要流程是:

  • 应用启动的时候/用户点击检查新版本的时候,autoUpdater 会向预先配置好的 publish 服务器地址发起请求。
  • 服务器返回一个 latest.yml 元数据文件进行版本对比;
  • 如果发现新版本,就通知用户;
  • 用户如果同意安装新版本,它就会开始下载更新包(.exe, .dmg, .AppImage 等);
  • 下载完成后触发 update-downloaded 事件。你可以在此时通知用户,并调用 autoUpdater.quitAndInstall() 方法退出应用并启动安装程序。
bash 复制代码
# 安装打包工具(会自动包含 electron-updater)
npm install --save-dev electron-builder
# 安装更新模块作为生产依赖
npm install --save electron-updater
json 复制代码
{
  "name": "your-app",
  "version": "1.0.0",
  "build": {
    "appId": "com.yourcompany.yourapp",
    "productName": "Your App",
    "publish": [{
      "provider": "generic",
      "url": "https://your-update-server.com/updates/"
    }]
  }
}

在主进程编写一些逻辑:

js 复制代码
// main.js
const { autoUpdater } = require('electron-updater');
const { BrowserWindow, dialog } = require('electron');

let mainWindow;
// 禁用自动下载
autoUpdater.autoDownload = false
// 禁用退出时自动安装(让用户完全控制)
autoUpdater.autoInstallOnAppQuit = false

function createWindow() {
  mainWindow = new BrowserWindow({ /* ... */ });
  mainWindow.loadFile('index.html');
  setupAutoUpdater(); // 初始化自动更新
}

function setupAutoUpdater() {
  // 启动时自动检查更新(不会主动通知用户)
  autoUpdater.checkForUpdates();

  // 监听事件,发现新版本
  autoUpdater.on('update-available', (info) => {

  });
  
  // 记得让主进程写一个监听事件,如果用户想开始更新就开始下载
  // autoUpdater.downloadUpdate()
  
  // 监听加载进度
  autoUpdater.on('download-progress', (progressObj) => {
	  // progressObj: { percent, bytesPerSecond, transferred, total }
	  mainWindow.webContents.send('update-download-progress', progressObj.percent)
	})
	
  // 下载完成之后
  autoUpdater.on('update-downloaded', (info) => {
    // 通知渲染进程下载完成
    mainWindow.webContents.send('update-status', '更新下载完成');

    // 弹窗询问用户是否立即安装
    dialog.showMessageBox(mainWindow, {
      type: 'question',
      buttons: ['稍后', '立即安装'],
      title: '应用更新',
      message: '新版本已下载,是否立即安装?',
      detail: '安装将重启应用。'
    }).then((result) => {
      if (result.response === 1) {
        autoUpdater.quitAndInstall(); // 退出并安装更新
      }
    });
  });

  autoUpdater.on('error', (err) => {
    console.error('更新出错:', err);
    mainWindow.webContents.send('update-status', '更新出错');
  });
}

app.whenReady().then(createWindow);

跨平台踩坑

接下来介绍一下我在跨平台时候遇到的坑吧,主要是在文件路径这一块。

  1. window和mac的斜杠方向不同,文件路径处理方式不同,所以遇到路径问题,一律使用path进行处理和转译。
js 复制代码
const path = require('path')
const configPath = path.join(app.getPath('userData'), 'config.json')
  1. mac和window的大小写敏感程度不一样,Windows 不区分大小写,所以一定别手滑;
  2. 建议始终使用app.getPath('userData') 存储应用配置、本地数据库等,在不同平台虽然用户目录路径长得不一样,但是app.getPath('userData')已经帮你处理完成;
  3. 不同用途的文件建议(有时候window需要考虑多用户的情况,也需要考虑不同系统的权限问题):
数据类型 推荐路径(通过 app.getPath(name) Windows 实际路径 macOS 实际路径
用户专属配置/数据(如设置、本地数据库、缓存) app.getPath('userData') C:\Users\<用户名>\AppData\Roaming\<应用名> ~/Library/Application Support/<应用名>
用户文档(用户主动创建的文件,如笔记、项目文件) app.getPath('documents') C:\Users\<用户名>\Documents ~/Documents
临时文件(可随时删除) app.getPath('temp') C:\Users\<用户名>\AppData\Local\Temp /tmp
所有用户共享的数据(需要安装程序写入,普通用户只读) app.getPath('commonAppData') C:\ProgramData\<应用名> /Library/Application Support/<应用名>
应用日志 app.getPath('logs') C:\Users\<用户名>\AppData\Roaming\<应用名>\logs ~/Library/Logs/<应用名>
缓存文件(可被系统清理) app.getPath('cache') C:\Users\<用户名>\AppData\Local\<应用名>\Cache ~/Library/Caches/<应用名>
  1. 开发环境和打包环境的文件路径可能不一样。之前做过一个项目,在当前代码项目中yt-dlp文件是放在当前某个目录下,然后打包没有打包这个文件(即没有去编译),而是特意原封不懂挪到了安装之后软件所在的目录中,这样读取的时候,就需要做区分:
js 复制代码
if (app.isPackaged) {
    const resourcesPath = process.resourcesPath || app.getAppPath();
    const basePath = path.join(resourcesPath, 'yt-dlp');
    executablePath = path.join(basePath, softName);
  } else {
    const basePath = path.join(__dirname, '..', 'resources', 'yt-dlp', platform);
    executablePath = path.join(basePath, softName);
  }

打包加密

(不过也不一定能完全安全)

  • 使用 V8 字节码:将敏感代码编译成 V8 引擎的字节码。源码被转为中间码,大幅增加阅读难度;
  • 启用 ASAR 完整性校验:防止用户篡改打包后的文件;
  • 代码混淆:如果是用我上面提到的流程搭建的,记得要将electron和vue的代码单独配置混淆。

小结

  • 主要了解了Electron的两大进程:主进程和渲染进程,前者主要可以执行Node.js和操作系统底层;后者主要用于渲染页面;
  • 了解两进程之间的通信方式和几大核心模块;
  • Electron可以操作底层系统,所以一定要重视安全性;且其本身"比较重",所以启动慢、运行异卡顿、打包体积也大,需要做优化;
  • Electron可以做到自动更新;
  • 跨平台之间文件路径的读取问题坑比较多,一定要借助path模块,且不同文件的存储最好放在推荐的位置;
  • 为了保护知识产权,可以适当做打包加密,就是不一定管用。
相关推荐
F2E_Zhangmo2 小时前
react native如何发送蓝牙命令
javascript·react native·react.js
博主花神2 小时前
【TypeScript】梳理
javascript·ubuntu·typescript
淡笑沐白2 小时前
ECharts入门指南:数据可视化实战
前端·javascript·echarts
魔卡少女12 小时前
Nginx配置代码化自动部署詹金斯/Github方案
前端·nginx·github
开发者如是说2 小时前
可能是最好用的多语言管理工具
android·前端·后端
非科班Java出身GISer2 小时前
ArcGIS JS 基础教程(1):地图初始化(含AMD/ESM两种引入方式)
javascript·arcgis·arcgis js·arcgis js 初始化·arcgis js 地图初始化
是上好佳佳佳呀2 小时前
【前端(六)】HTML5 新特性笔记总结
前端·笔记·html5
北城笑笑3 小时前
FPGA 与 市场主流芯片分类详解:SoC/CPU/GPU/DPU 等芯片核心特性与工程应用
前端·单片机·fpga开发·fpga
A923A3 小时前
【从零开始学 React | 第四章】useEffect和自定义Hook
前端·react.js·fetch·useeffect