目录
- 1、进程模型
-
- 1.1、渲染进程、预加载进程、主进程交互方式
-
- 1.1.1、三种进程的定位与职责
- [1.1.2、为什么不能直接在渲染进程中使用 Node.js?](#1.1.2、为什么不能直接在渲染进程中使用 Node.js?)
- 1.1.3、标准交互流程
-
- [方向一:渲染进程 → 主进程(单向通知)](#方向一:渲染进程 → 主进程(单向通知))
- [方向二:渲染进程 → 主进程(请求-响应)](#方向二:渲染进程 → 主进程(请求-响应))
- [方向三:主进程 → 渲染进程(主动推送)](#方向三:主进程 → 渲染进程(主动推送))
- [1.1.4、IPC 通信方法详解](#1.1.4、IPC 通信方法详解)
-
- [一、`send` + `on`(单向通信)](#一、
send+on(单向通信)) - [二、`invoke` + `handle`(双向通信,⭐ 推荐)](#二、
invoke+handle(双向通信,⭐ 推荐)) - [三、主进程 → 渲染进程(主动推送)](#三、主进程 → 渲染进程(主动推送))
- 四、`sendTo`:向指定渲染进程发送
- 五、`once`:一次性监听
- [一、`send` + `on`(单向通信)](#一、
- [1.1.5、安全实践:contextBridge 的正确用法](#1.1.5、安全实践:contextBridge 的正确用法)
- 1.1.6、多窗口场景的注意事项
- 1.1.7、调试技巧
-
- 1.1.7.1、查看所有注册的频道
- 1.1.7.2、渲染进程调试
- [1.1.7.3、通过主进程主动打开 DevTools](#1.1.7.3、通过主进程主动打开 DevTools)
- [1.1.7.4、监听所有 IPC 消息(开发环境)](#1.1.7.4、监听所有 IPC 消息(开发环境))
- 1.1.8、方法速查表
- 1.2、ContextBridge(上下文隔离)
-
- [1.2.1、什么是 ContextBridge?](#1.2.1、什么是 ContextBridge?)
- 1.2.2、为什么需要它?
- [1.2.3、核心 API](#1.2.3、核心 API)
- 1.2.4、安全原则
-
- [✅ 白名单原则(Whitelist)](#✅ 白名单原则(Whitelist))
- [✅ 输入校验](#✅ 输入校验)
- [✅ 最小暴露原则](#✅ 最小暴露原则)
- 1.2.5、数据传递限制
- 1.2.6、最佳实践示例
- 1.2.7、一句话总结
- 1.3、Electron-Vite的多进程交互方式
-
- 1.3.1、本节定位
- [1.3.2、认识 @electron-toolkit/preload](#1.3.2、认识 @electron-toolkit/preload)
- 1.3.3、预加载脚本的标准写法
- 1.3.4、渲染进程中的使用方式
- 1.3.5、主进程中的完整示例
- 1.3.6、完整调用链路图
- [1.3.7、TypeScript 类型安全用法](#1.3.7、TypeScript 类型安全用法)
- [1.3.8、与 1.1、1.2 节的关系](#1.3.8、与 1.1、1.2 节的关系)
- 1.3.9、本节速查
- 2、核心功能模块
-
- 2.1、窗口管理
-
- 2.1.1、窗口创建、大小、关闭逻辑
-
- 2.1.1.1、窗口创建
- 2.1.1.2、窗口尺寸控制
- 2.1.1.3、窗口关闭逻辑
- 2.1.1.4、应用退出逻辑
- 2.1.1.5、多窗口管理
- 2.1.1.6、窗口状态持久化
- 2.1.1.7、开发调试技巧
-
- [2.1.1.7.1、自动打开 DevTools](#2.1.1.7.1、自动打开 DevTools)
- 2.1.1.7.2、常用调试命令
- 2.1.1.8、本节速查
- [2.1.2、 Vue 路由页面的加载方式](#2.1.2、 Vue 路由页面的加载方式)
-
- 2.1.2.1、加载方式概述
- 2.1.2.2、参考代码(推荐方式)
- 2.1.2.3、在窗口创建中使用
- [2.1.2.4、hash 模式与 history 模式](#2.1.2.4、hash 模式与 history 模式)
-
- [2.1.2.4.1、hash 模式(推荐,无需额外配置)](#2.1.2.4.1、hash 模式(推荐,无需额外配置))
- [2.1.2.4.2、history 模式(需额外处理 404 回退)](#2.1.2.4.2、history 模式(需额外处理 404 回退))
- 2.1.2.5、开发环境与生产环境的加载流程
- 2.1.2.6、常用路由跳转场景
- 2.1.2.7、本节速查
- 2.2、托盘管理
- 2.3、快捷键管理(全局/局部)
-
- 2.3.1、快捷键概述
- 2.3.2、全局快捷键
-
- 2.3.2.1、基础使用
- 2.3.2.2、完整实现(参考示例)
- [2.3.2.3、注册与注销 IPC 方法](#2.3.2.3、注册与注销 IPC 方法)
- 2.3.2.4、清理(应用退出时)
- 2.3.3、局部快捷键
- 2.3.4、快捷键冲突处理
- 2.3.5、局部快捷键详细实现
- 2.3.6、渲染进程调用快捷键(参考实现)
- 2.3.7、常用快捷键组合
- 2.3.8、最佳实践
- 2.3.9、本节速查
- 2.4、本地存储与持久化
- 2.4、本地存储与持久化
-
- 2.4.1、项目缓存目录
-
- 2.4.1.1、缓存目录概述
- 2.4.1.2、获取缓存目录:app.getPath()
- 2.4.1.3、各平台默认路径
-
- [2.4.1.3.1、`userData` ------ 用户数据目录](#2.4.1.3.1、
userData—— 用户数据目录) - [2.4.1.3.2、`cache` ------ 系统缓存目录](#2.4.1.3.2、
cache—— 系统缓存目录)
- [2.4.1.3.1、`userData` ------ 用户数据目录](#2.4.1.3.1、
- 2.4.1.4、项目自定义缓存目录
-
- [2.4.1.4.1、在 userData 下创建自定义缓存子目录](#2.4.1.4.1、在 userData 下创建自定义缓存子目录)
- 2.4.1.4.2、使用系统缓存目录
- 2.4.1.5、自定义缓存路径:app.setPath()
- 2.4.1.6、缓存目录的清理
- 2.4.1.7、最佳实践
- 2.4.2、Electron存储(electron-store)
- [2.4.2、Electron 存储(electron-store)](#2.4.2、Electron 存储(electron-store))
-
- [2.4.2.1、electron-store 概述](#2.4.2.1、electron-store 概述)
- 2.4.2.2、安装与配置
- 2.4.2.3、基本用法
-
- [2.4.2.3.1、创建 Store 实例](#2.4.2.3.1、创建 Store 实例)
- 2.4.2.3.2、读写数据
- 2.4.2.3.3、嵌套数据操作
- [2.4.2.4、默认值与 Schema 验证](#2.4.2.4、默认值与 Schema 验证)
-
- 2.4.2.4.1、设置默认值
- [2.4.2.4.2、JSON Schema 数据校验](#2.4.2.4.2、JSON Schema 数据校验)
- 2.4.2.5、数据迁移(migrations)
- 2.4.2.6、数据加密
- 2.4.2.7、在渲染进程中使用
-
- 2.4.2.7.1、方式一:主进程中初始化(推荐)
- [2.4.2.7.2、方式二:预加载脚本暴露 API](#2.4.2.7.2、方式二:预加载脚本暴露 API)
- 2.4.2.7.3、渲染进程调用
- 2.4.2.8、实用封装示例
-
- [2.4.2.8.1、按功能拆分多个 Store](#2.4.2.8.1、按功能拆分多个 Store)
- 2.4.2.9、最佳实践
- 2.4.2.10、本节速查
- [2.4.2.11、与 2.4.1 节的关系](#2.4.2.11、与 2.4.1 节的关系)
- 2.4.3、数据库管理
- 2.4.3、数据库管理
-
- 2.4.3.1、数据库选型
- 2.4.3.2、架构设计
- 2.4.3.3、核心实现(参考)
-
- [2.4.3.3.1、主进程 DatabaseManager](#2.4.3.3.1、主进程 DatabaseManager)
- [2.4.3.3.2、IPC 注册](#2.4.3.3.2、IPC 注册)
- 2.4.3.3.3、渲染进程代理
- 2.4.3.4、关键设计点
- 2.4.3.5、高级特性(参考)
-
- [2.4.3.5.1、FTS5 全文搜索](#2.4.3.5.1、FTS5 全文搜索)
- [2.4.3.5.2、装饰器 ORM(可选)](#2.4.3.5.2、装饰器 ORM(可选))
- [2.4.3.5.3、SQL XML 管理(可选)](#2.4.3.5.3、SQL XML 管理(可选))
- 2.4.3.6、最佳实践
- 2.4.3.7、本节速查
- 2.5、其他知识点
- 2.5、其他知识点
-
- 2.5.1、判断主进程环境、系统
- 2.5.2、开发环境开启控制台
-
- [2.5.2.1、窗口创建后开启 DevTools](#2.5.2.1、窗口创建后开启 DevTools)
- 2.5.2.2、无条件开启(调试特殊问题时使用)
- 2.5.2.3、通过环境变量控制
- [2.5.3、开发环境引入 Vue 开发工具](#2.5.3、开发环境引入 Vue 开发工具)
-
- [2.5.3.1、方式一:加载本地已安装的 Chrome 扩展](#2.5.3.1、方式一:加载本地已安装的 Chrome 扩展)
- [2.5.3.2、方式二:使用 electron-devtools-installer(推荐)](#2.5.3.2、方式二:使用 electron-devtools-installer(推荐))
- 2.5.3.3、注意事项
- 2.5.4、设置窗口名称
- 2.5.5、本节速查
- 3、主进程生命周期
- 4、工程化与交付
-
- 4.1、运行配置
-
- [4.1.1、electron-vite 配置](#4.1.1、electron-vite 配置)
- [4.1.2、TypeScript 配置](#4.1.2、TypeScript 配置)
- 4.1.3、路径别名统一管理
- [4.1.4、NPM 脚本](#4.1.4、NPM 脚本)
- [4.1.5、VSCode 开发配置](#4.1.5、VSCode 开发配置)
- 4.1.6、本节速查
- 代码参考说明
- 4.2、打包方式
-
- [4.2.1、electron-builder 配置](#4.2.1、electron-builder 配置)
- [4.2.1.2、Windows 打包配置](#4.2.1.2、Windows 打包配置)
- [4.2.1.3、macOS 打包配置](#4.2.1.3、macOS 打包配置)
- [4.2.1.4、Linux 打包配置](#4.2.1.4、Linux 打包配置)
- 4.2.1.5、更新发布配置
- 4.2.2、打包脚本
- 4.2.3、跨平台打包注意事项
- 4.2.4、本节速查
- 4.3、更新方式
-
- 4.3.1、更新检测机制
-
- 4.3.1.1、获取当前版本
- 4.3.1.2、检测方式一:手动请求接口
- [4.3.1.3、检测方式二:electron-updater 自动获取](#4.3.1.3、检测方式二:electron-updater 自动获取)
- 4.3.1.4、两种检测方式对比
- 4.3.2、更新触发时机
- 4.3.3、手动更新(跳转下载)
- 4.3.4、自动更新(electron-updater)
-
- 4.3.4.1、自动更新配置概述
- 4.3.4.2、打包配置(electron-builder.yml)
- 4.3.4.3、开发环境配置(dev-app-update.yml)
- 4.3.4.4、更新服务器文件结构
- [4.3.4.5、代码中的 autoUpdater 配置](#4.3.4.5、代码中的 autoUpdater 配置)
- 4.3.4.6、更新通道(channel)配置
- 4.3.4.7、常见配置问题排查
- 4.3.4.8、完整配置示例
- 4.3.4.9、配置速查表
- 5、其他注意点
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 节点 |
| 普通对象、数组 | 函数(作为参数传递时) |
Buffer、Uint8Array |
Map、Set(会丢失原型) |
| 日期对象(转为字符串) | 类实例(仅保留属性) |
函数传递的特殊处理:
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 | require、process 等不可用 |
| 预加载脚本的上下文与渲染进程隔离 | 即使预加载中定义了 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 对象 |
普通对象({})、数组([]) |
函数(作为参数传递时) |
ArrayBuffer、Uint8Array |
Map、Set、WeakMap(原型丢失) |
| 日期对象(转为 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 覆盖面 | 按需暴露,需要自己维护 | 预置了 ipcRenderer、webFrame、webUtils、process |
| 类型安全 | 需手动定义 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 通信核心(send、invoke、on、once 等) |
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/on、invoke/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 包含哪些能力? |
ipcRenderer、webFrame、webUtils、process |
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 |
| 关闭事件触发顺序? | close → will-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;临时缓存 → cache 或 userData/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 的
safeStorageAPI,利用操作系统原生加密(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 |
|---|---|---|
| 数据类型 | 临时数据(可重建) | 持久化数据(不可丢失) |
| 存储位置 | cache 或 userData/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、本节速查
| 问题 | 答案 |
|---|---|
| 主进程启动顺序? | beforeReady → app.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.json 和 tsconfig.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.ts 的 resolve.alias |
| TypeScript 路径别名在哪里配置? | tsconfig.node.json 和 tsconfig.web.json 的 paths |
| 装饰器支持需要开启什么? | experimentalDecorators 和 emitDecoratorMetadata |
| 开发命令是什么? | npm run dev |
| 打包命令是什么? | npm run build:win / build:mac / build:linux |
| VSCode 保存时自动格式化怎么配置? | editor.formatOnSave: true |
| 保存时自动修复 ESLint? | source.fixAll.eslint: "explicit" |
代码参考说明
参考价值分析:
以上配置提取自你的项目,核心设计值得参考:
- 三进程独立构建 :通过
electron-vite的main/preload/renderer三部分分离配置,各进程互不干扰- 项目引用(Project References) :通过根
tsconfig.json引用两个子配置,实现主进程和渲染进程的类型隔离- 别名统一管理:Vite 和 TypeScript 使用同一套路径别名,避免解析不一致
- 自动导入 :通过
unplugin-auto-import和unplugin-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.png、entitlements.mac.plist 等文件 |
| files | 打包包含的文件 | 默认包含 out/**/* 和 package.json;resources/ 需手动添加 |
| 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) |
建议使用 256x256 的 icon.png |
artifactName |
安装包文件名模板 | ${name}、${version}、${arch}、${platform} |
target |
安装包格式和架构 | nsis、portable、squirrel |
oneClick |
是否一键安装 | false 显示安装向导;true 无界面静默安装 |
allowToChangeInstallationDirectory |
是否允许修改安装路径 | 需配合 oneClick: false 使用 |
createDesktopShortcut |
桌面快捷方式 | always、never、perMachine |
artifactName 变量:
| 变量 | 说明 | 示例 |
|---|---|---|
${name} |
package.json 中的 name |
lucky-electron |
${version} |
package.json 中的 version |
2.0.0 |
${arch} |
架构 | x64、ia32、arm64 |
${platform} |
平台 | win、mac、linux |
${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 |
更新源类型(generic、github、s3、spaces) |
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)、github、s3、spaces |
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.isPackaged 为 false)。通过 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.yml 中 url 配置错误 |
检查并修正 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"
}
}