Electron学习文档

目录

1、进程模型

1.1、渲染进程、预加载进程、主进程交互方式

1.1.1、三种进程的定位与职责

在 Electron 中,进程模型继承自 Chromium 的多进程架构,三者各司其职:

进程类型 运行环境 数量 核心职责 可用能力
主进程(Main) Node.js 1 个 应用生命周期管理、窗口创建、系统资源调用 完整 Node.js API、原生模块
渲染进程(Renderer) Chromium(浏览器) 每个窗口 1 个 页面渲染、UI 交互(Vue/React) DOM API、有限 Node.js(需开启)
预加载脚本(Preload) 特殊沙盒环境 每个窗口 1 个 安全桥梁,连接主进程与渲染进程 DOM API + 受限 Node.js(白名单)

架构示意图:

复制代码
┌─────────────────────────────────────────────────────────┐
│                      主进程 (Main)                      │
│  • app 生命周期事件                                    │
│  • BrowserWindow 管理                                 │
│  • ipcMain 监听/处理                                  │
│  • 系统资源调用(文件、数据库、网络)                   │
└───────────────┬─────────────────┬─────────────────────┘
                │                 │
    ┌───────────▼───────┐  ┌──────▼───────────┐
    │   预加载 (Preload) │  │   预加载 (Preload) │
    │ • contextBridge    │  │ • contextBridge    │
    │ • 暴露安全 API     │  │ • 暴露安全 API     │
    └───────────┬───────┘  └──────┬───────────┘
                │                 │
    ┌───────────▼───────┐  ┌──────▼───────────┐
    │  渲染进程 (窗口1)  │  │  渲染进程 (窗口2)  │
    │ • Vue 应用        │  │ • Vue 应用        │
    │ • 通过 preload     │  │ • 通过 preload     │
    │   调用主进程能力   │  │   调用主进程能力   │
    └───────────────────┘  └───────────────────┘

1.1.2、为什么不能直接在渲染进程中使用 Node.js?

早期 Electron 允许通过 nodeIntegration: true 在渲染进程中直接使用 require('fs')require('child_process') 等模块。但这是一个严重的安全隐患

  • XSS 攻击风险:如果渲染进程加载了第三方恶意脚本,攻击者可以直接通过 Node.js 删除文件、执行系统命令、植入病毒。
  • 权限失控:渲染进程接触网络请求,可能被恶意网站利用 Node.js API 穿透沙盒。

Electron 的默认安全策略(12.0 版本起):

配置项 默认值 说明
nodeIntegration false 禁止渲染进程直接访问 Node.js
contextIsolation true 隔离渲染进程的 window 与预加载脚本的上下文

因此,预加载脚本成了渲染进程与主进程之间唯一的安全通道


1.1.3、标准交互流程

方向一:渲染进程 → 主进程(单向通知)

Vue 触发事件,主进程执行任务,无需返回结果。
Main (主进程) Preload (预加载) Vue (渲染进程) Main (主进程) Preload (预加载) Vue (渲染进程) #mermaid-svg-vhJKIbUbh5UjlcHT{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vhJKIbUbh5UjlcHT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vhJKIbUbh5UjlcHT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vhJKIbUbh5UjlcHT .error-icon{fill:#552222;}#mermaid-svg-vhJKIbUbh5UjlcHT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vhJKIbUbh5UjlcHT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vhJKIbUbh5UjlcHT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vhJKIbUbh5UjlcHT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vhJKIbUbh5UjlcHT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vhJKIbUbh5UjlcHT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vhJKIbUbh5UjlcHT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vhJKIbUbh5UjlcHT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vhJKIbUbh5UjlcHT .marker.cross{stroke:#333333;}#mermaid-svg-vhJKIbUbh5UjlcHT svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vhJKIbUbh5UjlcHT p{margin:0;}#mermaid-svg-vhJKIbUbh5UjlcHT .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-vhJKIbUbh5UjlcHT text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-vhJKIbUbh5UjlcHT .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-vhJKIbUbh5UjlcHT .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-vhJKIbUbh5UjlcHT .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-vhJKIbUbh5UjlcHT .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-vhJKIbUbh5UjlcHT #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-vhJKIbUbh5UjlcHT .sequenceNumber{fill:white;}#mermaid-svg-vhJKIbUbh5UjlcHT #sequencenumber{fill:#333;}#mermaid-svg-vhJKIbUbh5UjlcHT #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-vhJKIbUbh5UjlcHT .messageText{fill:#333;stroke:none;}#mermaid-svg-vhJKIbUbh5UjlcHT .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-vhJKIbUbh5UjlcHT .labelText,#mermaid-svg-vhJKIbUbh5UjlcHT .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-vhJKIbUbh5UjlcHT .loopText,#mermaid-svg-vhJKIbUbh5UjlcHT .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-vhJKIbUbh5UjlcHT .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-vhJKIbUbh5UjlcHT .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-vhJKIbUbh5UjlcHT .noteText,#mermaid-svg-vhJKIbUbh5UjlcHT .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-vhJKIbUbh5UjlcHT .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-vhJKIbUbh5UjlcHT .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-vhJKIbUbh5UjlcHT .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-vhJKIbUbh5UjlcHT .actorPopupMenu{position:absolute;}#mermaid-svg-vhJKIbUbh5UjlcHT .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-vhJKIbUbh5UjlcHT .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-vhJKIbUbh5UjlcHT .actor-man circle,#mermaid-svg-vhJKIbUbh5UjlcHT line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-vhJKIbUbh5UjlcHT :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. window.api.sendLog('点击了按钮') 2. ipcRenderer.send('log-message', data) 3. 写入日志文件

方向二:渲染进程 → 主进程(请求-响应)

Vue 向主进程请求数据,主进程返回结果(Promise 风格)。
Main (主进程) Preload (预加载) Vue (渲染进程) Main (主进程) Preload (预加载) Vue (渲染进程) #mermaid-svg-CQDhlVuImXHgpqtj{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CQDhlVuImXHgpqtj .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CQDhlVuImXHgpqtj .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CQDhlVuImXHgpqtj .error-icon{fill:#552222;}#mermaid-svg-CQDhlVuImXHgpqtj .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CQDhlVuImXHgpqtj .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CQDhlVuImXHgpqtj .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CQDhlVuImXHgpqtj .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CQDhlVuImXHgpqtj .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CQDhlVuImXHgpqtj .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CQDhlVuImXHgpqtj .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CQDhlVuImXHgpqtj .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CQDhlVuImXHgpqtj .marker.cross{stroke:#333333;}#mermaid-svg-CQDhlVuImXHgpqtj svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CQDhlVuImXHgpqtj p{margin:0;}#mermaid-svg-CQDhlVuImXHgpqtj .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CQDhlVuImXHgpqtj text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-CQDhlVuImXHgpqtj .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CQDhlVuImXHgpqtj .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-CQDhlVuImXHgpqtj .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-CQDhlVuImXHgpqtj .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-CQDhlVuImXHgpqtj #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-CQDhlVuImXHgpqtj .sequenceNumber{fill:white;}#mermaid-svg-CQDhlVuImXHgpqtj #sequencenumber{fill:#333;}#mermaid-svg-CQDhlVuImXHgpqtj #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-CQDhlVuImXHgpqtj .messageText{fill:#333;stroke:none;}#mermaid-svg-CQDhlVuImXHgpqtj .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CQDhlVuImXHgpqtj .labelText,#mermaid-svg-CQDhlVuImXHgpqtj .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-CQDhlVuImXHgpqtj .loopText,#mermaid-svg-CQDhlVuImXHgpqtj .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-CQDhlVuImXHgpqtj .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-CQDhlVuImXHgpqtj .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-CQDhlVuImXHgpqtj .noteText,#mermaid-svg-CQDhlVuImXHgpqtj .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-CQDhlVuImXHgpqtj .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CQDhlVuImXHgpqtj .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CQDhlVuImXHgpqtj .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-CQDhlVuImXHgpqtj .actorPopupMenu{position:absolute;}#mermaid-svg-CQDhlVuImXHgpqtj .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-CQDhlVuImXHgpqtj .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-CQDhlVuImXHgpqtj .actor-man circle,#mermaid-svg-CQDhlVuImXHgpqtj line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-CQDhlVuImXHgpqtj :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. await window.api.readFile('/path') 2. ipcRenderer.invoke('read-file', path) 3. fs.readFile 读取文件 4. return 文件内容 5. Promise resolve

方向三:主进程 → 渲染进程(主动推送)

主进程主动向 Vue 发送消息(如下载进度、系统事件)。
Vue (渲染进程) Preload (预加载) Main (主进程) Vue (渲染进程) Preload (预加载) Main (主进程) #mermaid-svg-vxpG3JUn6kguSq6J{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vxpG3JUn6kguSq6J .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vxpG3JUn6kguSq6J .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vxpG3JUn6kguSq6J .error-icon{fill:#552222;}#mermaid-svg-vxpG3JUn6kguSq6J .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-vxpG3JUn6kguSq6J .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vxpG3JUn6kguSq6J .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vxpG3JUn6kguSq6J .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vxpG3JUn6kguSq6J .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vxpG3JUn6kguSq6J .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vxpG3JUn6kguSq6J .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vxpG3JUn6kguSq6J .marker{fill:#333333;stroke:#333333;}#mermaid-svg-vxpG3JUn6kguSq6J .marker.cross{stroke:#333333;}#mermaid-svg-vxpG3JUn6kguSq6J svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vxpG3JUn6kguSq6J p{margin:0;}#mermaid-svg-vxpG3JUn6kguSq6J .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-vxpG3JUn6kguSq6J text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-vxpG3JUn6kguSq6J .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-vxpG3JUn6kguSq6J .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-vxpG3JUn6kguSq6J .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-vxpG3JUn6kguSq6J .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-vxpG3JUn6kguSq6J #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-vxpG3JUn6kguSq6J .sequenceNumber{fill:white;}#mermaid-svg-vxpG3JUn6kguSq6J #sequencenumber{fill:#333;}#mermaid-svg-vxpG3JUn6kguSq6J #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-vxpG3JUn6kguSq6J .messageText{fill:#333;stroke:none;}#mermaid-svg-vxpG3JUn6kguSq6J .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-vxpG3JUn6kguSq6J .labelText,#mermaid-svg-vxpG3JUn6kguSq6J .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-vxpG3JUn6kguSq6J .loopText,#mermaid-svg-vxpG3JUn6kguSq6J .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-vxpG3JUn6kguSq6J .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-vxpG3JUn6kguSq6J .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-vxpG3JUn6kguSq6J .noteText,#mermaid-svg-vxpG3JUn6kguSq6J .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-vxpG3JUn6kguSq6J .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-vxpG3JUn6kguSq6J .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-vxpG3JUn6kguSq6J .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-vxpG3JUn6kguSq6J .actorPopupMenu{position:absolute;}#mermaid-svg-vxpG3JUn6kguSq6J .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-vxpG3JUn6kguSq6J .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-vxpG3JUn6kguSq6J .actor-man circle,#mermaid-svg-vxpG3JUn6kguSq6J line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-vxpG3JUn6kguSq6J :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 已通过 contextBridge 暴露了监听注册函数 1. win.webContents.send('progress', { p: 50 }) 2. 触发已注册的回调 3. 更新 UI


1.1.4、IPC 通信方法详解

一、send + on(单向通信)

渲染进程 → 主进程,主进程接收后执行操作,无返回值

位置 API 参数说明
预加载 ipcRenderer.send('channel', ...args) 任意可序列化数据
主进程 ipcMain.on('channel', (event, ...args) => {}) event 包含发送者信息

代码示例:

javascript 复制代码
// ---------- preload.js ----------
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  sendLog: (level, message) => ipcRenderer.send('log-message', level, message)
});

// ---------- main.js ----------
const { ipcMain } = require('electron');

ipcMain.on('log-message', (event, level, message) => {
  // 这里的 event.sender 可以获取发送消息的 webContents
  console.log(`[${level}] ${message}`);
  // 写入文件、上报服务器等
});

// ---------- App.vue ----------
window.electronAPI.sendLog('info', '用户登录成功');

event 对象常用属性/方法:

属性/方法 类型 说明
event.sender webContents 发送消息的渲染进程实例,可用于回复
event.reply(channel, ...args) 方法 向发送者回复消息(但推荐使用 invoke
event.senderId number 发送者的 webContents ID(多窗口场景)

二、invoke + handle(双向通信,⭐ 推荐)

渲染进程 → 主进程请求数据,主进程返回结果 。支持 async/await,错误会以 throw 方式传递。

位置 API 返回值
预加载 ipcRenderer.invoke('channel', ...args) Promise
主进程 ipcMain.handle('channel', async (event, ...args) => {}) 任意可序列化数据

代码示例:

javascript 复制代码
// ---------- preload.js ----------
contextBridge.exposeInMainWorld('electronAPI', {
  readFile: (path) => ipcRenderer.invoke('read-file', path),
  writeFile: (path, content) => ipcRenderer.invoke('write-file', path, content),
  queryDatabase: (sql) => ipcRenderer.invoke('db-query', sql)
});

// ---------- main.js ----------
ipcMain.handle('read-file', async (event, filePath) => {
  try {
    const content = await fs.promises.readFile(filePath, 'utf-8');
    return { success: true, data: content };
  } catch (error) {
    // 错误会通过 Promise reject 传递到渲染进程
    throw new Error(`读取文件失败: ${error.message}`);
  }
});

ipcMain.handle('db-query', async (event, sql) => {
  // 数据库查询逻辑
  const result = await db.query(sql);
  return result;
});

// ---------- App.vue ----------
async function loadConfig() {
  try {
    const result = await window.electronAPI.readFile('./config.json');
    config.value = JSON.parse(result.data);
  } catch (error) {
    console.error(error.message);
    // 处理错误
  }
}

⚠️ 关键配对规则:

渲染进程调用 主进程监听 是否匹配
ipcRenderer.send() ipcMain.on() ✅ 正确
ipcRenderer.invoke() ipcMain.handle() ✅ 正确
ipcRenderer.send() ipcMain.handle() ❌ 不匹配
ipcRenderer.invoke() ipcMain.on() ❌ 不匹配

三、主进程 → 渲染进程(主动推送)

主进程通过 webContents.send() 向渲染进程发送消息,渲染进程通过 ipcRenderer.on() 接收。

位置 API 说明
主进程 win.webContents.send('channel', data) 通过窗口实例主动发送
预加载(暴露监听器) ipcRenderer.on('channel', (event, data) => {}) 注册回调函数

代码示例:

javascript 复制代码
// ---------- preload.js ----------
contextBridge.exposeInMainWorld('electronAPI', {
  // 暴露注册监听的方法
  onProgress: (callback) => {
    ipcRenderer.on('download-progress', (event, data) => {
      callback(data);
    });
  },
  // 暴露取消监听的方法(防止内存泄漏)
  offProgress: () => {
    ipcRenderer.removeAllListeners('download-progress');
  }
});

// ---------- main.js ----------
function startDownload(win) {
  let progress = 0;
  const timer = setInterval(() => {
    progress += 10;
    win.webContents.send('download-progress', { 
      percent: progress,
      status: progress < 100 ? 'downloading' : 'completed'
    });
    
    if (progress >= 100) {
      clearInterval(timer);
    }
  }, 500);
}

// ---------- App.vue ----------
import { onMounted, onUnmounted, ref } from 'vue';

const percent = ref(0);

onMounted(() => {
  window.electronAPI.onProgress((data) => {
    percent.value = data.percent;
    if (data.status === 'completed') {
      ElNotification.success('下载完成!');
    }
  });
});

onUnmounted(() => {
  window.electronAPI.offProgress(); // 组件卸载时移除监听
});

四、sendTo:向指定渲染进程发送

当应用有多个窗口时,可以通过 webContents.sendTo 精确发送:

javascript 复制代码
// 主进程
const allWindows = BrowserWindow.getAllWindows();
const targetWindow = allWindows[0]; // 获取第一个窗口
targetWindow.webContents.send('custom-event', { from: 'main' });

// 也可以通过 ID 发送
const targetId = targetWindow.webContents.id;
webContents.sendTo(targetId, 'custom-event', { data: 'hello' });

五、once:一次性监听

如果只需要接收一次消息,可以使用 once

javascript 复制代码
// 预加载
ipcRenderer.once('init-data', (event, data) => {
  console.log('只会执行一次:', data);
});

// 主进程
ipcMain.once('app-ready', () => {
  console.log('应用首次准备就绪');
});

1.1.5、安全实践:contextBridge 的正确用法

一、基本原则:白名单暴露,最小权限

❌ 错误做法:暴露整个 ipcRenderer

javascript 复制代码
// 危险!渲染进程可以调用任何频道
contextBridge.exposeInMainWorld('api', {
  send: ipcRenderer.send,
  invoke: ipcRenderer.invoke,
  on: ipcRenderer.on
});
// 渲染进程可以随意调用:window.api.send('delete-file', '/system')

✅ 正确做法:按需暴露,限定频道

javascript 复制代码
contextBridge.exposeInMainWorld('electronAPI', {
  // 只暴露特定功能,频道名称硬编码
  openSettings: () => ipcRenderer.send('open-settings-window'),
  getUserInfo: () => ipcRenderer.invoke('get-user-info'),
  onThemeChange: (cb) => ipcRenderer.on('theme-changed', (_, data) => cb(data))
});
二、数据类型限制

通过 contextBridge 传递的数据必须是可序列化 的(结构化克隆算法支持),以下类型无法传递

可传递 不可传递
基本类型(string、number、boolean) DOM 节点
普通对象、数组 函数(作为参数传递时)
BufferUint8Array MapSet(会丢失原型)
日期对象(转为字符串) 类实例(仅保留属性)

函数传递的特殊处理:

javascript 复制代码
// 预加载中暴露方法时,函数本身不能作为参数传递,但可以传递回调
ipcRenderer.on('data', (event, data) => {
  // data 中不能包含函数
});

// 如果需要回调,通过调用暴露的函数来间接传递
contextBridge.exposeInMainWorld('electronAPI', {
  onData: (callback) => ipcRenderer.on('data', (_, data) => callback(data))
});

1.1.6、多窗口场景的注意事项

当应用有多个窗口时,每个窗口都有独立的渲染进程和预加载脚本:

场景 处理方式
主进程向所有窗口广播 遍历 BrowserWindow.getAllWindows(),逐个 send
主进程向特定窗口发送 使用 webContents.sendTo(id, channel, data)
主进程区分消息来源 通过 event.sender.id 判断来自哪个窗口
窗口间直接通信 需要经过主进程转发(不支持渲染进程间直接通信)

广播示例:

javascript 复制代码
// 主进程广播
function broadcastToAllWindows(channel, data) {
  const windows = BrowserWindow.getAllWindows();
  windows.forEach(win => {
    win.webContents.send(channel, data);
  });
}

1.1.7、调试技巧

1.1.7.1、查看所有注册的频道
javascript 复制代码
// 主进程调试
console.log('已注册的 handle 频道:', ipcMain.eventNames());

// 查看某个频道的监听器数量
console.log('read-file 监听器数:', ipcMain.listenerCount('read-file'));
1.1.7.2、渲染进程调试

在 DevTools Console 中直接测试暴露的 API:

javascript 复制代码
// 测试暴露的方法
await window.electronAPI.readFile('/test.txt');

// 查看暴露了什么
console.log(Object.keys(window.electronAPI));
1.1.7.3、通过主进程主动打开 DevTools
javascript 复制代码
win.webContents.openDevTools();
1.1.7.4、监听所有 IPC 消息(开发环境)
javascript 复制代码
// 在预加载中添加调试日志
const originalSend = ipcRenderer.send;
ipcRenderer.send = (channel, ...args) => {
  console.log('[IPC Debug] send:', channel, args);
  originalSend.call(ipcRenderer, channel, ...args);
};

1.1.8、方法速查表

方向 渲染进程 主进程 返回值 适用场景
渲染 → 主 send(channel, data) on(channel, cb) ❌ 无 日志、触发操作
渲染 → 主 invoke(channel, data) handle(channel, cb) ✅ Promise ⭐ 推荐,所有请求-响应
主 → 渲染 on(channel, cb) win.webContents.send(channel, data) ❌ 无 进度推送、系统事件
主 → 指定窗口 on(channel, cb) webContents.sendTo(id, channel, data) ❌ 无 多窗口定向推送
一次性 once(channel, cb) once(channel, cb) 视情况 只需接收一次的数据

1.2、ContextBridge(上下文隔离)

1.2.1、什么是 ContextBridge?

contextBridge 是 Electron 提供的一个安全模块,用于在隔离的上下文之间安全地暴露 API。它的核心作用是:

在预加载脚本中,将选定的功能安全地暴露给渲染进程,同时保持 Node.js 环境的隔离。

可以把它理解为一个经过安检的传送带------只有你明确放上去的东西,才能被送到渲染进程那边。


1.2.2、为什么需要它?

从 Electron 12 开始,contextIsolation 默认开启,导致以下结果:

问题 说明
渲染进程无法直接访问 Node.js requireprocess 等不可用
预加载脚本的上下文与渲染进程隔离 即使预加载中定义了 window.xxx,渲染进程也拿不到
直接挂载暴露 ipcRenderer 会被拦截 需要在预加载中建立显式桥梁

contextBridge 就是为了解决上述问题而存在的唯一官方通道。


1.2.3、核心 API

javascript 复制代码
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('apiName', {
  // 这里定义暴露给渲染进程的方法
  methodName: (args) => ipcRenderer.invoke('channel', args)
});

语法说明:

参数 说明
exposeInMainWorld(key, api) api 对象挂载到渲染进程的 window[key]
key 渲染进程中访问的全局变量名(如 electronAPI
api 暴露的方法对象,只能包含函数和基本类型

渲染进程中通过 window.electronAPI.methodName() 调用。


1.2.4、安全原则

✅ 白名单原则(Whitelist)

只暴露必要的最小功能集合,不要暴露整个模块。

javascript 复制代码
// ❌ 危险:暴露了整个 ipcRenderer
contextBridge.exposeInMainWorld('api', {
  ipc: ipcRenderer  // 渲染进程可以调用任意频道
});

// ✅ 安全:只暴露特定功能
contextBridge.exposeInMainWorld('electronAPI', {
  openFile: () => ipcRenderer.invoke('dialog:openFile'),
  saveFile: (data) => ipcRenderer.invoke('dialog:saveFile', data)
});
✅ 输入校验

所有从渲染进程传入的参数,主进程端都要进行类型校验和内容清洗

javascript 复制代码
ipcMain.handle('save-file', (event, data) => {
  // 不要直接信任渲染进程传过来的数据
  if (typeof data !== 'object' || data === null) {
    throw new Error('无效的数据格式');
  }
  // ... 处理逻辑
});
✅ 最小暴露原则

能不在预加载中暴露的功能,就不要暴露。能用 invoke/handle 的,就不要用 send/on


1.2.5、数据传递限制

通过 contextBridge 传递的数据受结构化克隆算法限制:

可传递 不可传递
基本类型(string、number、boolean、null、undefined) DOM 节点、Window 对象
普通对象({})、数组([] 函数(作为参数传递时)
ArrayBufferUint8Array MapSetWeakMap(原型丢失)
日期对象(转为 ISO 字符串) 类实例(仅保留属性,丢失方法)
RegExp 对象 循环引用的对象

1.2.6、最佳实践示例

javascript 复制代码
// ---------- preload.js ----------
const { contextBridge, ipcRenderer } = require('electron');

// 定义暴露的 API 接口
contextBridge.exposeInMainWorld('electronAPI', {
  // 请求-响应模式
  readConfig: () => ipcRenderer.invoke('config:read'),
  saveConfig: (config) => ipcRenderer.invoke('config:save', config),
  
  // 主动推送模式
  onUpdateAvailable: (callback) => {
    ipcRenderer.on('update:available', (_, data) => callback(data));
  },
  
  // 清理监听
  removeAllListeners: () => {
    ipcRenderer.removeAllListeners('update:available');
  }
});

// ---------- renderer App.vue ----------
// Vue 组件中调用
const config = await window.electronAPI.readConfig();
window.electronAPI.onUpdateAvailable((data) => {
  console.log('发现新版本:', data.version);
});

1.2.7、一句话总结

contextBridge 是 Electron 安全架构的守门员,它强制开发者用「显式白名单」的方式暴露 API,从根本上防止了渲染进程越权访问 Node.js 环境。在 Electron 12+ 中,这是唯一推荐的预加载-渲染进程通信方式。

1.3、Electron-Vite的多进程交互方式

1.3.1、本节定位

1.1 节讲了「IPC 通信方法」的理论知识,1.2 节讲了「ContextBridge」的安全原则。本节是在 electron-vite 这一具体工具链下,将前两节的理论落地为实际代码。三者是「理论 → 安全规范 → 工程实践」的递进关系。

在 electron-vite 项目中,IPC 底层机制没有变化 (仍然是 ipcRenderer + ipcMain + contextBridge),但借助 @electron-toolkit/preload 工具包,预加载脚本的代码被极大简化,开发体验显著提升。


1.3.2、认识 @electron-toolkit/preload

@electron-toolkit/preload 是 electron-vite 官方生态中的工具包,它预封装了 contextBridge.exposeInMainWorld 的模板代码 ,让开发者无需重复编写暴露 ipcRenderer 的样板代码。

核心价值:

对比项 手写方式(1.1 节) 使用 @electron-toolkit/preload
代码量 需手动编写 contextBridge.exposeInMainWorld 开箱即用,一行导入
API 覆盖面 按需暴露,需要自己维护 预置了 ipcRendererwebFramewebUtilsprocess
类型安全 需手动定义 TypeScript 类型 自带完整类型声明
安全隔离 需要自己处理 process.contextIsolated 判断 已内置安全判断逻辑

1.3.3、预加载脚本的标准写法

typescript 复制代码
// src/preload/index.ts
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'

// 自定义 API(可扩展业务功能)
const api = {
  // 例如:封装业务相关的 IPC 调用
  saveConfig: (config: object) => ipcRenderer.invoke('config:save', config),
  loadConfig: () => ipcRenderer.invoke('config:load')
}

if (process.contextIsolated) {
  try {
    // 暴露预置的 electronAPI
    contextBridge.exposeInMainWorld('electron', electronAPI)
    // 暴露自定义 API
    contextBridge.exposeInMainWorld('api', api)
  } catch (error) {
    console.error(error)
  }
} else {
  // 降级方案(兼容 contextIsolation = false 的旧项目)
  window.electron = electronAPI
  window.api = api
}

electronAPI 包含的能力:

属性 类型 说明
ipcRenderer IpcRenderer IPC 通信核心(sendinvokeononce 等)
webFrame WebFrame 页面渲染控制(缩放、布局等)
webUtils WebUtils Web 工具方法
process NodeProcess Node.js process 对象(受限版本)

注意: electronAPI不包含 shell 。如需使用 shell.openExternal,需单独处理(详见下文 TS 部分)。


1.3.4、渲染进程中的使用方式

官方模板通过 @electron-toolkit/preload 在渲染进程的 window 上挂载了两个对象:

  • window.electron ------ 预置的 electronAPI(IPC、process、webFrame、webUtils)
  • window.api ------ 自定义业务 API
vue 复制代码
<script setup>
// ✅ 发送单向消息
const ping = () => window.electron.ipcRenderer.send('ping')

// ✅ 请求-响应(双向通信)
const getUser = async () => {
  const user = await window.electron.ipcRenderer.invoke('get-user', 1)
  console.log(user)
}

// ✅ 监听主进程推送
window.electron.ipcRenderer.on('download-progress', (event, data) => {
  console.log(`进度: ${data.percent}%`)
})

// ✅ 获取进程信息
console.log(window.electron.process.platform)

// ✅ 控制页面缩放
window.electron.webFrame.setZoomFactor(1.2)

// ✅ 调用自定义 API
await window.api.saveConfig({ theme: 'dark' })
</script>

1.3.5、主进程中的完整示例

主进程的写法与 1.1 节完全一致,没有变化:

typescript 复制代码
// src/main/index.ts
import { app, BrowserWindow, ipcMain } from 'electron'
import path from 'path'

function createWindow() {
  const win = new BrowserWindow({
    width: 900,
    height: 670,
    webPreferences: {
      preload: path.join(__dirname, '../preload/index.js'),
      sandbox: false
    }
  })

  // 开发环境加载 Vite 服务器,生产环境加载本地文件
  if (process.env.ELECTRON_RENDERER_URL) {
    win.loadURL(process.env.ELECTRON_RENDERER_URL)
  } else {
    win.loadFile(path.join(__dirname, '../renderer/index.html'))
  }

  return win
}

// 监听渲染进程的单向消息
ipcMain.on('ping', () => {
  console.log('收到 ping 消息')
})

// 处理渲染进程的请求-响应
ipcMain.handle('get-user', async (event, id: number) => {
  // 模拟数据库查询
  return { id, name: 'Electron User' }
})

// 主进程主动向渲染进程推送
ipcMain.handle('start-download', async (event) => {
  const win = BrowserWindow.fromWebContents(event.sender)
  let progress = 0
  const timer = setInterval(() => {
    progress += 10
    win?.webContents.send('download-progress', { percent: progress })
    if (progress >= 100) clearInterval(timer)
  }, 500)
})

app.whenReady().then(createWindow)

1.3.6、完整调用链路图

Main (主进程) Preload (预加载) Vue (渲染进程) Main (主进程) Preload (预加载) Vue (渲染进程) #mermaid-svg-uwNdb0vHYM047cCq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-uwNdb0vHYM047cCq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-uwNdb0vHYM047cCq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-uwNdb0vHYM047cCq .error-icon{fill:#552222;}#mermaid-svg-uwNdb0vHYM047cCq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-uwNdb0vHYM047cCq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-uwNdb0vHYM047cCq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-uwNdb0vHYM047cCq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-uwNdb0vHYM047cCq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-uwNdb0vHYM047cCq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-uwNdb0vHYM047cCq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-uwNdb0vHYM047cCq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-uwNdb0vHYM047cCq .marker.cross{stroke:#333333;}#mermaid-svg-uwNdb0vHYM047cCq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-uwNdb0vHYM047cCq p{margin:0;}#mermaid-svg-uwNdb0vHYM047cCq .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-uwNdb0vHYM047cCq text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-uwNdb0vHYM047cCq .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-uwNdb0vHYM047cCq .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-uwNdb0vHYM047cCq .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-uwNdb0vHYM047cCq .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-uwNdb0vHYM047cCq #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-uwNdb0vHYM047cCq .sequenceNumber{fill:white;}#mermaid-svg-uwNdb0vHYM047cCq #sequencenumber{fill:#333;}#mermaid-svg-uwNdb0vHYM047cCq #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-uwNdb0vHYM047cCq .messageText{fill:#333;stroke:none;}#mermaid-svg-uwNdb0vHYM047cCq .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-uwNdb0vHYM047cCq .labelText,#mermaid-svg-uwNdb0vHYM047cCq .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-uwNdb0vHYM047cCq .loopText,#mermaid-svg-uwNdb0vHYM047cCq .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-uwNdb0vHYM047cCq .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-uwNdb0vHYM047cCq .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-uwNdb0vHYM047cCq .noteText,#mermaid-svg-uwNdb0vHYM047cCq .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-uwNdb0vHYM047cCq .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-uwNdb0vHYM047cCq .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-uwNdb0vHYM047cCq .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-uwNdb0vHYM047cCq .actorPopupMenu{position:absolute;}#mermaid-svg-uwNdb0vHYM047cCq .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-uwNdb0vHYM047cCq .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-uwNdb0vHYM047cCq .actor-man circle,#mermaid-svg-uwNdb0vHYM047cCq line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-uwNdb0vHYM047cCq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} window.electron 已由 @electron-toolkit/preload 暴露 主进程主动推送 1. window.electron.ipcRenderer.invoke('get-user', 1) 2. ipcRenderer.invoke('get-user', 1) 3. 查询数据库 4. return { id:1, name:'Electron' } 5. Promise 返回结果 6. win.webContents.send('download-progress', {percent:50}) 7. 触发已注册的监听器 8. 更新进度条 UI


1.3.7、TypeScript 类型安全用法

问题根源

直接使用 window.electron.ipcRenderer 会报类型错误:

typescript 复制代码
window.electron.ipcRenderer.send('ping')  
// ❌ TS2339: 类型"Window & typeof globalThis"上不存在属性"electron"
推荐做法:复用 @electron-toolkit/preload 的类型
typescript 复制代码
// src/renderer/electron.d.ts
/// <reference types="vite/client" />

import { ElectronAPI } from '@electron-toolkit/preload'

declare global {
  interface Window {
    electron: ElectronAPI     // ⬅️ 复用官方类型,包含 ipcRenderer、webFrame、webUtils、process
    api: {
      // 自定义 API 的类型声明(需与 preload 中的定义保持一致)
      saveConfig(config: { theme: string }): Promise<void>
      loadConfig(): Promise<{ theme: string }>
    }
  }
}
最终效果
vue 复制代码
<script setup lang="ts">
// ✅ 预置的 electronAPI 有完整类型
window.electron.ipcRenderer.send('ping')
const user = await window.electron.ipcRenderer.invoke('get-user', 1)
console.log(window.electron.process.platform)
window.electron.webFrame.setZoomFactor(1.2)

// ✅ 自定义 API 也有完整类型
await window.api.saveConfig({ theme: 'dark' })
const config = await window.api.loadConfig()

// ❌ 错误调用会被 TypeScript 拦截
// window.api.saveConfig({})  // 类型报错:缺少 theme 属性
</script>

1.3.8、与 1.1、1.2 节的关系

章节 核心内容 与 electron-vite 的关系
1.1 IPC 通信方法 send/oninvoke/handle、主→渲染推送等原生 API electron-vite 完全沿用,用法不变
1.2 ContextBridge 安全规范 contextBridge.exposeInMainWorld 的安全原则 electron-vite 完全沿用@electron-toolkit/preload 只是帮你封装了这一步
1.3 Electron-Vite 实践 在 electron-vite 项目中如何组织 IPC 代码 使用 @electron-toolkit/preload 简化预加载脚本,开发体验更流畅

核心认知: @electron-toolkit/preload 并没有创造新的 IPC 机制,它只是把 1.1 节和 1.2 节的内容打包成工具,让开发者少写重复代码。理解了这个本质,遇到 bug 时就能快速定位问题,而不是把工具包当成黑盒。


1.3.9、本节速查

问题 答案
electron-vite 改变了 IPC 底层机制吗? ❌ 没有,底层仍使用 ipcRenderer + ipcMain
@electron-toolkit/preload 的作用是什么? 封装了 contextBridge.exposeInMainWorld 的模板代码
渲染进程中如何发送 IPC 消息? window.electron.ipcRenderer.send('channel', data)
渲染进程中如何请求主进程数据? const result = await window.electron.ipcRenderer.invoke('channel', data)
window.electron 包含哪些能力? ipcRendererwebFramewebUtilsprocess
window.electron 包含 shell 吗? ❌ 不包含,需要单独 import { shell } from 'electron'
如何自定义业务 API? 在预加载中定义 api 对象,通过 contextBridge.exposeInMainWorld('api', api) 暴露
window.electron 报类型不存在怎么办? env.d.ts 中扩展 Window 接口
推荐用什么类型? import { ElectronAPI } from '@electron-toolkit/preload'
自定义 api 怎么加类型? Window.api 接口中声明对应的方法签名

2、核心功能模块

2.1、窗口管理

2.1.1、窗口创建、大小、关闭逻辑

2.1.1.1、窗口创建
2.1.1.1.1、基础创建方式
typescript 复制代码
// src/main/index.ts
import { BrowserWindow, app } from 'electron'
import path from 'path'

function createWindow(): BrowserWindow {
  const win = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, '../preload/index.js'),
      nodeIntegration: false,
      contextIsolation: true,
      sandbox: false
    }
  })

  if (process.env.ELECTRON_RENDERER_URL) {
    win.loadURL(process.env.ELECTRON_RENDERER_URL)
  } else {
    win.loadFile(path.join(__dirname, '../renderer/index.html'))
  }

  if (!app.isPackaged) {
    win.webContents.openDevTools({ mode: 'detach' })
  }

  return win
}

app.whenReady().then(() => {
  const win = createWindow()
})
2.1.1.1.2、常用窗口配置项
配置项 类型 说明
width / height number 窗口初始宽高
minWidth / minHeight number 窗口最小尺寸
maxWidth / maxHeight number 窗口最大尺寸
resizable boolean 是否允许调整大小
maximized boolean 是否最大化启动
fullscreen boolean 是否全屏启动
minimized boolean 是否最小化启动
frame boolean 是否显示窗口边框
transparent boolean 是否透明背景
alwaysOnTop boolean 是否置顶
modal boolean 是否为模态窗口
parent BrowserWindow 父窗口
show boolean 是否立即显示
center boolean 是否居中显示
title string 窗口标题
icon string 窗口图标路径
backgroundColor string 窗口背景色
skipTaskbar boolean 是否在任务栏隐藏
titleBarStyle string 标题栏样式(macOS)
trafficLightPosition { x: number, y: number } 交通灯位置(macOS)
2.1.1.1.3、窗口加载事件监听
typescript 复制代码
win.webContents.on('did-finish-load', () => {
  console.log('页面加载完成')
})

win.webContents.on('did-fail-load', (_, errorCode, errorDescription) => {
  console.error('页面加载失败:', errorDescription)
})
2.1.1.2、窗口尺寸控制
2.1.1.2.1、尺寸与位置操作
typescript 复制代码
const win = BrowserWindow.getFocusedWindow()

// -------- 获取 --------
const [width, height] = win.getSize()
const [x, y] = win.getPosition()
const bounds = win.getBounds() // { x, y, width, height }

// -------- 设置 --------
win.setSize(1024, 768)
win.setPosition(100, 50)
win.setBounds({ x: 100, y: 50, width: 1024, height: 768 })

// 居中显示
win.center()
2.1.1.2.2、尺寸约束
typescript 复制代码
// 限制最小/最大尺寸
win.setMinimumSize(800, 600)
win.setMaximumSize(1920, 1080)

// 设置宽高比例约束(如 16:9)
win.setAspectRatio(16 / 9)
2.1.1.2.3、全屏与最大化
方法 说明
win.maximize() 最大化(保留标题栏)
win.unmaximize() 恢复最大化前的尺寸
win.isMaximized() 判断是否最大化
win.fullscreen() 全屏(隐藏所有系统 UI)
win.setFullScreen(false) 退出全屏
win.isFullScreen() 判断是否全屏
win.minimize() 最小化
win.restore() 从最小化恢复
2.1.1.2.4、尺寸变化事件
typescript 复制代码
win.on('resize', () => {
  console.log('窗口尺寸:', win.getSize())
})

win.on('move', () => {
  console.log('窗口位置:', win.getPosition())
})

win.on('enter-full-screen', () => { /* 进入全屏 */ })
win.on('leave-full-screen', () => { /* 退出全屏 */ })

win.on('maximize', () => { /* 最大化 */ })
win.on('unmaximize', () => { /* 还原 */ })
2.1.1.3、窗口关闭逻辑
2.1.1.3.1、关闭流程

App BrowserWindow 用户 App BrowserWindow 用户 #mermaid-svg-5fXoNIcDLoFXvnsM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5fXoNIcDLoFXvnsM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5fXoNIcDLoFXvnsM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5fXoNIcDLoFXvnsM .error-icon{fill:#552222;}#mermaid-svg-5fXoNIcDLoFXvnsM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5fXoNIcDLoFXvnsM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5fXoNIcDLoFXvnsM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5fXoNIcDLoFXvnsM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5fXoNIcDLoFXvnsM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5fXoNIcDLoFXvnsM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5fXoNIcDLoFXvnsM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5fXoNIcDLoFXvnsM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5fXoNIcDLoFXvnsM .marker.cross{stroke:#333333;}#mermaid-svg-5fXoNIcDLoFXvnsM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5fXoNIcDLoFXvnsM p{margin:0;}#mermaid-svg-5fXoNIcDLoFXvnsM .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5fXoNIcDLoFXvnsM text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-5fXoNIcDLoFXvnsM .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5fXoNIcDLoFXvnsM .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-5fXoNIcDLoFXvnsM .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-5fXoNIcDLoFXvnsM .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-5fXoNIcDLoFXvnsM #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-5fXoNIcDLoFXvnsM .sequenceNumber{fill:white;}#mermaid-svg-5fXoNIcDLoFXvnsM #sequencenumber{fill:#333;}#mermaid-svg-5fXoNIcDLoFXvnsM #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-5fXoNIcDLoFXvnsM .messageText{fill:#333;stroke:none;}#mermaid-svg-5fXoNIcDLoFXvnsM .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5fXoNIcDLoFXvnsM .labelText,#mermaid-svg-5fXoNIcDLoFXvnsM .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-5fXoNIcDLoFXvnsM .loopText,#mermaid-svg-5fXoNIcDLoFXvnsM .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-5fXoNIcDLoFXvnsM .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-5fXoNIcDLoFXvnsM .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-5fXoNIcDLoFXvnsM .noteText,#mermaid-svg-5fXoNIcDLoFXvnsM .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-5fXoNIcDLoFXvnsM .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5fXoNIcDLoFXvnsM .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5fXoNIcDLoFXvnsM .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-5fXoNIcDLoFXvnsM .actorPopupMenu{position:absolute;}#mermaid-svg-5fXoNIcDLoFXvnsM .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-5fXoNIcDLoFXvnsM .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-5fXoNIcDLoFXvnsM .actor-man circle,#mermaid-svg-5fXoNIcDLoFXvnsM line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-5fXoNIcDLoFXvnsM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 可在此阻止关闭(如弹窗确认) 点击关闭 / 调用 win.close() 触发 'close' 事件 触发 'will-quit'(仅最后一个窗口) 销毁渲染进程,释放资源 触发 'closed' 事件

2.1.1.3.2、关闭确认弹窗
typescript 复制代码
win.on('close', (event) => {
  if (win.isVisible() === false) return

  const choice = dialog.showMessageBoxSync(win, {
    type: 'question',
    buttons: ['取消', '确定退出'],
    defaultId: 0,
    cancelId: 0,
    message: '确定要退出吗?',
    detail: '未保存的数据将丢失'
  })

  if (choice !== 1) {
    event.preventDefault()
  }
})
2.1.1.3.3、防止关闭死循环
typescript 复制代码
let isClosing = false

win.on('close', (event) => {
  if (isClosing) return

  event.preventDefault()
  
  const choice = dialog.showMessageBoxSync(win, {
    buttons: ['取消', '确定退出'],
    message: '确定要退出吗?'
  })

  if (choice === 1) {
    isClosing = true
    win.close()
  }
})
2.1.1.3.4、清理资源
typescript 复制代码
win.on('closed', () => {
  win = null
})
2.1.1.4、应用退出逻辑
2.1.1.4.1、退出事件
事件 触发时机 可阻止
before-quit 应用即将退出
will-quit 所有窗口关闭后
quit 应用已退出
typescript 复制代码
app.on('will-quit', (event) => {
  // 可调用 event.preventDefault() 阻止退出
})

app.on('quit', () => {
  console.log('应用已退出')
})
2.1.1.4.2、各平台差异处理
typescript 复制代码
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  }
})
平台 关闭所有窗口后的行为 推荐做法
Windows / Linux 应用退出 window-all-closed 中调用 app.quit()
macOS 应用保持运行 不退出,在 activate 中重建窗口
2.1.1.5、多窗口管理
2.1.1.5.1、获取窗口
typescript 复制代码
// 所有窗口
const allWindows = BrowserWindow.getAllWindows()

// 当前焦点窗口
const focusedWindow = BrowserWindow.getFocusedWindow()

// 根据 webContents 获取
const win = BrowserWindow.fromWebContents(event.sender)
2.1.1.5.2、跨窗口通信
typescript 复制代码
// 广播到所有窗口
function broadcastToAllWindows(channel: string, data: any) {
  BrowserWindow.getAllWindows().forEach((win) => {
    win.webContents.send(channel, data)
  })
}

// 向特定窗口发送(通过 webContents ID)
webContents.sendTo(targetId, 'private-message', data)

// 获取消息来源窗口
ipcMain.handle('some-action', (event) => {
  const win = BrowserWindow.fromWebContents(event.sender)
})
2.1.1.6、窗口状态持久化
typescript 复制代码
import Store from 'electron-store'

const store = new Store()

function createWindow() {
  const savedBounds = store.get('windowBounds', { width: 1200, height: 800 })
  
  const win = new BrowserWindow({
    ...savedBounds,
    webPreferences: { /* ... */ }
  })

  const saveBounds = () => {
    const bounds = win.getBounds()
    store.set('windowBounds', bounds)
  }

  win.on('resize', saveBounds)
  win.on('move', saveBounds)
  win.on('close', saveBounds)

  // ...
}
2.1.1.7、开发调试技巧
2.1.1.7.1、自动打开 DevTools
typescript 复制代码
if (!app.isPackaged) {
  win.webContents.openDevTools({ mode: 'detach' })
}
2.1.1.7.2、常用调试命令
typescript 复制代码
// 查看窗口 ID
console.log(win.id)

// 查看所有窗口
console.log(BrowserWindow.getAllWindows().map(w => w.id))

// 强制刷新页面
win.reload()

// 打开 DevTools
win.webContents.openDevTools()
2.1.1.8、本节速查
问题 答案
如何创建窗口? new BrowserWindow({ ... })
如何加载页面? win.loadURL(url)win.loadFile(path)
如何限制最小尺寸? win.setMinimumSize(800, 600)
如何居中? win.center()
最大化与全屏的区别? 最大化保留标题栏;全屏隐藏系统 UI
关闭事件触发顺序? closewill-quit(最后一个)→ closed
如何阻止关闭? event.preventDefault()
macOS 关闭所有窗口后应用保持运行? 不调用 app.quit(),在 activate 中重建
如何保存窗口尺寸? electron-store 持久化
开发时如何调试? win.webContents.openDevTools()

2.1.2、 Vue 路由页面的加载方式

2.1.2.1、加载方式概述

在 Electron 中,Vue 路由页面的加载方式取决于运行环境(开发环境 vs 生产环境):

环境 加载方式 URL 格式
开发环境 Vite 开发服务器 http://localhost:5173(带热更新)
生产环境 本地静态文件 file:///path/to/renderer/index.html

核心思路:开发环境加载 URL,生产环境加载本地文件,并支持 Vue Router 的 hash 路由。

2.1.2.2、参考代码(推荐方式)

以下代码封装了统一的页面加载工具函数,同时支持开发/生产环境和 hash 路由:

typescript 复制代码
// src/main/utils/url.ts
import { pathToFileURL } from 'url'
import path from 'path'
import { app } from 'electron'

// 渲染进程 HTML 入口路径
const rendererHtmlPath = path.join(__dirname, '../renderer/index.html')

// 是否为开发环境
const isDevelopment = !app.isPackaged

// 获取渲染进程的基础 URL
const getRendererBaseUrl = (): string => {
  // 开发环境:使用 Vite 开发服务器
  if (isDevelopment && process.env.ELECTRON_RENDERER_URL) {
    return process.env.ELECTRON_RENDERER_URL
  }
  // 生产环境:使用本地文件路径(转换为 file:// 协议)
  return pathToFileURL(rendererHtmlPath).toString()
}

/**
 * 构建渲染进程的完整加载 URL
 * @param hash - Vue Router 的路由路径(如 '/settings' 或 'settings')
 * @returns 完整的加载 URL
 */
export const buildRendererUrl = (hash?: string): string => {
  const base = getRendererBaseUrl()
  if (!hash) return base
  
  // 确保 hash 以 # 开头,兼容 Vue Router 的 hash 模式
  const cleanedHash = hash.startsWith('#') ? hash : `#${hash}`
  return `${base}${cleanedHash}`
}
2.1.2.3、在窗口创建中使用
typescript 复制代码
// src/main/index.ts
import { BrowserWindow, app } from 'electron'
import { buildRendererUrl } from './utils/url'

function createWindow(): BrowserWindow {
  const win = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, '../preload/index.js'),
      nodeIntegration: false,
      contextIsolation: true,
      sandbox: false
    }
  })

  // 方式一:加载默认页面(不带路由)
  win.loadURL(buildRendererUrl())

  // 方式二:加载指定路由页面
  // win.loadURL(buildRendererUrl('/home'))

  // 方式三:加载带参数的路由页面
  // win.loadURL(buildRendererUrl('/user/profile?id=123'))

  return win
}

app.whenReady().then(() => {
  const win = createWindow()
})
2.1.2.4、hash 模式与 history 模式
2.1.2.4.1、hash 模式(推荐,无需额外配置)

Vue Router 默认使用 hash 模式,URL 格式为 file:///path/index.html#/home

typescript 复制代码
// Vue Router 配置(推荐)
import { createRouter, createWebHashHistory } from 'vue-router'

const router = createRouter({
  history: createWebHashHistory(),  // hash 模式
  routes: [
    { path: '/', component: Home },
    { path: '/settings', component: Settings }
  ]
})

// 主进程中加载
win.loadURL(buildRendererUrl('/settings'))
// 实际 URL: file:///path/index.html#/settings

优点: 无需服务端配置,所有路由都基于 index.html,与 Electron 的 loadFile 完美兼容。

2.1.2.4.2、history 模式(需额外处理 404 回退)

不推荐使用

2.1.2.5、开发环境与生产环境的加载流程

#mermaid-svg-wzuGEcQ7lpm8LBSu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wzuGEcQ7lpm8LBSu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wzuGEcQ7lpm8LBSu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wzuGEcQ7lpm8LBSu .error-icon{fill:#552222;}#mermaid-svg-wzuGEcQ7lpm8LBSu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wzuGEcQ7lpm8LBSu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wzuGEcQ7lpm8LBSu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wzuGEcQ7lpm8LBSu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wzuGEcQ7lpm8LBSu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wzuGEcQ7lpm8LBSu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wzuGEcQ7lpm8LBSu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wzuGEcQ7lpm8LBSu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wzuGEcQ7lpm8LBSu .marker.cross{stroke:#333333;}#mermaid-svg-wzuGEcQ7lpm8LBSu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wzuGEcQ7lpm8LBSu p{margin:0;}#mermaid-svg-wzuGEcQ7lpm8LBSu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wzuGEcQ7lpm8LBSu .cluster-label text{fill:#333;}#mermaid-svg-wzuGEcQ7lpm8LBSu .cluster-label span{color:#333;}#mermaid-svg-wzuGEcQ7lpm8LBSu .cluster-label span p{background-color:transparent;}#mermaid-svg-wzuGEcQ7lpm8LBSu .label text,#mermaid-svg-wzuGEcQ7lpm8LBSu span{fill:#333;color:#333;}#mermaid-svg-wzuGEcQ7lpm8LBSu .node rect,#mermaid-svg-wzuGEcQ7lpm8LBSu .node circle,#mermaid-svg-wzuGEcQ7lpm8LBSu .node ellipse,#mermaid-svg-wzuGEcQ7lpm8LBSu .node polygon,#mermaid-svg-wzuGEcQ7lpm8LBSu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wzuGEcQ7lpm8LBSu .rough-node .label text,#mermaid-svg-wzuGEcQ7lpm8LBSu .node .label text,#mermaid-svg-wzuGEcQ7lpm8LBSu .image-shape .label,#mermaid-svg-wzuGEcQ7lpm8LBSu .icon-shape .label{text-anchor:middle;}#mermaid-svg-wzuGEcQ7lpm8LBSu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wzuGEcQ7lpm8LBSu .rough-node .label,#mermaid-svg-wzuGEcQ7lpm8LBSu .node .label,#mermaid-svg-wzuGEcQ7lpm8LBSu .image-shape .label,#mermaid-svg-wzuGEcQ7lpm8LBSu .icon-shape .label{text-align:center;}#mermaid-svg-wzuGEcQ7lpm8LBSu .node.clickable{cursor:pointer;}#mermaid-svg-wzuGEcQ7lpm8LBSu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wzuGEcQ7lpm8LBSu .arrowheadPath{fill:#333333;}#mermaid-svg-wzuGEcQ7lpm8LBSu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wzuGEcQ7lpm8LBSu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wzuGEcQ7lpm8LBSu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wzuGEcQ7lpm8LBSu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wzuGEcQ7lpm8LBSu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wzuGEcQ7lpm8LBSu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wzuGEcQ7lpm8LBSu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wzuGEcQ7lpm8LBSu .cluster text{fill:#333;}#mermaid-svg-wzuGEcQ7lpm8LBSu .cluster span{color:#333;}#mermaid-svg-wzuGEcQ7lpm8LBSu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wzuGEcQ7lpm8LBSu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wzuGEcQ7lpm8LBSu rect.text{fill:none;stroke-width:0;}#mermaid-svg-wzuGEcQ7lpm8LBSu .icon-shape,#mermaid-svg-wzuGEcQ7lpm8LBSu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wzuGEcQ7lpm8LBSu .icon-shape p,#mermaid-svg-wzuGEcQ7lpm8LBSu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wzuGEcQ7lpm8LBSu .icon-shape .label rect,#mermaid-svg-wzuGEcQ7lpm8LBSu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wzuGEcQ7lpm8LBSu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wzuGEcQ7lpm8LBSu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wzuGEcQ7lpm8LBSu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是



调用 buildRendererUrl
是否为开发环境
读取 ELECTRON_RENDERER_URL
加载 http://localhost:5173
Vite HMR 热更新
定位 renderer/index.html
pathToFileURL 转换为 file:// 协议
加载本地静态文件
是否传入 hash?
拼接 #/xxx
加载基础页面

2.1.2.6、常用路由跳转场景
场景 代码示例 说明
加载首页 win.loadURL(buildRendererUrl()) 不带 hash
加载设置页 win.loadURL(buildRendererUrl('/settings')) hash 模式
加载用户页 win.loadURL(buildRendererUrl('/user?id=1')) 带查询参数
加载深层嵌套 win.loadURL(buildRendererUrl('/dashboard/analytics')) 多级路由
2.1.2.7、本节速查
问题 答案
开发环境如何加载页面? process.env.ELECTRON_RENDERER_URL(Vite 服务器地址)
生产环境如何加载页面? pathToFileURL(path.join(__dirname, '../renderer/index.html'))
为什么要用 pathToFileURL 将文件路径转为 file:// 协议,与 loadURL 兼容
Vue Router 推荐哪种模式? createWebHashHistory()(hash 模式),无需额外配置
history 模式有什么问题? 生产环境 file:///path/xxx 会 404,需做回退处理
如何加载指定路由? buildRendererUrl('/settings')

2.2、托盘管理

2.2.1、托盘概述

托盘(Tray)是 Electron 桌面应用的重要功能,允许应用在系统任务栏/菜单栏中显示图标,并提供快捷操作入口。用户可以通过托盘图标快速访问应用的核心功能,而无需打开主窗口。

核心能力:

能力 说明
系统托盘图标 在系统通知区域显示自定义图标
右键上下文菜单 提供快捷操作入口(如显示/退出/设置)
鼠标事件响应 单击/双击/右键/悬停等交互
图标闪烁提醒 消息通知、下载完成等场景的视觉提醒
提示文本 鼠标悬停时显示状态信息

2.2.2、托盘创建

2.2.2.1、基础创建
typescript 复制代码
import { Tray, nativeImage, Menu, BrowserWindow } from 'electron'
import path from 'path'

let tray: Tray | null = null
let mainWindow: BrowserWindow | null = null

function createTray() {
  // 1. 创建图标
  const iconPath = path.join(__dirname, '../../resources/tray_icon.png')
  const icon = nativeImage.createFromPath(iconPath)
  
  // 2. 创建托盘实例
  tray = new Tray(icon)
  
  // 3. 设置提示文本
  tray.setToolTip('My Electron App')
  
  // 4. 创建右键菜单
  const contextMenu = Menu.buildFromTemplate([
    { label: '显示主窗口', click: () => showWindow() },
    { label: '退出', click: () => app.quit() }
  ])
  tray.setContextMenu(contextMenu)
  
  // 5. 绑定事件
  tray.on('click', () => {
    // 左键单击:显示/隐藏窗口
    toggleWindow()
  })
}
2.2.2.2、创建参数与配置
配置项 说明
icon 图标路径(支持 PNG/ICO/JPEG/多种尺寸)
tooltip 鼠标悬停提示文本
contextMenu 右键菜单模板

2.2.3、托盘事件

2.2.3.1、支持的事件
事件 触发时机 典型用途
click 左键单击 显示/隐藏主窗口
right-click 右键单击 显示上下文菜单(系统会自动处理,也可手动覆盖)
double-click 左键双击 打开主窗口
mouse-enter 鼠标进入托盘区域 显示额外提示信息
mouse-leave 鼠标离开托盘区域 隐藏提示
drag-enter 文件拖入托盘区域 接收拖拽文件
drag-leave 文件拖出托盘区域 拖拽状态清理
drop 文件在托盘区域释放 处理拖入的文件
drop-text 文本拖入托盘区域 处理拖入的文本
2.2.3.2、事件绑定
typescript 复制代码
// 左键单击:切换窗口显示状态
tray.on('click', () => {
  if (mainWindow) {
    if (mainWindow.isVisible()) {
      mainWindow.hide()
    } else {
      mainWindow.show()
      mainWindow.focus()
    }
  }
})

// 双击:确保窗口显示并聚焦
tray.on('double-click', () => {
  if (mainWindow) {
    if (mainWindow.isMinimized()) mainWindow.restore()
    mainWindow.show()
    mainWindow.focus()
  }
})

// 鼠标进入/离开:可用于状态提示
tray.on('mouse-enter', () => {
  // 例如:显示更详细的 tooltip
  tray.setToolTip('点击显示窗口 · 右键打开菜单')
})

tray.on('mouse-leave', () => {
  tray.setToolTip('My Electron App')
})

2.2.4、托盘菜单

2.2.4.1、基础菜单
typescript 复制代码
const menu = Menu.buildFromTemplate([
  {
    label: '显示窗口',
    click: () => { /* 显示窗口逻辑 */ }
  },
  {
    label: '设置',
    click: () => { /* 打开设置窗口逻辑 */ }
  },
  { type: 'separator' },  // 分割线
  {
    label: '退出',
    click: () => { app.quit() }
  }
])

tray.setContextMenu(menu)
2.2.4.2、动态更新菜单
typescript 复制代码
function updateMenu() {
  const menuItems = [
    {
      label: isLoggedIn ? '个人中心' : '登录',
      click: () => { /* ... */ }
    },
    {
      label: isLoggedIn ? '退出登录' : '注册',
      click: () => { /* ... */ }
    },
    { type: 'separator' },
    {
      label: '退出',
      click: () => { app.quit() }
    }
  ]
  
  const menu = Menu.buildFromTemplate(menuItems)
  tray.setContextMenu(menu)
}
2.2.4.3、菜单项常用属性
属性 类型 说明
label string 菜单项显示文本
click Function 点击回调
enabled boolean 是否可点击
checked boolean 是否选中(checkbox 类型)
type `'normal' 'separator'
submenu MenuItem[] 子菜单
accelerator string 快捷键(如 CmdOrCtrl+Q

2.2.5、图标闪烁提醒

2.2.5.1、实现原理

通过定时器交替切换托盘图标,达到视觉闪烁效果。需要准备两种图标:

  • 正常图标:应用默认状态
  • 空图标:透明或半透明图标(与正常图标同尺寸)
typescript 复制代码
let flashTimer: NodeJS.Timeout | null = null
let isFlashing = false

function startFlash() {
  if (flashTimer) return
  
  const normalIcon = nativeImage.createFromPath('icon_normal.png')
  const emptyIcon = nativeImage.createFromPath('icon_empty.png')  // 透明图
  
  let showNormal = false
  flashTimer = setInterval(() => {
    showNormal = !showNormal
    tray?.setImage(showNormal ? normalIcon : emptyIcon)
  }, 500)
}

function stopFlash() {
  if (flashTimer) {
    clearInterval(flashTimer)
    flashTimer = null
  }
  // 恢复为正常图标
  const normalIcon = nativeImage.createFromPath('icon_normal.png')
  tray?.setImage(normalIcon)
}
2.2.5.2、使用场景
场景 触发方式
收到新消息 startFlash()
下载完成 startFlash()
需要用户注意 startFlash()
用户已处理 stopFlash()

2.2.6、托盘动态更新

2.2.6.1、更新提示文本
typescript 复制代码
function updateTooltip(text: string) {
  tray?.setToolTip(text)
}

// 示例:更新未读消息数量
function updateUnreadCount(count: number) {
  if (count > 0) {
    tray?.setToolTip(`您有 ${count} 条未读消息`)
  } else {
    tray?.setToolTip('My App')
  }
}
2.2.6.2、更新图标
typescript 复制代码
function updateIcon(iconPath: string) {
  const icon = nativeImage.createFromPath(iconPath)
  tray?.setImage(icon)
}

// 示例:状态变化时更换图标
function updateStatus(status: 'online' | 'offline' | 'busy') {
  const iconMap = {
    online: 'icon_online.png',
    offline: 'icon_offline.png',
    busy: 'icon_busy.png'
  }
  updateIcon(iconMap[status])
}

2.2.7、主题适配

2.2.7.1、监听系统主题变化

系统主题变化时,托盘图标应随之适配,以确保在深色/浅色模式下均清晰可见。

typescript 复制代码
import { nativeTheme } from 'electron'

// 监听系统主题变化
nativeTheme.on('updated', () => {
  const isDark = nativeTheme.shouldUseDarkColors
  updateIcon(isDark ? 'icon_dark.png' : 'icon_light.png')
})
2.2.7.2、获取主题状态
typescript 复制代码
const isDarkMode = nativeTheme.shouldUseDarkColors
const isHighContrast = nativeTheme.shouldUseHighContrastColors

2.2.8、多平台差异

2.2.8.1、各平台特性
平台 特点
Windows 托盘位于任务栏右下角(通知区域);支持所有鼠标事件
macOS 托盘位于菜单栏右侧(状态栏);通常只支持 click 事件,double-click 可能不生效
Linux 行为因桌面环境而异(GNOME/KDE 等);需确保图标与主题兼容
2.2.8.2、平台适配建议
typescript 复制代码
import { platform } from 'os'

function setupTrayEvents() {
  // macOS 通常只响应 click
  if (platform() === 'darwin') {
    tray.on('click', handleClick)
  } else {
    tray.on('click', handleClick)
    tray.on('right-click', handleRightClick)
    tray.on('double-click', handleDoubleClick)
  }
}

图标尺寸建议:

平台 推荐尺寸
Windows 16x16、32x32、48x48
macOS 16x16、22x22(Retina:32x32、44x44)
Linux 随主题变化,建议提供多尺寸

2.2.9、主窗口与托盘的交互

2.2.9.1、点击托盘切换窗口显示
typescript 复制代码
tray.on('click', () => {
  if (!mainWindow) return
  
  if (mainWindow.isVisible()) {
    mainWindow.hide()
  } else {
    if (mainWindow.isMinimized()) mainWindow.restore()
    mainWindow.show()
    mainWindow.focus()
  }
})
2.2.9.2、窗口隐藏/显示时同步托盘状态
typescript 复制代码
// 窗口关闭时不要退出应用,而是隐藏窗口
mainWindow.on('close', (event) => {
  if (!app.isQuitting) {
    event.preventDefault()
    mainWindow.hide()
  }
})

// 窗口显示时更新托盘状态
mainWindow.on('show', () => {
  updateTooltip('应用已打开')
})

// 窗口隐藏时更新托盘状态
mainWindow.on('hide', () => {
  updateTooltip('应用已最小化到托盘')
})

2.2.10、托盘销毁与清理

应用退出或重新创建托盘时,需彻底清理资源:

typescript 复制代码
function destroyTray() {
  // 1. 清除闪烁定时器
  if (flashTimer) {
    clearInterval(flashTimer)
    flashTimer = null
  }
  
  // 2. 销毁托盘实例
  if (tray) {
    tray.destroy()
    tray = null
  }
}

注意: tray.destroy() 会自动移除图标并释放资源,无需额外清理事件监听。

2.2.11、本节速查

问题 答案
如何创建托盘? new Tray(nativeImage.createFromPath(iconPath))
如何设置右键菜单? tray.setContextMenu(Menu.buildFromTemplate(items))
如何实现图标闪烁? setInterval 交替设置正常图标和空图标
如何监听系统主题变化? nativeTheme.on('updated', callback)
如何更新托盘图标? tray.setImage(nativeImage.createFromPath(iconPath))
如何更新提示文本? tray.setToolTip(text)
macOS 左键双击事件是否生效? 通常不生效,建议仅使用 click 事件
窗口关闭时如何最小化到托盘? 监听 close 事件,event.preventDefault() + win.hide()
如何销毁托盘? tray.destroy()
资源文件打包需注意什么? 配置 asarUnpacked 确保图标文件不被压缩打包

2.3、快捷键管理(全局/局部)

2.3.1、快捷键概述

Electron 中的快捷键分为两类:

类型 说明 适用场景
全局快捷键 即使应用未获得焦点也能响应 系统级操作(如全局截图、音乐控制)
局部快捷键 仅当应用窗口获得焦点时生效 应用内功能(如保存、撤销、复制)

两者在实现方式和使用场景上有本质区别。

2.3.2、全局快捷键

全局快捷键通过 globalShortcut 模块实现,注册后在整个系统中生效,无论应用是否处于激活状态。

2.3.2.1、基础使用
typescript 复制代码
import { globalShortcut, app } from 'electron'

// 注册全局快捷键
app.whenReady().then(() => {
  const ret = globalShortcut.register('CommandOrControl+Shift+I', () => {
    console.log('全局快捷键被触发')
  })

  if (!ret) {
    console.log('注册失败,可能被其他应用占用')
  }
})

// 注销指定快捷键
globalShortcut.unregister('CommandOrControl+Shift+I')

// 注销所有全局快捷键
app.on('will-quit', () => {
  globalShortcut.unregisterAll()
})
2.3.2.2、完整实现(参考示例)

你提供的代码实现了一个多窗口场景下的全局快捷键管理器,核心设计如下:

typescript 复制代码
// src/main/shortcut-manager.ts
import { BrowserWindow, globalShortcut, ipcMain, IpcMainInvokeEvent } from 'electron'
import { IpcEventType } from '~/universal/types/enum'

// 组合键 -> 窗口 ID
const registeredMap = new Map<string, number>()
// 窗口 ID -> 该窗口注册的所有组合键集合
const windowCombos = new Map<number, Set<string>>()

// 获取窗口 ID(⚠️ 注意:不要用 event.sender.id,那是网页 ID)
function getWindowId(event: IpcMainInvokeEvent): number | null {
  const win = BrowserWindow.fromWebContents(event.sender)
  return win?.id ?? null
}

function sendTrigger(combo: string, windowId: number) {
  const win = BrowserWindow.fromId(windowId)
  if (win && !win.isDestroyed()) {
    win.webContents.send(IpcEventType.shortcut_triggered, combo)
  }
}

关键设计点:

设计点 说明
registeredMap 记录快捷键归属窗口,用于冲突检测和触发路由
windowCombos 记录每个窗口注册的快捷键列表,便于窗口关闭时批量清理
冲突检测 同一快捷键只能被一个窗口注册,后续窗口尝试注册返回失败
来源定位 通过 BrowserWindow.fromWebContents 获取真实窗口 ID
触发路由 快捷键触发时,只通知注册该快捷键的窗口
2.3.2.3、注册与注销 IPC 方法

1. 注册快捷键:

typescript 复制代码
ipcMain.handle(IpcEventType.shortcut_register, async (event, combo: string) => {
  const winId = getWindowId(event)

  // 已被本窗口注册 → 成功
  if (registeredMap.get(combo) === winId) {
    return { success: true }
  }

  // 已被其他窗口注册 → 拒绝
  if (registeredMap.has(combo)) {
    return { success: false, reason: 'already_registered_by_other' }
  }

  const ret = globalShortcut.register(combo, () => {
    sendTrigger(combo, winId)
  })

  if (ret) {
    registeredMap.set(combo, winId)
    if (!windowCombos.has(winId)) {
      windowCombos.set(winId, new Set())
    }
    windowCombos.get(winId)!.add(combo)
    return { success: true }
  }

  return { success: false, reason: 'register_failed' }
})

2. 注销指定快捷键:

typescript 复制代码
ipcMain.handle(IpcEventType.shortcut_unregister, async (event, combo: string) => {
  globalShortcut.unregister(combo)
  
  if (registeredMap.has(combo)) {
    const winId = registeredMap.get(combo)!
    registeredMap.delete(combo)
    windowCombos.get(winId)?.delete(combo)
    if (windowCombos.get(winId)?.size === 0) {
      windowCombos.delete(winId)
    }
  }
  return true
})

3. 注销当前窗口所有快捷键:

typescript 复制代码
ipcMain.handle(IpcEventType.shortcut_unregister_current_window, async (event) => {
  const winId = getWindowId(event)
  const combos = windowCombos.get(winId)
  
  if (combos) {
    for (const combo of combos) {
      if (registeredMap.get(combo) === winId) {
        globalShortcut.unregister(combo)
        registeredMap.delete(combo)
      }
    }
    windowCombos.delete(winId)
  }
  return true
})

4. 查询与调试:

typescript 复制代码
// 检测快捷键是否已被注册
ipcMain.handle(IpcEventType.shortcut_is_registered, async (_event, combo: string) => {
  return registeredMap.has(combo)
})

// 获取所有已注册快捷键(调试用)
ipcMain.handle(IpcEventType.shortcut_get_registered, async () => {
  return Array.from(registeredMap.keys())
})
2.3.2.4、清理(应用退出时)
typescript 复制代码
export function cleanupShortcuts() {
  globalShortcut.unregisterAll()
  registeredMap.clear()
  windowCombos.clear()
}

// 在 app 退出时调用
app.on('will-quit', cleanupShortcuts)

2.3.3、局部快捷键

局部快捷键仅在应用窗口获得焦点时生效 ,不会与系统或其他应用产生冲突。详见 2.3.5 节详细实现

本节仅作对比说明:

对比项 全局快捷键 局部快捷键
生效范围 系统级 仅应用内
冲突风险 高(可能被其他应用占用)
注册方式 globalShortcut.register() 菜单加速器 / 键盘事件
典型场景 全局截图、音乐播放 Ctrl+S、Ctrl+Z

局部快捷键的三种实现方式:

方式 适用场景 说明
应用菜单加速器 应用级功能(保存、打印) 与菜单绑定,用户可见
渲染进程键盘事件 页面/组件级快捷键 灵活可控,可精细判断焦点
electron-localshortcut 主进程注册局部快捷键 API 简洁,自动管理生命周期

2.3.4、快捷键冲突处理

2.3.4.1、冲突检测
typescript 复制代码
// 检测快捷键是否被其他应用占用
const isRegistered = globalShortcut.isRegistered('CommandOrControl+Shift+A')

if (isRegistered) {
  console.log('该快捷键已被占用')
} else {
  globalShortcut.register('CommandOrControl+Shift+A', callback)
}
2.3.4.2、多窗口冲突(参考实现)
typescript 复制代码
// 同一快捷键只能被一个窗口注册
if (registeredMap.has(combo)) {
  return { success: false, reason: 'already_registered_by_other' }
}
2.3.4.3、冲突处理策略
策略 说明
拒绝注册 快捷键已被占用时拒绝新注册(参考实现采用此方式)
覆盖注册 强制注销旧注册,注册新窗口
多窗口分发 同一快捷键触发时,通知所有注册的窗口

2.3.5、局部快捷键详细实现

2.3.5.1、方式一:应用菜单加速器(推荐)

在主进程中创建菜单时,为菜单项绑定快捷键:

typescript 复制代码
// src/main/index.ts
import { Menu, MenuItem } from 'electron'

const menu = new Menu()

menu.append(new MenuItem({
  label: '保存',
  accelerator: 'CmdOrCtrl+S',
  click: () => {
    // 发送事件到当前活动窗口
    const win = BrowserWindow.getFocusedWindow()
    win?.webContents.send('menu-save')
  }
}))

Menu.setApplicationMenu(menu)

优点: 用户可以在菜单栏中看到快捷键提示,交互直观。

2.3.5.2、方式二:渲染进程键盘事件(页面级)

在 Vue 组件中监听键盘事件,适用于页面级或组件级快捷键:

vue 复制代码
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'

const handleKeydown = (event: KeyboardEvent) => {
  // Ctrl+S 或 Command+S
  if ((event.ctrlKey || event.metaKey) && event.key === 's') {
    event.preventDefault()
    console.log('保存')
    // 执行业务逻辑
  }
}

onMounted(() => {
  window.addEventListener('keydown', handleKeydown)
})

onUnmounted(() => {
  window.removeEventListener('keydown', handleKeydown)
})
</script>

优点: 灵活,可根据当前焦点元素精细控制。

2.3.5.3、方式三:electron-localshortcut(主进程局部)

第三方库,在主进程中注册仅对特定窗口生效的快捷键:

typescript 复制代码
import electronLocalshortcut from 'electron-localshortcut'

electronLocalshortcut.register(win, 'Ctrl+A', () => {
  console.log('在当前窗口按下了 Ctrl+A')
})

// 窗口失焦时自动禁用,重新聚焦时自动恢复

优点: API 简洁,自动管理生命周期。

2.3.6、渲染进程调用快捷键(参考实现)

在 Vue 组件中调用主进程的快捷键管理器:

typescript 复制代码
// src/renderer/src/composables/useShortcut.ts
import { onMounted, onUnmounted } from 'vue'

export function useShortcut() {
  // 注册快捷键
  const registerShortcut = async (combo: string) => {
    const result = await window.electron.ipcRenderer.invoke(
      IpcEventType.shortcut_register,
      combo
    )
    if (!result.success) {
      console.warn('快捷键注册失败:', result.reason)
    }
    return result
  }

  // 注销快捷键
  const unregisterShortcut = async (combo: string) => {
    return window.electron.ipcRenderer.invoke(IpcEventType.shortcut_unregister, combo)
  }

  // 监听快捷键触发事件
  const onShortcutTriggered = (callback: (combo: string) => void) => {
    const handler = (_event: any, combo: string) => callback(combo)
    window.electron.ipcRenderer.on(IpcEventType.shortcut_triggered, handler)
    return () => {
      window.electron.ipcRenderer.removeListener(IpcEventType.shortcut_triggered, handler)
    }
  }

  return {
    registerShortcut,
    unregisterShortcut,
    onShortcutTriggered
  }
}

组件中使用:

vue 复制代码
<script setup lang="ts">
import { useShortcut } from '@/composables/useShortcut'

const { registerShortcut, onShortcutTriggered } = useShortcut()

const handleShortcut = (combo: string) => {
  console.log('快捷键被触发:', combo)
  // 执行对应操作
}

onMounted(async () => {
  // 注册全局快捷键
  await registerShortcut('CommandOrControl+Shift+A')
  
  // 监听触发事件
  const cleanup = onShortcutTriggered(handleShortcut)
  
  // 组件卸载时清理
  onUnmounted(cleanup)
})
</script>

2.3.7、常用快捷键组合

组合键 说明
CommandOrControl+C 复制
CommandOrControl+V 粘贴
CommandOrControl+X 剪切
CommandOrControl+Z 撤销
CommandOrControl+Y 重做
CommandOrControl+S 保存
CommandOrControl+Shift+I 打开开发者工具
CommandOrControl+Q 退出应用
F11 全屏切换

2.3.8、最佳实践

实践 说明
区分全局/局部 仅必要时使用全局快捷键,避免干扰用户
冲突检测 注册前使用 globalShortcut.isRegistered 检测
及时注销 窗口关闭时注销该窗口的所有快捷键
应用退出清理 app.on('will-quit') 中调用 unregisterAll
用户可知 局部快捷键建议在菜单中显示加速器,全局快捷键需向用户说明
平台适配 使用 CommandOrControl 自动适配 Win/Linux 和 macOS
错误处理 注册失败时给用户友好提示

2.3.9、本节速查

问题 答案
全局快捷键用什么模块? globalShortcut
如何检测快捷键是否被占用? globalShortcut.isRegistered(combo)
如何注销所有全局快捷键? globalShortcut.unregisterAll()
局部快捷键有哪些实现方式? 菜单加速器 / 渲染进程键盘事件 / electron-localshortcut
如何适配 Win/Linux 和 macOS? 使用 CommandOrControl 前缀
窗口关闭时如何处理快捷键? 注销该窗口注册的所有快捷键
全局快捷键可以跨窗口共享吗? 可以,但需要自行管理归属关系

2.4、本地存储与持久化

2.4.1、项目缓存目录

2.4、本地存储与持久化

2.4.1、项目缓存目录

2.4.1.1、缓存目录概述

在 Electron 应用中,缓存目录用于存储应用运行过程中产生的临时数据,包括:

缓存类型 说明
网页缓存 Chromium 生成的 HTTP 缓存、图片、脚本等资源
代码缓存 V8 引擎生成的 JavaScript 编译缓存
应用自定义缓存 开发者自行存储的临时数据(如接口响应、缩略图等)

缓存目录与用户数据目录的区别:

对比项 缓存目录(Cache) 用户数据目录(userData)
数据性质 临时、可重建 持久、不可丢失
系统清理 可能被系统自动清理 不会被系统自动清理
典型数据 网页资源、图片缓存 用户配置、数据库文件
2.4.1.2、获取缓存目录:app.getPath()

Electron 提供了 app.getPath(name) 方法,用于获取系统级别的标准目录路径。

typescript 复制代码
import { app } from 'electron'

// 获取用户数据目录(推荐用于持久化存储)
const userDataPath = app.getPath('userData')
// Windows: C:\Users\<user>\AppData\Roaming\<app-name>
// macOS: ~/Library/Application Support/<app-name>
// Linux: ~/.config/<app-name>

// 获取系统缓存目录
const cachePath = app.getPath('cache')
// Windows: C:\Users\<user>\AppData\Local\Temp\<app-name>\Cache
// macOS: ~/Library/Caches/<app-name>
// Linux: ~/.cache/<app-name>

app.getPath() 常用 name 参数:

name 说明 用途
userData 当前应用的数据目录 ⭐ 推荐:存储配置和持久化数据
cache 临时缓存目录 存储可重建的临时数据
appData 用户应用数据目录 跨应用共享数据
temp 系统临时目录 临时文件
documents 用户文档目录 用户文件
downloads 用户下载目录 下载文件
home 用户主目录 跨平台基础路径
2.4.1.3、各平台默认路径
2.4.1.3.1、userData ------ 用户数据目录
平台 默认路径
Windows %APPDATA%\<app-name>(如 C:\Users\xxx\AppData\Roaming\MyApp
macOS ~/Library/Application Support/<app-name>
Linux $XDG_CONFIG_HOME~/.config/<app-name>

这是最推荐 的持久化数据存储位置,electron-store 等库默认使用此目录。

2.4.1.3.2、cache ------ 系统缓存目录
平台 默认路径
Windows %TEMP%\<app-name>\Cache
macOS ~/Library/Caches/<app-name>
Linux ~/.cache/<app-name>
2.4.1.4、项目自定义缓存目录
2.4.1.4.1、在 userData 下创建自定义缓存子目录

最佳实践是在 userData 目录下创建应用专属的缓存子目录:

typescript 复制代码
// src/main/utils/cache.ts
import { app } from 'electron'
import path from 'path'
import fs from 'fs'

export function getAppCacheDir(): string {
  const userData = app.getPath('userData')
  const cacheDir = path.join(userData, 'cache')
  
  // 确保目录存在
  if (!fs.existsSync(cacheDir)) {
    fs.mkdirSync(cacheDir, { recursive: true })
  }
  
  return cacheDir
}

export function getAppTempDir(): string {
  const cacheDir = getAppCacheDir()
  const tempDir = path.join(cacheDir, 'temp')
  
  if (!fs.existsSync(tempDir)) {
    fs.mkdirSync(tempDir, { recursive: true })
  }
  
  return tempDir
}
2.4.1.4.2、使用系统缓存目录

对于大型临时数据(如下载缓存),可以使用系统缓存目录:

typescript 复制代码
function getSystemCacheDir(): string {
  const baseCache = app.getPath('cache')
  const appCache = path.join(baseCache, 'my-app')
  
  if (!fs.existsSync(appCache)) {
    fs.mkdirSync(appCache, { recursive: true })
  }
  
  return appCache
}
2.4.1.5、自定义缓存路径:app.setPath()

如果需要完全自定义 目录位置,可以使用 app.setPath()

typescript 复制代码
import { app } from 'electron'
import path from 'path'

// ⚠️ 必须在 app ready 事件之前调用
app.setPath('userData', path.join(process.cwd(), 'custom-data'))
app.setPath('cache', path.join(process.cwd(), 'custom-cache'))

app.whenReady().then(() => {
  console.log(app.getPath('userData')) // /project/custom-data
})

注意事项:

注意点 说明
调用时机 必须在 app 模块的 ready 事件之前调用
影响范围 修改 userData 会影响 cookies、缓存等所有 Chromium 存储位置
不建议 将数据存储在应用安装目录内,因为应用更新会覆盖该目录
2.4.1.6、缓存目录的清理
2.4.1.6.1、清理自定义缓存目录
typescript 复制代码
// src/main/utils/cache.ts
import fs from 'fs'
import path from 'path'

function deleteDirectoryRecursive(dirPath: string) {
  if (!fs.existsSync(dirPath)) return
  
  fs.readdirSync(dirPath).forEach((file) => {
    const curPath = path.join(dirPath, file)
    if (fs.lstatSync(curPath).isDirectory()) {
      deleteDirectoryRecursive(curPath)
    } else {
      fs.unlinkSync(curPath)
    }
  })
  fs.rmdirSync(dirPath)
}

export function clearAppCache() {
  const cacheDir = getAppCacheDir()
  if (fs.existsSync(cacheDir)) {
    deleteDirectoryRecursive(cacheDir)
    fs.mkdirSync(cacheDir, { recursive: true })
  }
}
2.4.1.7、最佳实践
实践 说明
使用 app.getPath() 始终使用 Electron 提供的 API 获取路径,而非硬编码
区分数据类型 配置文件 → userData;临时缓存 → cacheuserData/cache
按需创建目录 使用 fs.mkdirSync(dir, { recursive: true }) 确保目录存在
定期清理 实现 LRU 淘汰策略或定期清理过期缓存
提供清理入口 在设置页面提供"清除缓存"按钮,方便用户手动清理
不要存应用目录 不要在 app.getAppPath() 目录下存储用户数据

2.4.2、Electron存储(electron-store)

2.4.2、Electron 存储(electron-store)

2.4.2.1、electron-store 概述

electron-store 是 Electron 生态中最流行的数据持久化方案,专门为 Electron 应用设计的轻量级键值存储库。

核心特性:

特性 说明
简单易用 类似 localStorage 的 API 风格,上手即用
原子写入 写入时先写临时文件再重命名替换,防止数据损坏
进程共享 主进程和所有渲染进程共享同一份数据,保证一致性
JSON Schema 验证 内置基于 JSON Schema 的数据校验
数据加密 内置 AES-256-CBC 加密,防止普通用户直接编辑配置文件
TypeScript 支持 自带完整类型定义
数据迁移 支持应用版本升级时的数据迁移
嵌套访问 支持点号路径访问嵌套属性

与原生方案对比:

对比项 原生 fs 读写 localStorage electron-store
进程间共享 ❌ 需手动处理 ❌ 仅限单个渲染进程 ✅ 自动共享
原子写入 ❌ 需手动实现 ✅ 浏览器自动 ✅ 内置
数据校验 ❌ 需手动实现 ✅ JSON Schema
默认值支持 ❌ 需手动实现
API 简洁度 繁琐 简单 简单

2.4.2.2、安装与配置

2.4.2.2.1、安装
bash 复制代码
npm install electron-store
2.4.2.2.2、默认存储位置

electron-store 默认将数据存储在 app.getPath('userData') 目录下的 config.json 文件中。

平台 默认路径
Windows %APPDATA%\<app-name>\config.json
macOS ~/Library/Application Support/<app-name>/config.json
Linux ~/.config/<app-name>/config.json

2.4.2.3、基本用法

2.4.2.3.1、创建 Store 实例
typescript 复制代码
// src/main/utils/storage.ts
import Store from 'electron-store'

// 默认配置(使用 config.json)
const store = new Store()

// 自定义文件名
const settingsStore = new Store({ name: 'settings' })
// 数据保存在 settings.json 中

// 多个独立 Store(推荐按功能拆分)
const userStore = new Store({ name: 'user' })
const appStore = new Store({ name: 'app' })
const cacheStore = new Store({ name: 'cache' })
2.4.2.3.2、读写数据
typescript 复制代码
// -------- 写入 --------
// 写入单个值
store.set('username', 'electron-user')

// 写入多个值
store.set({
  username: 'electron-user',
  theme: 'dark',
  notifications: true
})

// -------- 读取 --------
// 读取值(不存在时返回 undefined)
const username = store.get('username')

// 读取并设置默认值(不存在时返回默认值)
const theme = store.get('theme', 'light')

// -------- 删除 --------
store.delete('username')

// -------- 清空所有数据 --------
store.clear()

// -------- 检查是否存在 --------
const hasKey = store.has('username')
2.4.2.3.3、嵌套数据操作

electron-store 支持使用点号路径访问嵌套属性:

typescript 复制代码
// 设置嵌套值
store.set('user.name', 'Alice')
store.set('user.preferences.theme', 'dark')
store.set('user.preferences.language', 'zh-CN')

// 读取嵌套值
const userName = store.get('user.name')
const theme = store.get('user.preferences.theme')

// 读取整个嵌套对象
const user = store.get('user')
// { name: 'Alice', preferences: { theme: 'dark', language: 'zh-CN' } }

// 一次性设置整个对象
store.set('user', {
  name: 'Alice',
  preferences: {
    theme: 'dark',
    language: 'zh-CN'
  }
})

2.4.2.4、默认值与 Schema 验证

2.4.2.4.1、设置默认值
typescript 复制代码
const store = new Store({
  defaults: {
    theme: 'light',
    language: 'zh-CN',
    notifications: true,
    windowBounds: { width: 1200, height: 800 }
  }
})

// 读取时如果 key 不存在,自动返回默认值
const theme = store.get('theme') // 'light'
2.4.2.4.2、JSON Schema 数据校验

通过 schema 选项定义数据结构和校验规则:

typescript 复制代码
import Store from 'electron-store'

const store = new Store({
  schema: {
    theme: {
      type: 'string',
      enum: ['light', 'dark', 'system'],
      default: 'light'
    },
    language: {
      type: 'string',
      enum: ['zh-CN', 'en-US', 'ja-JP'],
      default: 'zh-CN'
    },
    notifications: {
      type: 'boolean',
      default: true
    },
    windowBounds: {
      type: 'object',
      properties: {
        width: { type: 'number', minimum: 200 },
        height: { type: 'number', minimum: 200 }
      },
      default: { width: 1200, height: 800 }
    },
    recentFiles: {
      type: 'array',
      items: { type: 'string' },
      default: [],
      maxItems: 20
    }
  }
})

校验优势: 当写入的数据不符合 Schema 定义时,electron-store 会自动抛出错误,防止脏数据写入。

2.4.2.5、数据迁移(migrations)

当应用升级导致数据结构变化时,需要通过迁移将旧数据转换为新格式。

2.4.2.5.1、基本迁移配置
typescript 复制代码
const store = new Store({
  migrations: {
    // 应用版本升级到 1.0.0 时执行
    '1.0.0': (store) => {
      // 添加新字段
      store.set('newField', 'default value')
    },
    // 应用版本升级到 2.0.0 时执行
    '2.0.0': (store) => {
      // 删除已废弃字段
      store.delete('deprecatedField')
      // 转换数据格式
      const oldValue = store.get('oldFormat')
      store.set('newFormat', transformData(oldValue))
      store.delete('oldFormat')
    }
  }
})
2.4.2.5.2、迁移前钩子(beforeEachMigration)
typescript 复制代码
const store = new Store({
  beforeEachMigration: (store, context) => {
    console.log(`迁移: ${context.fromVersion} → ${context.toVersion}`)
    // 备份当前数据
    const backup = { ...store.store }
    store.set(`_backup_${context.fromVersion}`, backup)
  },
  migrations: {
    '1.0.0': (store) => {
      // 迁移逻辑...
    },
    '2.0.0': (store) => {
      // 迁移逻辑...
    }
  }
})

⚠️ 注意: 迁移功能目前存在一些已知问题,官方维护者不积极维护,但接受 PR。使用时建议充分测试。

2.4.2.6、数据加密

electron-store 内置 AES-256-CBC 加密支持,用于防止普通用户直接编辑配置文件

2.4.2.6.1、启用加密
typescript 复制代码
const store = new Store({
  encryptionKey: 'my-secret-encryption-key-32-chars'
  // 注意:密钥长度应为 32 字符(256 位)
})
2.4.2.6.2、加密的定位与限制
定位 说明
作用 防止普通用户手动编辑配置文件,增加篡改难度
局限 密钥存储在应用代码中,可被逆向工程提取,不能用于存储真正敏感的信息(如密码、API Key)

安全建议: 对于真正的敏感数据(密码、Token 等),应使用 Electron 的 safeStorage API,利用操作系统原生加密(macOS 钥匙串、Windows DPAPI)。

2.4.2.7、在渲染进程中使用

electron-store 不能直接在渲染进程中使用,需要通过 IPC 与主进程通信。

2.4.2.7.1、方式一:主进程中初始化(推荐)
typescript 复制代码
// src/main/index.ts
import Store from 'electron-store'

// 在应用启动时初始化 Store
app.whenReady().then(() => {
  const store = new Store()
  // Store 会自动在 userData 目录创建配置文件
})

// 同时暴露 IPC 处理器供渲染进程调用
ipcMain.handle('store-get', (event, key: string) => {
  return store.get(key)
})

ipcMain.handle('store-set', (event, key: string, value: any) => {
  store.set(key, value)
})
2.4.2.7.2、方式二:预加载脚本暴露 API
typescript 复制代码
// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron'

contextBridge.exposeInMainWorld('electronAPI', {
  store: {
    get: (key: string) => ipcRenderer.invoke('store-get', key),
    set: (key: string, value: any) => ipcRenderer.invoke('store-set', key, value),
    delete: (key: string) => ipcRenderer.invoke('store-delete', key)
  }
})
2.4.2.7.3、渲染进程调用
vue 复制代码
<script setup lang="ts">
const loadSettings = async () => {
  const theme = await window.electronAPI.store.get('theme')
  const user = await window.electronAPI.store.get('user')
}

const saveSettings = async () => {
  await window.electronAPI.store.set('theme', 'dark')
  await window.electronAPI.store.set('user', { name: 'Alice' })
}
</script>

2.4.2.8、实用封装示例

2.4.2.8.1、按功能拆分多个 Store
typescript 复制代码
// src/main/utils/storage.ts
import Store from 'electron-store'

// 用户配置
export const userStore = new Store({
  name: 'user',
  defaults: {
    profile: { name: '', avatar: '' },
    preferences: { theme: 'light', language: 'zh-CN' }
  }
})

// 应用状态
export const appStore = new Store({
  name: 'app',
  defaults: {
    windowBounds: { width: 1200, height: 800 },
    lastOpened: null as string | null
  }
})

// 缓存数据
export const cacheStore = new Store({
  name: 'cache',
  defaults: {
    recentFiles: [] as string[],
    searchHistory: [] as string[]
  }
})

2.4.2.9、最佳实践

实践 说明
区分数据类型 用户配置、应用状态、缓存数据使用不同的 Store 实例
定义 Schema 使用 JSON Schema 进行数据校验,防止脏数据写入
设置默认值 通过 defaults 提供合理的默认值,避免空值判断
类型安全 配合 TypeScript 定义数据类型接口
数据迁移 应用升级时通过 migrations 处理数据结构变化
不要存敏感数据 加密仅用于防篡改,敏感数据应使用 safeStorage
主进程初始化 确保在主进程中创建 Store 实例,渲染进程通过 IPC 访问

2.4.2.10、本节速查

问题 答案
数据默认存储在哪里? app.getPath('userData')/config.json
如何自定义文件名? new Store({ name: 'custom' })
如何读写数据? store.set(key, value) / store.get(key)
如何访问嵌套属性? 使用点号路径:store.get('user.name')
如何设置默认值? new Store({ defaults: { key: value } })
如何进行数据校验? new Store({ schema: { ... } }) 使用 JSON Schema
如何加密存储? new Store({ encryptionKey: 'xxx' })
渲染进程能直接用吗? ❌ 不能,需通过 IPC 调用主进程
如何数据迁移? new Store({ migrations: { '1.0.0': (store) => {} } })
敏感数据如何存储? 使用 Electron safeStorage API,而非 electron-store 加密

2.4.2.11、与 2.4.1 节的关系

对比项 2.4.1 缓存目录 2.4.2 electron-store
数据类型 临时数据(可重建) 持久化数据(不可丢失)
存储位置 cacheuserData/cache userData/config.json
典型数据 图片缓存、接口响应 用户配置、应用状态
清理方式 可定期清理 通常不清理
推荐工具 文件系统操作 electron-store

总结: 缓存目录存的是丢了也不怕 的临时数据;electron-store 存的是丢了用户会骂的配置和状态数据。

2.4.3、数据库管理

2.4.3、数据库管理

2.4.3.1、数据库选型

Electron 应用常用本地数据库方案:

方案 适用场景 特点
better-sqlite3 复杂数据、关系型存储 同步 API、性能优秀、使用最广泛
electron-store 轻量配置、键值对 基于 JSON,适合简单配置
sqlite3 需要异步 API 异步 API,适合与 Promise 配合

推荐: 大多数场景使用 better-sqlite3,配合自研轻量 ORM 或直接编写 SQL。

2.4.3.2、架构设计

2.4.3.2.1、两种架构模式对比
模式 说明 适用场景
主进程管理 数据库连接在主进程,渲染进程通过 IPC 调用 推荐,数据集中管理,避免多进程冲突
渲染进程直接管理 每个渲染进程独立连接数据库 不推荐,多进程可能导致锁冲突和数据不一致
2.4.3.2.2、推荐架构(主进程管理)
复制代码
┌─────────────────────────────────────────────────────────┐
│                      渲染进程                          │
│  ┌─────────────────────────────────────────────────┐  │
│  │  Vue 组件 → Mapper 层 → DatabaseManager 代理   │  │
│  └────────────────────┬────────────────────────────┘  │
│                       │ ipcRenderer.invoke()          │
└───────────────────────┼──────────────────────────────┘
                        │ IPC
┌───────────────────────▼──────────────────────────────┐
│                      主进程                          │
│  ┌─────────────────────────────────────────────────┐  │
│  │  DatabaseManager(单例)                       │  │
│  │  • better-sqlite3 连接管理                    │  │
│  │  • 用户隔离(按 userId 分目录)              │  │
│  │  • 多实例管理(多个数据库文件)              │  │
│  └─────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────┘

2.4.3.3、核心实现(参考)

2.4.3.3.1、主进程 DatabaseManager
typescript 复制代码
// src/main/database/DatabaseManager.ts
import Database from 'better-sqlite3'
import path from 'path'
import { app } from 'electron'

export class DatabaseManager {
  private static instances = new Map<string, DatabaseManager>()
  private static userId: string = ''
  private db: Database.Database | null = null

  static async setUserId(userId: string): Promise<void> {
    this.userId = userId
    // 切换用户时关闭所有连接
    for (const instance of this.instances.values()) {
      instance.close()
    }
    this.instances.clear()
  }

  static getInstance(): DatabaseManager {
    const key = 'default'
    if (!this.instances.has(key)) {
      this.instances.set(key, new DatabaseManager())
    }
    return this.instances.get(key)!
  }

  private getConnection(): Database.Database {
    if (this.db) return this.db

    // 用户隔离:按 userId 分目录
    const userDir = path.join(app.getPath('userData'), 'users', DatabaseManager.userId)
    const dbPath = path.join(userDir, 'app.db')

    this.db = new Database(dbPath)
    this.applyPragmas(this.db)
    return this.db
  }

  private applyPragmas(db: Database.Database): void {
    db.exec('PRAGMA journal_mode = WAL;')
    db.exec('PRAGMA synchronous = NORMAL;')
    db.exec('PRAGMA foreign_keys = ON;')
    db.exec('PRAGMA busy_timeout = 5000;')
  }

  // ========== 公开 API ==========
  query<T = any>(sql: string, params: unknown[] = []): T[] {
    const db = this.getConnection()
    return db.prepare(sql).all(...params) as T[]
  }

  execute(sql: string, params: unknown[] = []): { rowsAffected: number; lastInsertRowid?: number } {
    const db = this.getConnection()
    const info = db.prepare(sql).run(...params)
    return { rowsAffected: info.changes, lastInsertRowid: info.lastInsertRowid }
  }

  close(): void {
    if (this.db) {
      this.db.close()
      this.db = null
    }
  }
}
2.4.3.3.2、IPC 注册
typescript 复制代码
// src/main/database/index.ts
import { ipcMain } from 'electron'
import { DatabaseManager } from './DatabaseManager'

export function registerDatabaseHandlers() {
  ipcMain.handle('db:query', async (_, sql: string, params: any[]) => {
    return DatabaseManager.getInstance().query(sql, params)
  })

  ipcMain.handle('db:execute', async (_, sql: string, params: any[]) => {
    return DatabaseManager.getInstance().execute(sql, params)
  })

  ipcMain.handle('db:set-user', async (_, userId: string) => {
    await DatabaseManager.setUserId(userId)
  })
}
2.4.3.3.3、渲染进程代理
typescript 复制代码
// src/renderer/utils/database.ts
import { ipcRenderer } from 'electron'

export const db = {
  query<T = any>(sql: string, params: any[] = []): Promise<T[]> {
    return ipcRenderer.invoke('db:query', sql, params)
  },

  execute(sql: string, params: any[] = []): Promise<{ rowsAffected: number }> {
    return ipcRenderer.invoke('db:execute', sql, params)
  },

  setUserId(userId: string): Promise<void> {
    return ipcRenderer.invoke('db:set-user', userId)
  }
}

2.4.3.4、关键设计点

设计点 说明
用户数据隔离 数据存储在 userData/users/{userId}/app.db,不同用户数据物理隔离
WAL 模式 PRAGMA journal_mode = WAL 提升读写并发性能
单例模式 每个数据库文件一个实例,避免重复打开连接
懒加载 首次调用时才建立连接,减少启动时间
连接关闭 切换用户时关闭所有旧连接,释放资源

2.4.3.5、高级特性(参考)

2.4.3.5.1、FTS5 全文搜索

SQLite FTS5 支持高效全文检索:

sql 复制代码
-- 创建虚拟表
CREATE VIRTUAL TABLE messages_fts USING fts5(content, sender)

-- 搜索(支持 AND/OR/前缀匹配)
SELECT * FROM messages_fts WHERE content MATCH 'electron* AND database*'
2.4.3.5.2、装饰器 ORM(可选)

参考实现使用装饰器定义实体映射,适合大型项目:

typescript 复制代码
@Entity('chats')
export class Chats extends BaseEntity {
  @PrimaryKey(false)
  @Column('chatId', 'TEXT')
  chatId!: string

  @Column('name', 'TEXT')
  name!: string

  @Column('unread', 'INTEGER')
  unread!: number
}
2.4.3.5.3、SQL XML 管理(可选)

将 SQL 语句集中到 XML 文件管理,便于维护:

xml 复制代码
<SQLStore className="Chats">
  <SQL name="findLastChat">
    SELECT * FROM chats ORDER BY sequence DESC LIMIT 1
  </SQL>
</SQLStore>

2.4.3.6、最佳实践

实践 说明
主进程管理连接 避免多进程锁冲突,集中管理更安全
用户数据隔离 多用户场景按 userId 分目录存储
参数化查询 使用 ? 占位符,防止 SQL 注入
WAL 模式 启用 journal_mode = WAL 提升性能
事务支持 批量操作使用 BEGIN/COMMIT 保证原子性
连接懒加载 启动时不创建连接,首次使用时再建立

2.4.3.7、本节速查

问题 答案
推荐使用哪个 SQLite 库? better-sqlite3
连接应在主进程还是渲染进程? 主进程(推荐),渲染进程通过 IPC 调用
用户数据如何隔离? userData/users/{userId}/app.db
如何防止 SQL 注入? 使用参数化查询(? 占位符)
WAL 模式有何作用? 提升读写并发性能
FTS5 是什么? SQLite 内置全文搜索引擎
切换用户时如何处理连接? 关闭所有旧连接,清空实例缓存
数据库优化关键配置? WAL、synchronous=NORMAL、mmap_size、cache_size

2.5、其他知识点

2.5、其他知识点

2.5.1、判断主进程环境、系统

2.5.1.1、系统平台判断

通过 process.platform 判断当前操作系统:

typescript 复制代码
// src/main/utils/platform.ts
export const isWindows = process.platform === 'win32'
export const isMac = process.platform === 'darwin'
export const isLinux = process.platform === 'linux'
2.5.1.2、运行环境判断

通过 process.env.NODE_ENV 区分开发/生产环境:

typescript 复制代码
export const isDevelopment = process.env.NODE_ENV === 'development'
2.5.1.3、组合判断(业务场景)
typescript 复制代码
// 托盘功能:Windows 和 Linux 默认启用,开发环境也启用便于调试
export const isCreateTray = isWindows || isLinux || isDevelopment

// MPRIS 媒体控制:仅 Linux 需要
export const isCreateMpris = isLinux
2.5.1.4、平台差异速查
判断 说明
process.platform === 'win32' Windows 系统
process.platform === 'darwin' macOS 系统
process.platform === 'linux' Linux 系统
process.env.NODE_ENV === 'development' 开发环境

2.5.2、开发环境开启控制台

2.5.2.1、窗口创建后开启 DevTools
typescript 复制代码
// src/main/windows/main.ts
if (isDevelopment) {
  win.webContents.openDevTools()
}
2.5.2.2、无条件开启(调试特殊问题时使用)
typescript 复制代码
// 强制开启,不受环境限制
win.webContents.openDevTools()
2.5.2.3、通过环境变量控制
typescript 复制代码
// 通过环境变量控制是否开启
const isDevToolsEnabled = process.env.ENABLE_DEVTOOLS === 'true' || isDevelopment
if (isDevToolsEnabled) {
  win.webContents.openDevTools()
}

2.5.3、开发环境引入 Vue 开发工具

2.5.3.1、方式一:加载本地已安装的 Chrome 扩展
typescript 复制代码
import { session } from 'electron'
import path from 'path'

// 加载本地 Chrome 扩展目录中的 Vue Devtools
const vueDevtoolsPath = path.resolve(
  __dirname,
  'C:/Users/xxx/AppData/Local/Google/Chrome/User Data/Default/Extensions/nhdogjmejiglipccpnnnanhbledajbpd/7.7.7_0'
)

try {
  await session.defaultSession.loadExtension(vueDevtoolsPath)
  console.log('Vue Devtools 加载成功')
} catch (e) {
  console.error('Vue Devtools 加载失败:', e)
}
2.5.3.2、方式二:使用 electron-devtools-installer(推荐)
typescript 复制代码
import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'

if (isDevelopment) {
  try {
    await installExtension(VUEJS_DEVTOOLS)
    console.log('Vue Devtools 安装成功')
  } catch (e) {
    console.error('Vue Devtools 安装失败:', e)
  }
}
2.5.3.3、注意事项
注意点 说明
仅开发环境 生产环境不应加载 Devtools
路径正确性 本地加载方式需确保路径指向扩展目录
版本兼容 确保 Devtools 版本与 Vue 版本兼容
加载时机 app.whenReady() 之后加载

2.5.4、设置窗口名称

2.5.4.1、主进程创建窗口时传递标识
typescript 复制代码
// src/main/windows/windowManager.ts
const window = new BrowserWindow({
  webPreferences: {
    preload: path.join(__dirname, '../preload/index.js'),
    additionalArguments: [`--window-label=${label}`]  // 通过参数传递窗口标识
  }
})
2.5.4.2、预加载脚本中读取并设置
typescript 复制代码
// src/preload/index.ts
// 从进程参数中读取窗口标签
const labelArg = process.argv.find(arg => arg.startsWith('--window-label='))
if (labelArg) {
  window.name = labelArg.split('=')[1]
  console.log('设置窗口名称成功', window.name)
}
2.5.4.3、使用场景
场景 说明
多窗口区分 主窗口、设置窗口、登录窗口等通过名称区分
窗口管理器 通过 label 管理不同窗口的创建/关闭/切换
事件路由 IPC 消息根据窗口名称路由到对应的渲染进程
日志追踪 日志中记录窗口名称,便于排查问题

2.5.5、本节速查

问题 答案
如何判断 Windows 系统? process.platform === 'win32'
如何判断开发环境? process.env.NODE_ENV === 'development'
如何开启 DevTools? win.webContents.openDevTools()
如何加载 Vue Devtools? electron-devtools-installer 或加载本地扩展目录
如何给窗口命名? 主进程通过 additionalArguments 传递,预加载读取并设置 window.name
窗口名称有什么用途? 多窗口管理、事件路由、日志追踪
生产环境需要加载 Devtools 吗? ❌ 不需要
Linux 下 MPRIS 是什么? 媒体播放器远程控制协议,用于桌面环境集成

3、主进程生命周期

3.1、主进程启动流程分析

3.1.1、启动流程概述

Electron 主进程的启动遵循一套固定的生命周期事件序列。理解这个流程对于应用初始化、窗口创建、资源清理至关重要。

核心启动序列:

复制代码
app 启动 → beforeReady(环境准备)→ 单例锁检查 → app.whenReady() → onReady(初始化)→ 应用运行中 → 窗口关闭 → 应用退出

3.1.2、完整启动流程(参考实现)

以下是一个完整的 Electron 主进程启动流程封装:

typescript 复制代码
// src/main/index.ts
import { app, Menu, session } from 'electron'
import * as dotenv from 'dotenv'
import { autoUpdater } from 'electron-updater'
import fs from 'fs'
import path from 'path'

class LifeCycle {
  // ==================== 阶段一:准备阶段 ====================
  private async beforeReady() {
    // 加载环境变量
    const envPath = isDevelopment
      ? path.resolve(process.cwd(), '.env.development')
      : path.join(process.resourcesPath, '.env')
    if (fs.existsSync(envPath)) {
      dotenv.config({ path: envPath })
    }
  }

  // ==================== 阶段二:就绪阶段 ====================
  private onReady() {
    const readyFunction = async () => {
      // 1. 配置自动更新
      autoUpdater.forceDevUpdateConfig = true

      // 2. 隐藏默认菜单(按 Alt 不显示菜单栏)
      Menu.setApplicationMenu(null)

      // 3. 注册 IPC 处理器
      ipcList.listen()

      // 4. 初始化应用目录
      initDataDir()
      initLogDir()
      initCacheDir()

      // 5. 加载 Vue Devtools(仅开发环境)
      if (isDevelopment) {
        const vueDevtoolsPath = path.resolve(
          __dirname,
          'C:/Users/xxx/AppData/Local/Google/Chrome/User Data/Default/Extensions/xxx'
        )
        try {
          await session.defaultSession.loadExtension(vueDevtoolsPath)
          console.log('Vue Devtools 加载成功')
        } catch (e) {
          console.error('Vue Devtools 加载失败:', e)
        }
      }

      // 6. 创建主窗口
      const existingWindow = windowManager.getWindow(StoresEnum.MAIN)
      if (!existingWindow) {
        CreateMainWindow('login')
      }
    }
    app.whenReady().then(readyFunction)
  }

  // ==================== 阶段三:运行阶段 ====================
  private onRunning() {
    // 单实例锁:第二次启动时激活已有窗口
    app.on('second-instance', () => {
      ShowMainWindow()
    })
  }

  // ==================== 阶段四:退出阶段 ====================
  private onQuit() {
    // 所有窗口关闭(macOS 特殊处理)
    app.on('window-all-closed', () => {
      if (!isMac) {
        app.quit()
      }
    })

    // 应用即将退出:清理资源
    app.on('will-quit', () => {
      cleanupShortcuts()
      cleanupMousePollers()
    })
  }

  // ==================== 启动入口 ====================
  async launchApp() {
    // 单例锁:确保只有一个应用实例
    const gotTheLock = app.requestSingleInstanceLock()
    if (!gotTheLock) {
      app.quit()
    } else {
      this.beforeReady()
      this.onReady()
      this.onRunning()
      this.onQuit()
    }
  }
}

const bootstrap = new LifeCycle()
bootstrap.launchApp()

3.1.3、启动阶段详解

3.1.3.1、阶段一:beforeReady(环境准备)

app.ready 事件之前执行,用于加载环境变量等前置配置。

操作 说明
加载 .env 开发环境加载 .env.development,生产环境加载 resources/.env
路径差异 process.cwd()(开发)vs process.resourcesPath(生产)
3.1.3.2、阶段二:onReady(核心初始化)

app.whenReady() 回调中执行,此时 Electron 核心已就绪。

操作 说明
配置自动更新 autoUpdater.forceDevUpdateConfig = true 允许开发环境测试更新
隐藏默认菜单 Menu.setApplicationMenu(null) 避免按 Alt 弹出菜单
注册 IPC 处理器 监听渲染进程发来的 IPC 消息
初始化应用目录 创建数据目录、日志目录、缓存目录
加载 Devtools 开发环境加载 Vue Devtools 扩展
创建主窗口 如果主窗口不存在则创建
3.1.3.3、阶段三:onRunning(运行时事件)

监听应用运行过程中的系统事件。

事件 触发时机 处理逻辑
second-instance 用户尝试启动第二个实例 激活已有窗口(显示并聚焦)
3.1.3.4、阶段四:onQuit(退出清理)

监听退出事件,释放资源。

事件 触发时机 处理逻辑
window-all-closed 所有窗口已关闭 非 macOS 平台调用 app.quit()
will-quit 应用即将退出 清理快捷键、鼠标轮询器等资源

各平台退出行为差异:

平台 关闭所有窗口后 推荐做法
Windows / Linux 应用退出 window-all-closed 中调用 app.quit()
macOS 应用保持运行(Dock 图标存活) 不退出,等待 activate 事件重建窗口

3.1.4、单例锁机制

app.requestSingleInstanceLock() 确保应用只有一个实例运行:

typescript 复制代码
const gotTheLock = app.requestSingleInstanceLock()
if (!gotTheLock) {
  app.quit()  // 已有实例,当前进程退出
} else {
  // 首次启动,正常初始化
  this.beforeReady()
  this.onReady()
  // ...
}

作用:

  • 防止用户同时打开多个应用实例
  • 二次启动时通过 second-instance 事件激活已有窗口

3.1.5、开发调试技巧

3.1.5.1、开发环境快速调试
typescript 复制代码
// 开发环境自动开启 DevTools
if (isDevelopment) {
  win.webContents.openDevTools()
}

// 强制开启(临时调试用)
// if (true) {
//   win.webContents.openDevTools()
// }
3.1.5.2、查看启动日志
typescript 复制代码
// 在关键节点添加日志
console.log('main', '应用启动 - beforeReady')
console.log('main', '应用启动 - 窗口创建完成')

3.1.6、生命周期事件速查

事件 触发时机 能否阻止退出
ready Electron 核心初始化完成
window-all-closed 所有窗口已关闭 ❌(可决定是否 quit)
before-quit 应用即将退出(窗口关闭前)
will-quit 所有窗口已关闭,即将退出
quit 应用已退出
second-instance 用户尝试启动第二个实例

3.1.7、本节速查

问题 答案
主进程启动顺序? beforeReadyapp.whenReady()onReady → 运行中 → 退出清理
如何保证单例? app.requestSingleInstanceLock()
二次启动时如何激活窗口? 监听 second-instance 事件,调用 win.show()win.focus()
环境变量何时加载? beforeReady 阶段,在 app.ready 之前
开发/生产环境 .env 路径差异? 开发:process.cwd();生产:process.resourcesPath
如何隐藏默认菜单? Menu.setApplicationMenu(null)
macOS 关闭所有窗口后应用会退出吗? ❌ 不会,需在 window-all-closed 中判断 !isMac 再 quit
退出时如何清理资源? will-quit 事件中执行清理逻辑

4、工程化与交付

4.1、运行配置

4.1.1、electron-vite 配置

electron-vite 通过单一配置文件管理三个独立构建目标:

typescript 复制代码
// electron.vite.config.ts
import { defineConfig } from 'electron-vite'

export default defineConfig({
  main: { /* 主进程配置 */ },
  preload: { /* 预加载脚本配置 */ },
  renderer: { /* 渲染进程配置 */ }
})
4.1.1.1、主进程配置
typescript 复制代码
main: {
  resolve: { alias },
  build: {
    externalizeDeps: true  // 自动外部化 node_modules 依赖
  }
}
配置项 说明
resolve.alias 路径别名,与 TypeScript 的 paths 保持一致
externalizeDeps 自动将 node_modules 中的依赖标记为外部,不打包进输出
4.1.1.2、预加载配置
typescript 复制代码
preload: {
  resolve: { alias },
  build: {
    externalizeDeps: true
  }
}

预加载脚本配置与主进程基本一致,共同使用同一套路径别名。

4.1.1.3、渲染进程配置

渲染进程是 Vue 应用的完整 Vite 配置,包含:

配置类别 说明
插件系统 Vue 插件、DevTools、自动导入、SVG 图标、打包分析
路径解析 别名 @src/renderer/src~src
构建优化 Terser 压缩、代码分割、去 console/debugger
CSS 预处理 SCSS 支持(modern-compiler API)
typescript 复制代码
renderer: {
  root: 'src/renderer',
  plugins: [
    vue(),
    vueDevTools(),
    AutoImport({ /* 自动导入 Vue/Element Plus API */ }),
    Components({ /* 自动注册组件 */ }),
    visualizer({ /* 打包分析 */ }),
    createSvgIconsPlugin({ /* SVG 图标 */ })
  ],
  resolve: {
    alias: { '@': 'src/renderer/src', '~': 'src' }
  },
  build: {
    minify: 'terser',
    terserOptions: {
      compress: { drop_console: false, drop_debugger: true }
    }
  }
}

4.1.2、TypeScript 配置

采用项目引用(Project References)方式,分离主进程和渲染进程的类型检查:

json 复制代码
// tsconfig.json - 根配置(入口)
{
  "files": [],
  "references": [
    { "path": "./tsconfig.node.json" },  // 主进程 + 预加载
    { "path": "./tsconfig.web.json" }    // 渲染进程
  ]
}
4.1.2.1、主进程配置(tsconfig.node.json)
配置项 说明
extends 继承 @electron-toolkit/tsconfig/tsconfig.node.json
target: ES2020 编译目标
experimentalDecorators ✅ 启用装饰器(ORM 需要)
emitDecoratorMetadata ✅ 启用装饰器元数据
composite: true 开启项目引用
paths 路径别名,与 Vite 保持一致
4.1.2.2、渲染进程配置(tsconfig.web.json)
配置项 说明
extends 继承 @electron-toolkit/tsconfig/tsconfig.web.json
lib: ["DOM", "ESNext"] 浏览器环境类型
experimentalDecorators ✅ 启用装饰器
emitDecoratorMetadata ✅ 启用装饰器元数据
paths 路径别名与主进程保持一致

4.1.3、路径别名统一管理

为确保 Vite 和 TypeScript 的路径解析一致,两者需保持同步:

别名 实际路径 说明
@/* src/renderer/src/* 渲染进程源码根目录
~/* src/* 项目通用源码目录
apis/* src/main/apis/* 主进程 API 目录
@core/* src/main/apis/core/* 主进程核心模块

同步维护原则: 修改 vite.config.ts 中的 alias 时,必须同步更新 tsconfig.node.jsontsconfig.web.json 中的 paths

4.1.4、NPM 脚本

json 复制代码
{
  "scripts": {
    "dev": "electron-vite dev",           // 开发模式
    "build": "electron-vite build",       // 构建
    "start": "electron-vite preview",     // 预览构建产物
    "build:win": "electron-builder --win",   // 打包 Windows
    "build:mac": "electron-builder --mac",   // 打包 macOS
    "build:linux": "electron-builder --linux", // 打包 Linux
    "lint": "eslint --fix .",             // 代码检查
    "format": "prettier --write .",        // 代码格式化
    "typecheck": "tsc --noEmit"           // 类型检查
  }
}
脚本 用途
dev 启动开发服务器,支持 HMR
build 构建所有进程的生产代码到 out/
start 预览构建后的应用
build:win/mac/linux 打包成对应平台的可安装程序

4.1.5、VSCode 开发配置

json 复制代码
// .vscode/settings.json
{
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit",
    "source.organizeImports": "explicit"
  },
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[vue]": { "editor.defaultFormatter": "Vue.volar" },
  "[typescript]": { "editor.defaultFormatter": "vscode.typescript-language-features" }
}
配置 效果
formatOnSave 保存时自动格式化
source.fixAll.eslint 保存时自动修复 ESLint 问题
source.organizeImports 保存时自动整理 import 语句
[vue] Vue 文件使用 Volar 格式化

4.1.6、本节速查

问题 答案
electron-vite 管理几个构建目标? 三个:main、preload、renderer
路径别名在哪里配置? electron.vite.config.tsresolve.alias
TypeScript 路径别名在哪里配置? tsconfig.node.jsontsconfig.web.jsonpaths
装饰器支持需要开启什么? experimentalDecoratorsemitDecoratorMetadata
开发命令是什么? npm run dev
打包命令是什么? npm run build:win / build:mac / build:linux
VSCode 保存时自动格式化怎么配置? editor.formatOnSave: true
保存时自动修复 ESLint? source.fixAll.eslint: "explicit"

代码参考说明

参考价值分析:

以上配置提取自你的项目,核心设计值得参考:

  1. 三进程独立构建 :通过 electron-vitemain/preload/renderer 三部分分离配置,各进程互不干扰
  2. 项目引用(Project References) :通过根 tsconfig.json 引用两个子配置,实现主进程和渲染进程的类型隔离
  3. 别名统一管理:Vite 和 TypeScript 使用同一套路径别名,避免解析不一致
  4. 自动导入 :通过 unplugin-auto-importunplugin-vue-components 自动导入 Vue/Element Plus API 和组件,减少手动 import

4.2、打包方式

4.2.1、electron-builder 配置

electron-builder 是 Electron 官方推荐的打包工具,通过 electron-builder.yml 声明打包配置。

4.2.1.1、通用配置
yaml 复制代码
# 应用唯一标识(反向域名格式,用于系统识别)
appId: com.eastmoonlight.luckyim

# 应用显示名称(安装包、快捷方式、菜单栏显示的名称)
productName: 幸运IM

# 构建资源目录(存放图标、证书、plist 等构建资源)
directories:
  buildResources: build

# -------- 文件打包配置 --------
# 需要打包进应用的文件(相对于项目根目录)
files:
  - out/**/*              # 编译输出目录(electron-vite 构建产物)
  - resources/**/*        # 资源文件目录(图标、音频、字体等)
  - package.json          # 依赖声明文件(用于安装依赖)

# 不打包到 asar 的文件(以原始文件形式存放在 asar 外)
asarUnpack:
  - resources/**          # 资源文件保持原始格式,便于运行时访问

# -------- 额外资源(配置文件注入) --------
# 将指定文件复制到打包后的应用目录中
extraResources:
  - from: .env.production   # 源文件(项目根目录)
    to: .env                # 目标文件名(打包后为 resources/.env)
    filter:
      - '**/*'              # 过滤规则:包含所有文件

# -------- 原生模块配置 --------
# 是否重新编译原生模块(某些模块需在目标平台重新编译)
npmRebuild: false

# -------- Electron 下载镜像 --------
# 加速 Electron 下载(国内开发必备)
electronDownload:
  mirror: https://npmmirror.com/mirrors/electron/

通用配置项详细说明:

配置项 说明 注意事项
appId 应用唯一标识,推荐反向域名格式 不同平台签名/更新依赖此 ID,发布后不要更改
productName 应用显示名称 影响安装包名称、开始菜单快捷方式等
directories.buildResources 构建资源目录 存放 icon.pngentitlements.mac.plist 等文件
files 打包包含的文件 默认包含 out/**/*package.jsonresources/ 需手动添加
asarUnpack 排除在 asar 压缩包外的文件 资源文件、大文件应排除,否则运行时读取可能失败
extraResources 额外注入的资源文件 用于注入 .env、证书、配置文件等
npmRebuild 是否重新编译原生模块 better-sqlite3 等模块有问题,设为 true 重建
electronDownload.mirror Electron 下载镜像 加速首次构建下载,推荐使用国内镜像

4.2.1.2、Windows 打包配置

yaml 复制代码
win:
  icon: build/icon.png                                    # 应用图标
  artifactName: ${name}-${version}-${arch}.exe           # 安装包文件名模板
  target:
    - target: nsis                                        # 使用 NSIS 制作安装包
      arch:
        - x64                                             # 64 位
        # - ia32                                          # 32 位
        # - arm64                                         # ARM 架构

nsis:
  shortcutName: ${productName}                            # 快捷方式名称
  uninstallDisplayName: ${productName}                    # 卸载程序显示名称
  createDesktopShortcut: always                           # 始终创建桌面快捷方式
  oneClick: false                                         # 显示安装向导(非一键安装)
  allowToChangeInstallationDirectory: true                # 允许用户选择安装路径
  buildUniversalInstaller: false                          # 不构建通用安装包

Windows 配置项说明:

配置项 说明 可选值
icon 应用图标路径(.ico.png 建议使用 256x256icon.png
artifactName 安装包文件名模板 ${name}${version}${arch}${platform}
target 安装包格式和架构 nsisportablesquirrel
oneClick 是否一键安装 false 显示安装向导;true 无界面静默安装
allowToChangeInstallationDirectory 是否允许修改安装路径 需配合 oneClick: false 使用
createDesktopShortcut 桌面快捷方式 alwaysneverperMachine

artifactName 变量:

变量 说明 示例
${name} package.json 中的 name lucky-electron
${version} package.json 中的 version 2.0.0
${arch} 架构 x64ia32arm64
${platform} 平台 winmaclinux
${productName} productName 幸运IM

Windows 打包命令:

bash 复制代码
npm run build:win   # 打包为 Windows 安装包(.exe)

4.2.1.3、macOS 打包配置

yaml 复制代码
mac:
  icon: build/icon.png
  entitlementsInherit: build/entitlements.mac.plist      # 权限继承配置文件
  extendInfo:                                            # 系统权限描述
    NSCameraUsageDescription: Application requests access to the device's camera.
    NSMicrophoneUsageDescription: Application requests access to the device's microphone.
    NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
    NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
  notarize: false                                         # 是否进行苹果公证

dmg:
  artifactName: ${name}-${version}.${ext}                 # DMG 文件名

macOS 配置项说明:

配置项 说明
icon 应用图标(.icns.png,推荐 .icns
entitlementsInherit 继承的权限配置文件路径
extendInfo Info.plist 额外配置(权限说明、自定义键值)
notarize 是否向 Apple 提交公证(发布到 App Store 或公开分发需要)
dmg.artifactName DMG 安装包文件名模板

权限描述配置:

权限键 说明
NSCameraUsageDescription 摄像头使用权限说明
NSMicrophoneUsageDescription 麦克风使用权限说明
NSDocumentsFolderUsageDescription 文稿文件夹访问权限说明
NSDownloadsFolderUsageDescription 下载文件夹访问权限说明

macOS 打包命令:

bash 复制代码
npm run build:mac   # 打包为 macOS 应用(.dmg 或 .app)

注意: macOS 打包必须在 macOS 系统上执行,无法在 Windows/Linux 上跨平台打包 macOS 应用。

4.2.1.4、Linux 打包配置

yaml 复制代码
linux:
  target:
    - AppImage                                             # 便携式(无需安装)
    - snap                                                 # Snap 包
    - deb                                                  # Debian 安装包
  maintainer: electronjs.org                               # 维护者信息
  category: Utility                                        # 应用分类(Desktop Entry)

appImage:
  artifactName: ${productName}-${version}.${ext}          # AppImage 文件名

Linux 配置项说明:

配置项 说明
target 打包格式(可同时生成多种)
maintainer 维护者信息(显示在包管理器中)
category 应用分类(GNOME/KDE 菜单分类)

Linux 目标格式对比:

格式 说明 适用场景
AppImage 单文件,无需安装,双击运行 通用推荐,跨发行版兼容
deb Debian/Ubuntu 安装包 适合 Debian 系用户
snap Canonical 的沙箱包格式 Ubuntu 默认支持
rpm RedHat/Fedora 安装包 适合 RedHat 系用户

Linux 打包命令:

bash 复制代码
npm run build:linux   # 打包为 Linux 应用(AppImage + deb + snap)

4.2.1.5、更新发布配置

yaml 复制代码
# 所有更新包放在同一目录,electron-updater 根据平台自动识别
publish:
  - provider: generic
    url: http://127.0.0.1/electron/update     # 更新服务器地址
    publishAutoUpdate: true                    # 生成 latest.yml 等元数据文件

# 更新日志(写入 latest.yml,应用内展示)
releaseInfo:
  releaseNotes: |
    修复了登录失败的问题
    优化了消息列表滚动性能
    更新了图标样式
配置项 说明
provider 更新源类型(genericgithubs3spaces
url 更新服务器基础 URL
publishAutoUpdate 是否自动生成更新元数据文件(latest.yml
releaseNotes 更新日志内容(自动写入元数据文件)

详细更新机制见 4.3、更新方式

4.2.2、打包脚本

json 复制代码
{
  "scripts": {
    "build": "npm run typecheck && electron-vite build",
    "postinstall": "electron-builder install-app-deps",
    "build:unpack": "npm run build && electron-builder --dir",
    "build:win": "npm run build && electron-builder --win --publish never",
    "build:mac": "npm run build && electron-builder --mac --publish never",
    "build:linux": "npm run build && electron-builder --linux --publish never"
  }
}
命令 说明
npm run build 类型检查 → 构建代码到 out/
npm run postinstall 安装后自动重建原生模块
npm run build:unpack 构建并解包(不打包成安装包,用于调试)
npm run build:win 打包 Windows 安装包(--publish never 不上传更新)
npm run build:mac 打包 macOS 应用
npm run build:linux 打包 Linux 应用

4.2.3、跨平台打包注意事项

4.2.3.1、平台打包限制
平台 打包限制 说明
Windows 可在任何平台打包 需安装构建工具链(Windows SDK、Python 等)
macOS 只能在 macOS 上打包 签名和公证需 Apple 开发者证书
Linux 可在任何平台打包 跨平台打包 Linux 格式(AppImage 推荐)
4.2.3.2、原生模块处理
bash 复制代码
# 原生模块自动重建(postinstall 自动执行)
npm run postinstall

# 手动重建(如有问题)
npm rebuild better-sqlite3

# 开发环境手动构建原生模块
npm run rebuild
4.2.3.3、各平台输出产物
平台 输出产物 位置
Windows lucky-electron-2.0.0-x64.exe dist/
macOS lucky-electron-2.0.0.dmg dist/
Linux 幸运IM-2.0.0.AppImage dist/

4.2.4、本节速查

问题 答案
打包工具是什么? electron-builder
打包配置文件在哪? electron-builder.yml
Windows 安装包格式? NSIS(.exe
如何允许用户选择安装路径? oneClick: false + allowToChangeInstallationDirectory: true
macOS 打包能在 Windows 上执行吗? ❌ 不能,必须用 macOS 系统
Linux 推荐打包格式? AppImage(单文件,双击运行)
原生模块如何重建? electron-builder install-app-deps(postinstall 自动执行)
artifactName 是什么? 安装包文件名模板,支持 ${name}${version}${arch}
如何生成更新元数据文件? publish.publishAutoUpdate: true
Windows 打包命令? npm run build:win
macOS 打包命令? npm run build:mac
Linux 打包命令? npm run build:linux

继续推进 4.3、更新方式?🚀

4.3、更新方式

4.3.1、更新检测机制

更新检测机制的核心是如何获取最新版本信息并与当前版本对比。根据实现方式的不同,分为以下两种。

4.3.1.1、获取当前版本

无论使用哪种检测方式,都需要先从 package.json 中读取当前应用版本:

typescript 复制代码
import pkg from 'root/package.json'

const currentVersion = pkg.version  // 如 "2.0.0"

版本比较使用 semver 库:

typescript 复制代码
import { lt } from 'semver'

// 如果 current < latest,返回 true 表示有新版本
const hasUpdate = lt(currentVersion, latestVersion)
4.3.1.2、检测方式一:手动请求接口

由开发者自己调用接口 获取最新版本号,然后与当前版本对比。这种方式不依赖 electron-updater,完全自主控制。

typescript 复制代码
// src/main/updateChecker.ts
import { dialog, shell } from 'electron'
import { lt } from 'semver'
import pkg from 'root/package.json'

const currentVersion = pkg.version
const downloadUrl = 'https://github.com/xxx/releases/latest'

export async function checkForManualUpdate() {
  // 1. 自己调用接口获取最新版本
  const latestVersion = await fetchLatestVersion()
  if (!latestVersion) return

  // 2. 自己对比版本
  const hasUpdate = lt(currentVersion, latestVersion)
  if (!hasUpdate) return

  // 3. 自己弹窗提示
  const result = await dialog.showMessageBox({
    type: 'info',
    title: '发现新版本',
    message: `当前版本 v${currentVersion},最新版本 v${latestVersion}`,
    buttons: ['去下载', '暂不']
  })

  if (result.response === 0) {
    await shell.openExternal(downloadUrl)
  }
}

// 自己实现获取最新版本的接口
async function fetchLatestVersion(): Promise<string | null> {
  try {
    // 方式1:从更新服务器的 latest.yml 解析
    const res = await fetch('http://127.0.0.1/electron/update/latest.yml')
    const text = await res.text()
    const match = text.match(/version:\s*(.+)/)
    return match ? match[1] : null

    // 方式2:从 GitHub API 获取
    // const res = await fetch('https://api.github.com/repos/xxx/releases/latest')
    // const data = await res.json()
    // return data.tag_name.replace(/^v/, '')
  } catch {
    return null
  }
}

特点:

特点 说明
完全自主控制 可自定义接口格式、请求逻辑
灵活性高 可从任何来源获取版本信息
代码量较大 需自己实现请求、解析、比较、弹窗全流程
无增量更新 只能跳转下载页面,无法后台自动下载
4.3.1.3、检测方式二:electron-updater 自动获取

electron-updater自动获取最新版本信息,自动对比,自动触发事件。开发者只需监听事件即可。

typescript 复制代码
// src/main/index.ts
import { autoUpdater } from 'electron-updater'

// 开发环境强制开启更新检测
autoUpdater.forceDevUpdateConfig = true

// autoUpdater 自动获取最新版本,自动对比,自动触发事件
autoUpdater.on('update-available', (info) => {
  // 发现有新版本(info 中已包含最新版本号)
  console.log(`发现新版本 v${info.version}`)
})

autoUpdater.on('update-not-available', () => {
  console.log('当前已是最新版本')  // 自动判断,无需手动比较
})

autoUpdater.on('error', (err) => {
  console.error('检查更新失败:', err)
})

// 执行检测(程序会自动获取最新版本并触发对应事件)
autoUpdater.checkForUpdates()

配置要求: 需在 electron-builder.yml 中配置 publish 字段,指向更新服务器地址:

yaml 复制代码
publish:
  - provider: generic
    url: http://127.0.0.1/electron/update
    publishAutoUpdate: true

特点:

特点 说明
自动化程度高 自动获取、自动对比、自动触发事件
代码量少 只需监听事件,无需实现请求和比较逻辑
支持增量更新 配合 .blockmap 文件实现增量下载
需遵循标准格式 更新服务器需提供 latest.yml 元数据文件
4.3.1.4、两种检测方式对比
对比项 手动请求接口 electron-updater 自动获取
版本获取 自己调用接口 自动读取 latest.yml
版本对比 自己用 semver.lt() 比较 自动比较
事件通知 自己弹窗提示 触发 update-available 事件
代码量 较多 较少
灵活性 高(可自定义接口格式) 低(需遵循 latest.yml 格式)
增量更新 ❌ 不支持 ✅ 支持
适用场景 自定义更新逻辑、非标准更新源 标准 electron-builder + electron-updater 方案

4.3.2、更新触发时机

更新触发时机指的是何时发起检测。根据触发方式的不同,分为以下两种。

4.3.2.1、触发时机一:应用启动时自动检测

应用启动时在后台静默检查更新,不影响用户操作。

typescript 复制代码
// src/main/index.ts
import { autoUpdater } from 'electron-updater'

app.whenReady().then(() => {
  // 应用启动时自动检查更新
  autoUpdater.forceDevUpdateConfig = true
  autoUpdater.checkForUpdatesAndNotify()  // 后台静默检查,有更新时自动弹窗提示
})

特点:

特点 说明
用户无感知 打开应用即检查,不打断用户操作
有更新时弹窗 自动弹窗提示用户有新版可用
保证版本及时更新 用户每次启动都能获取最新版本信息

适用场景:

  • 需要保证用户始终使用最新版本
  • 希望更新过程对用户透明
4.3.2.2、触发时机二:用户手动点击检测

用户主动点击"检查更新"按钮,触发检测流程。需要 UI 配合,交互反馈明确。

主进程 IPC 处理:

typescript 复制代码
// src/main/ipc/update.ts
import { ipcMain } from 'electron'
import { autoUpdater } from 'electron-updater'

ipcMain.handle('check-for-updates', () => {
  return new Promise((resolve) => {
    autoUpdater.once('update-available', (info) => resolve(info))
    autoUpdater.once('update-not-available', () => resolve(null))
    autoUpdater.once('error', () => resolve(null))
    autoUpdater.checkForUpdates()
  })
})

渲染进程调用:

vue 复制代码
<template>
  <button :loading="checking" @click="checkUpdate">
    {{ checking ? '检查中...' : '检查更新' }}
  </button>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const checking = ref(false)

const checkUpdate = async () => {
  checking.value = true
  try {
    const info = await window.electron.ipcRenderer.invoke('check-for-updates')
    if (info) {
      ElMessage.success(`发现新版本 v${info.version}`)
      // 弹出更新对话框
    } else {
      ElMessage.info('当前已是最新版本')
    }
  } catch {
    ElMessage.error('检查更新失败')
  } finally {
    checking.value = false
  }
}
</script>

特点:

特点 说明
用户主动发起 用户掌控检查节奏
交互反馈明确 成功/失败/有无更新都有明确提示
用户体验好 满足用户主动确认版本的诉求

适用场景:

  • 设置页面中的"检查更新"功能
  • 用户想主动确认是否有新版本
4.3.2.3、两种触发时机对比
对比项 应用启动自动检测 用户手动点击检测
触发方式 程序自动 用户主动
用户感知 无感知(静默) 有感知(点击触发)
交互反馈 有更新时才弹窗 始终有明确反馈
API checkForUpdatesAndNotify() checkForUpdates() + 自己处理回调
适用场景 保证版本及时更新 设置页面的检查更新入口

最佳实践: 两种触发时机同时使用------应用启动时自动检查保证版本及时更新,同时设置"检查更新"按钮满足用户主动确认的需求。

4.3.3、手动更新(跳转下载)

手动更新指用户确认有新版本后,跳转到下载页面自行下载安装

实现流程:

复制代码
用户点击"检查更新" → 检测到新版本 → 弹窗提示 → 用户点击"去下载" → shell.openExternal() → 跳转下载页

代码实现:

typescript 复制代码
// src/main/updateChecker.ts
import { dialog, shell } from 'electron'
import { lt } from 'semver'
import pkg from 'root/package.json'

const currentVersion = pkg.version
const downloadUrl = 'https://github.com/xxx/releases/latest'

export async function checkForManualUpdate() {
  const latestVersion = await fetchLatestVersion()
  if (!latestVersion || !lt(currentVersion, latestVersion)) return

  const result = await dialog.showMessageBox({
    type: 'info',
    title: '发现新版本',
    message: `当前版本 v${currentVersion},最新版本 v${latestVersion}`,
    detail: '是否前往下载页面?',
    buttons: ['去下载', '暂不']
  })

  if (result.response === 0) {
    await shell.openExternal(downloadUrl)
  }
}

特点:

特点 说明
实现简单 只需 shell.openExternal 打开下载链接
用户自行操作 用户自己下载、安装新版本
无需搭建更新服务器 只需一个下载页面地址
无增量更新 每次下载完整安装包
更新体验一般 跳出应用,多步骤操作

4.3.4、自动更新(electron-updater)

4.3.4.1、自动更新配置概述

electron-updater 的自动更新需要三层配置配合工作:

配置层 文件 作用
打包配置 electron-builder.yml 定义更新服务器地址、发布策略
开发环境配置 dev-app-update.yml 开发环境下的更新服务器地址
代码配置 main/index.ts autoUpdater 的行为控制(强制开发更新、自动下载等)

4.3.4.2、打包配置(electron-builder.yml)

electron-builder.yml 中通过 publish 字段配置更新发布信息:

yaml 复制代码
# electron-builder.yml

# 发布配置
publish:
  - provider: generic                    # 更新源类型
    url: http://127.0.0.1/electron/update  # 更新服务器地址
    publishAutoUpdate: true              # 是否生成 latest.yml 等元数据文件

# 更新日志(写入 latest.yml)
releaseInfo:
  releaseNotes: |
    修复了登录失败的问题
    优化了消息列表滚动性能
    更新了图标样式

publish 配置项详解:

配置项 类型 说明
provider string 更新源类型:generic(通用HTTP)、githubs3spaces
url string 更新服务器的基础 URL
publishAutoUpdate boolean 是否自动生成 latest.yml 元数据文件(默认 true
channel string 更新通道:latest(正式版)、beta(测试版)等

provider 类型对比:

provider 说明 适用场景
generic 通用 HTTP/HTTPS 服务器 自建更新服务器
github GitHub Releases 开源项目,利用 GitHub Releases 托管更新包
s3 AWS S3 存储桶 使用 AWS S3 托管更新包
spaces DigitalOcean Spaces 使用 DigitalOcean Spaces 托管更新包

releaseInfo 配置项:

配置项 说明
releaseNotes 更新日志内容(支持多行文本)
releaseName 发布名称(如 v2.0.0
releaseDate 发布日期(自动生成)

配置地址说明:

我的地址使用的是nginx路径,nginx配置如下:

java 复制代码
server {
	listen       80;

	location / {
		# 关键:root 指向 dist 目录(绝对路径或相对路径)
		root   html/dist;         # 因为 nginx.exe 所在目录下有一个 html 文件夹,html/dist 就是完整路径
		index  index.html;
		try_files $uri $uri/ /index.html; # 解决 history 模式 404 的核心配置
	}

	location /electron {
		alias html/electron/;   # ✅ 使用 alias,访问路径直接映射到该目录
		try_files $uri $uri/ =404;
	}
}

4.3.4.3、开发环境配置(dev-app-update.yml)

开发环境下,electron-updater 默认不会检查更新 (因为 app.isPackagedfalse)。通过 dev-app-update.yml 可以强制开启开发环境的更新检测,方便调试。

yaml 复制代码
# dev-app-update.yml
provider: generic
url: http://127.0.0.1/electron/update

配合代码使用:

typescript 复制代码
// src/main/index.ts
import { autoUpdater } from 'electron-updater'

// 强制开发环境开启更新检测(必须与 dev-app-update.yml 配合)
autoUpdater.forceDevUpdateConfig = true

配置加载机制:

环境 配置文件 加载方式
开发环境 dev-app-update.yml autoUpdater.forceDevUpdateConfig = true 时自动加载
生产环境 electron-builder.yml 中的 publish 打包时写入 app-update.yml,自动加载

文件位置要求:

复制代码
项目根目录/
├── dev-app-update.yml          # 开发环境配置(放在项目根目录)
├── electron-builder.yml        # 打包配置
└── src/
    └── main/
        └── index.ts            # 代码中设置 forceDevUpdateConfig

4.3.4.4、更新服务器文件结构

更新服务器需要存放 electron-builder 生成的更新文件:

复制代码
更新服务器目录(如 http://127.0.0.1/electron/update/):
├── latest.yml                               # Windows 更新元数据
├── lucky-electron-2.0.0-x64.exe             # Windows 安装包
├── lucky-electron-2.0.0-x64.exe.blockmap    # Windows 增量更新映射
├── latest-mac.yml                           # macOS 更新元数据
├── lucky-electron-2.0.0.dmg                 # macOS 安装包
├── lucky-electron-2.0.0.dmg.blockmap        # macOS 增量更新映射
├── latest-linux.yml                         # Linux 更新元数据
└── 幸运IM-2.0.0.AppImage                    # Linux 安装包

latest.yml 文件内容示例:

yaml 复制代码
version: 2.0.0
files:
  - url: lucky-electron-2.0.0-x64.exe
    sha512: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
    size: 123456789
path: lucky-electron-2.0.0-x64.exe
sha512: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
releaseDate: '2024-01-15T10:30:00.000Z'
releaseNotes: |
  修复了登录失败的问题
  优化了消息列表滚动性能

生成这些文件:

bash 复制代码
npm run build:win   # 自动生成 latest.yml + .exe + .exe.blockmap
npm run build:mac   # 自动生成 latest-mac.yml + .dmg + .dmg.blockmap
npm run build:linux # 自动生成 latest-linux.yml + .AppImage

真实情况截图:

我是把打包后的内容放到nginx目录下

4.3.4.5、代码中的 autoUpdater 配置

typescript 复制代码
// src/main/index.ts
import { autoUpdater } from 'electron-updater'

// ========== 基础配置 ==========

// 1. 强制开发环境开启更新检测(需配合 dev-app-update.yml)
autoUpdater.forceDevUpdateConfig = true

// 2. 是否自动下载更新(默认 true)
// 设为 false 时,检测到更新只触发事件,需手动调用 downloadUpdate()
autoUpdater.autoDownload = true

// 3. 是否允许降级(默认 false)
// 设为 true 时允许从高版本降到低版本
autoUpdater.allowDowngrade = false

// ========== 更新通道配置 ==========

// 4. 设置更新通道(默认 'latest')
// 如设为 'beta',会请求 latest-beta.yml
autoUpdater.channel = 'latest'

// 5. 是否允许预发布版本(默认 false)
// 设为 true 时,会包含 beta/alpha 版本
autoUpdater.allowPrerelease = false

// ========== 请求配置 ==========

// 6. 设置请求头(如需要认证)
autoUpdater.headers = {
  'Authorization': 'Bearer your-token'
}

// 7. 设置请求超时时间(毫秒,默认 60000)
autoUpdater.requestHeaders = {
  'User-Agent': 'MyApp/2.0.0'
}

// ========== 日志配置 ==========

// 8. 开启调试日志(排查更新问题)
autoUpdater.logger = console
autoUpdater.logger = {
  info: (msg) => console.log('[Updater]', msg),
  warn: (msg) => console.warn('[Updater]', msg),
  error: (msg) => console.error('[Updater]', msg),
  debug: (msg) => console.debug('[Updater]', msg)
}

4.3.4.6、更新通道(channel)配置

通过 channel 可以实现多通道更新(正式版、测试版):

yaml 复制代码
# electron-builder.yml - 正式版
publish:
  - provider: generic
    url: http://127.0.0.1/electron/update
    channel: latest
yaml 复制代码
# electron-builder.yml - 测试版
publish:
  - provider: generic
    url: http://127.0.0.1/electron/update
    channel: beta

服务器文件结构:

复制代码
update/
├── latest/                    # 正式版
│   ├── latest.yml
│   └── lucky-electron-2.0.0-x64.exe
└── beta/                      # 测试版
    ├── latest-beta.yml
    └── lucky-electron-2.1.0-beta.1-x64.exe

代码中切换通道:

typescript 复制代码
// 正式版用户
autoUpdater.channel = 'latest'

// 测试版用户(加入测试计划)
autoUpdater.channel = 'beta'
autoUpdater.allowPrerelease = true

4.3.4.7、常见配置问题排查

问题 原因 解决方案
开发环境不检测更新 forceDevUpdateConfig 未设置 设置 autoUpdater.forceDevUpdateConfig = true
找不到 dev-app-update.yml 文件不在项目根目录 将文件放在项目根目录
更新服务器地址不对 electron-builder.ymlurl 配置错误 检查并修正 publish.url
更新包下载失败 服务器 CORS 或路径问题 确保服务器允许跨域,检查文件路径
latest.yml 未生成 publishAutoUpdate: false 设为 true 或删除该配置
版本比较异常 版本号格式不规范 使用 x.y.z 格式的 semver 版本号
正式版显示了 beta 版本 allowPrerelease: true 设为 false 或删除该配置

4.3.4.8、完整配置示例

electron-builder.yml 完整配置:

yaml 复制代码
appId: com.eastmoonlight.luckyim
productName: 幸运IM

# ... 其他打包配置 ...

# ========== 更新发布配置 ==========
publish:
  - provider: generic
    url: https://update.eastmoonlight.com/electron
    publishAutoUpdate: true
    channel: latest

# ========== 更新日志 ==========
releaseInfo:
  releaseName: v2.0.0
  releaseNotes: |
    ## 新增
    - 支持消息已读回执
    - 新增深色主题
    ## 修复
    - 修复登录失败的问题
    - 修复消息列表滚动卡顿

dev-app-update.yml 开发配置:

yaml 复制代码
provider: generic
url: http://127.0.0.1/electron/update

src/main/index.ts 代码配置:

typescript 复制代码
import { autoUpdater } from 'electron-updater'
import { app } from 'electron'

// ========== autoUpdater 配置 ==========
autoUpdater.forceDevUpdateConfig = true
autoUpdater.autoDownload = false   // 手动控制下载,以便展示进度
autoUpdater.allowPrerelease = false
autoUpdater.channel = 'latest'

// ========== 开启调试日志(仅开发环境) ==========
if (!app.isPackaged) {
  autoUpdater.logger = console
}

// ========== 监听事件 ==========
autoUpdater.on('checking-for-update', () => {
  console.log('[Updater] 正在检查更新...')
})

autoUpdater.on('update-available', (info) => {
  console.log('[Updater] 发现新版本:', info.version)
})

autoUpdater.on('update-not-available', () => {
  console.log('[Updater] 已是最新版本')
})

autoUpdater.on('error', (err) => {
  console.error('[Updater] 更新失败:', err)
})

autoUpdater.on('download-progress', (progress) => {
  console.log(`[Updater] 下载进度: ${progress.percent}%`)
})

autoUpdater.on('update-downloaded', (info) => {
  console.log('[Updater] 下载完成:', info.version)
})

// ========== 应用启动时自动检查 ==========
app.whenReady().then(() => {
  autoUpdater.checkForUpdatesAndNotify()
})

4.3.4.9、配置速查表

配置位置 配置项 说明 默认值
electron-builder.yml publish.provider 更新源类型 -
electron-builder.yml publish.url 更新服务器地址 -
electron-builder.yml publish.publishAutoUpdate 是否生成 latest.yml true
electron-builder.yml publish.channel 更新通道 latest
dev-app-update.yml provider / url 开发环境更新地址 -
autoUpdater.forceDevUpdateConfig 强制开发环境更新 false
autoUpdater.autoDownload 是否自动下载 true
autoUpdater.allowDowngrade 是否允许降级 false
autoUpdater.allowPrerelease 是否包含预发布版 false
autoUpdater.channel 更新通道 'latest'
autoUpdater.logger 日志输出 null

5、其他注意点

5.1、解决Electron-vite启动失败问题

java 复制代码
$env:ELECTRON_MIRROR = "https://npmmirror.com/mirrors/electron/"
node .\node_modules\electron\install.js

5.2、解决主进程启动中文乱码问题

java 复制代码
// 加上chcp 65001 > nul && ,然后在启动
{
  "scripts": {
    "dev": "chcp 65001 > nul && electron-vite dev"
  }
}
相关推荐
小小de风呀2 小时前
de风——【从零开始学习C++】(十八):AVL树——让二叉搜索树自己“站起来“的自平衡黑科技
c++·科技·学习
MartinYeung52 小时前
[论文学习]MCPTox:面向真实世界MCP服务器的工具投毒攻击基准测试
运维·服务器·学习
math_hongfan2 小时前
事务下单与状态流转:ArkTS 的 JOIN 联表在鸿蒙订单里实战
android·学习·华为·harmonyos
weixin_431600443 小时前
NestJS 入门(9):连上数据库,SQL 写在哪?
数据库·后端·sql·学习·nest.js
举手3 小时前
Epoll模型
linux·c++·学习
MartinYeung53 小时前
[论文学习]JBShield:通过激活概念分析与操纵防御大语言模型越狱攻击
人工智能·学习·语言模型
MartinYeung53 小时前
[论文学习]SMSR:带平滑检索的签名记忆——针对持久化LLM智能体系统运行时内存投毒的认证防御
人工智能·学习
橙橙笔记4 小时前
C++的学习第三部分
开发语言·c++·学习
小席是个热心肠13 小时前
AI相关的自我学习
java·人工智能·学习