前言
这篇解决一个问题:把一个 Vue3 + Vite 的前端项目做成桌面端应用之前,方案层应该怎么定。 为什么选 Electron 而不是别的、Electron 内部三个进程怎么分工、加载 Web 应用的三条路线各自什么下场、动手之前环境要备齐什么------这些决策点全部讲透之后,下一篇我会直接拿一个真实的若依 Vue3 项目开刀,把它改造成桌面端。
读完这篇你能带走三样东西:能看懂 Electron 的主进程 / 渲染进程 / preload 分工 ;能判断 file://、本地 HTTP、自定义协议三条加载路线什么时候用哪条 ;一套验证过版本号的开发环境准备清单。文中的版本号、报错信息、端口冲突全部来自我在真实项目上的实测,不是网上抄来的二手数据。
先交代实践对象:一个基于若依 Vue3 二次开发的管理系统,技术栈 Vue 3.5.26 + Element Plus 2.13.1 + Vite 6.4.1 ,构建产物 6.22 MB / 299 个文件。下文所有"实测"都发生在这个项目上。整个系列分上下两篇:上篇(本文)定方案、通原理、备环境;下篇动手把若依改造成桌面端。
这里咱们先看下最终实现的效果:


上篇结束时,这套方案已经在该项目里落地并实测通过,具体改动清单见第六章。

图1 封面图
一、为什么要桌面化,为什么是 Electron

1.1 管理系统"桌面化"的真实需求
我参与的这个项目是典型的内网管理系统:后端部署在内网(实测地址 192.168.x.x:8040,纯内网 IP),生产环境用 Nginx 托管前端。做桌面端的动机很朴素,就四条:
-
不想依赖用户的浏览器。 内网老机器上什么浏览器都有:IE 内核、低版本 Chrome、各家安全插件。前端报错有一半是浏览器环境不对。桌面端把运行环境锁死在自带的 Chromium 里,环境一致性从根上解决。
-
统一入口。 员工要的是"双击图标就能用",不是"从收藏夹里翻网址",更不是"地址栏手输一遍"。
-
离线 / 弱网容错。 前端资源打包在本地,启动不再依赖服务器把 index.html 送过来;断的只是业务接口,页面本身秒开。
-
系统能力。 托盘、系统通知、文件对话框、开机自启、本地配置文件------这些都是浏览器摸不到的东西。以开机自启为例,内网值班岗、监控大屏这类机器要求"开机即进入系统",浏览器方案只能靠用户手动点收藏夹,桌面端则可以在主进程里调用
app.setLoginItemSettings({ openAtLogin: true })一行注册到系统启动项,配合托盘常驻,做到开机后无人值守自动拉起;再比如托盘,把窗口最小化到托盘而不是任务栏,既符合内网工具"常驻后台"的使用习惯,也避免了误关窗口导致业务中断。这些能力单看都不复杂,但浏览器一个都给不了,而它们恰恰是"管理系统"和"网页"体验上最直观的分水岭。
再说一条亲身经历的成本账:一个内网系统如果只给浏览器版,支撑成本里永远有一项"排查用户浏览器环境"。低版本内核导致 ECharts 渲染错位、安全插件拦截 axios 请求、老 IE 直接白屏------这些问题的共同点是代码没错,环境错了 ,而你在电话里跟用户解释不清"什么是内核"。桌面端把这个问题一次性消灭:所有用户跑的是同一份打包进应用的 Chromium。
一句话总结:管理系统做桌面端,价值不在炫技,在于把"运行环境"从用户手里收回来。
1.2 桌面端技术路线对比
桌面化路线不止 Electron 一条,先摆一张对比表,再解释为什么这类项目我选 Electron:
| 方案 | 运行时 | 安装包体积 | 常驻内存(典型值) | 前端代码复用 | 生态成熟度 | 适合场景 |
|---|---|---|---|---|---|---|
| Electron | Chromium + Node.js | 60--100MB 量级 | 约 150--300MB | 直接复用 | 极高 | 管理系统、开发工具 |
| Tauri | 系统 WebView2 / WebKit | 3--10MB | 约 30--80MB | 基本复用 | 快速成长中 | 体积敏感的轻量工具 |
| NW.js | Chromium + Node.js | 与 Electron 同量级 | 与 Electron 同量级 | 直接复用 | 老牌 | 旧项目迁移 |
| WPF + CEF | .NET + CEF | 视 .NET 运行时而定 | 较高 | 不复用 | 中 | .NET 技术栈团队 |
| Qt WebEngine | Qt + Chromium | 40MB 以上 | 较高 | 不复用 | 中 | 嵌入式、工控设备 |
我的判断是:管理系统类项目,Electron 依然是复用度最高、社区答案最全的选择。 Tauri 包体小是事实,但管理系统普遍运行在 Windows 内网机器上,几十 MB 的体积差换不来实际收益,反而要面对一个新问题:系统 WebView 版本碎片化 (老 Win10 缺 WebView2 运行时要先装环境,公司内网装环境又要走审批)。而 Electron 自带固定版本的 Chromium,恰好把"环境一致性"这个桌面化的核心收益给得最彻底------它从来不是轻量方案,而是最省心的方案。
表里的内存和体积数据标了"典型值":不同版本、不同页面的实测波动很大,别拿某个精确数字当基准去和别人抬杠。判断技术路线看的是约束匹配度:体积敏感选 Tauri,.NET 团队选 WPF + CEF,前端团队做管理系统选 Electron,就这么简单。
1.3 Electron 到底是个什么东西
Electron 中的 Chromium 是框架的 浏览器内核/渲染引擎 ,运行在 渲染进程 ,负责 HTML/CSS/JavaScript 的解析渲染与执行,提供 Web API、DevTools、多进程沙箱 等能力,让开发者用 Web 技术 构建 跨平台桌面应用 。它随 Electron 捆绑并升级 ;主进程通过 BrowserWindow 加载页面,渲染进程默认不直接暴露 Node.js ,需用 preload 脚本 + contextBridge 安全桥接。因此 Chromium 决定 页面兼容性、性能和安全基线 ,也带来 安装包体积较大 和需跟随 Electron 更新 安全补丁 的维护成本。
Electron 里的 Chromium ,简单说就是整个框架的"浏览器内核 ",负责所有界面渲染 和网页技术(HTML/CSS/JS)的执行 ,让开发者用 Web 技术 就能构建跨平台桌面应用。
Electron = Chromium(负责渲染)+ Node.js(负责本地能力)+ 一层进程调度。 界面由 Chromium 渲染,文件系统、进程、网络、系统 API 由 Node.js 提供。
安装 Electron 时会看到一个不小的可执行文件,体积大正是因为里面装了一整个 Chromium。这里有一条关键认知,能帮你少踩很多坑:
每个 Electron 大版本自带固定版本的 Chromium 和 Node.js,与开发机安装的 Node 完全无关 ------package.json 里写的 electron 版本,决定的是打包进去的 Chromium/Node,而不是依赖开发机环境。所以"我本机 Node 才 12 能不能装 Electron"是个伪问题:能不能装,取决于 npm 安装器对 Node 的要求,而不是 Electron 本身。这个区别不是文字游戏------electron 45 的安装器就实测要求 Node ≥ 22.12.0(在 Node 20 上安装会静默失败,详见第七章注意事项)。
版本体系再补三条规律,做版本管理时用得上:
- 发布节奏 :Electron 大约每 8 周发一个大版本,官方只维护最新的 3 个大版本,老版本不再收安全补丁------所以别在旧版上"求稳",那不是稳,是裸奔。
- 内置版本对照 :举例来说,Electron 28 内置 Chromium 120 / Node 18.18,Electron 33 内置 Chromium 130 / Node 20.18。具体到某个版本的精确对照以官方 Release Notes 为准,这里给的是量级概念:渲染侧跟 Chromium 走,本地侧跟 Node 走。
- 升级成本 :Electron 升级 = Chromium + Node 同时升级。前端 API 兼容性变化基本只在 Chromium 层面,Node 层面主要影响主进程代码。所以主进程代码越薄,升级越轻松。
用 Electron 的知名产品不需要列一长串,VS Code 一个就够: 它证明了这套架构撑得起"高复杂度、高频迭代、跨平台"的桌面产品。Slack、Figma 桌面版、微信开发者工具走的也是同一条路。AI 桌面应用也大多是这条路,本机实测证据见 1.4。
1.4 AI 桌面应用也在走同一条路:Claude、Codex、DeepSeek Harness
不少前端会觉得 Electron 是"做管理系统的老技术",AI 时代该换新方案了。实际上你现在日常在用的 AI 桌面应用,拆开一看多半还是 Electron。这一节不引二手文章,全部用本机实测的安装目录说话。
Claude Desktop( Claude 桌面端 ):Electron。 本机安装目录 C:\Users\Lee\AppData\Local\AnthropicClaude 下,两个版本目录里都能看到 resources/app.asar(54.5MB)------app.asar 是 Electron 打包产物的标志性结构 ;同目录还有 Update.exe 和一串 Squirrel-*.log,Squirrel 正是 Electron 生态的 Windows 更新组件。社区里给 Claude Desktop 做 MCP 补丁的方案,改的就是这个 app.asar。
Codex:桌面客户端场景,也是落在 Electron 上。 Codex 目前以四种形态存在:网页版、CLI(Node 实现)、VS Code 插件(插件 UI 跑在 VS Code 的 Webview 里,React 系)、以及 ChatGPT 桌面应用内的入口。ChatGPT 的 Windows/macOS 桌面客户端是 Electron 内核(公开信息,以官方为准),所以 Codex 的桌面体验实际运行在 Electron 里。
**DeepSeek Harness( DeepSeek 桌面端 ):本机实测,Electron 实锤。**装在本机 D:\Software\DeepSeekHarness,目录里直接摆着 LICENSE.electron.txt,外加全套 Chromium 运行时文件(icudtl.dat、ffmpeg.dll、chrome_*.pak、resources.pak、v8_context_snapshot.bin),以及 resources/app.asar;任务管理器里它跑着 7 个同名进程------这正是 Electron"一主多渲染"的进程模型(第二章展开)。更关键的一点:它的 Web GUI 挂在 http://127.0.0.1:19387 本地服务上 ------这就是第三章路线 B(本地 HTTP)的活样本。同是 Electron,DeepSeek Harness 用本地 HTTP 承载界面,本文的若依桌面端走自定义协议(下篇落地),壳一样、加载路线不同,取舍依据正是第三章那张对比表。
| 产品 | 桌面形态 | 页面加载路线 | 前端框架 | 与本文的关系 |
|---|---|---|---|---|
| Claude Desktop | Electron(app.asar + Squirrel,本机实测) | 打包资源直接加载 | React 系 | 同一套桌面基建 |
| Codex | 无独立客户端;桌面场景跑在 ChatGPT 桌面应用(Electron) | 同上 | React 系 | 桌面体验依赖 Electron |
| DeepSeek Harness | Electron(LICENSE.electron.txt 等,本机实测) | 本地 HTTP(127.0.0.1:19387) | Web GUI(React 系) | 第三章路线 B 的活样本 |
| 本文若依桌面端 | Electron + Vue | 自定义协议 app://(下篇落地) | Vue 3 | 第三章路线 C |
前端框架与桌面方案是解耦的:这些产品的前端多是 React 系,本文是 Vue,但 Electron 的主进程 / 渲染进程 / preload / IPC / 协议机制对任何框架一视同仁------区别只在渲染进程里跑的是哪个框架打包出来的产物。所以"Vue + Electron"不是小众路线,是同一套桌面基建上的一种框架选择;第二章往后讲的一切,换 React 项目照单全收。
1.5 本项目为什么走到桌面化
把实践对象的现状摊开看,桌面化的理由就具体了:前端是若依 Vue3 二次开发,生产环境由 Nginx 托管两条路径(静态资源走 /IPESAgent/,接口走 /IPESAgent-api/ 转发到内网后端 8040);开发环境靠 Vite 代理转发。整套系统是典型的"浏览器 + 内网"架构,没有任何桌面端成分。
改造成桌面端的诉求只有一条:用户双击图标就能用这套系统,环境不依赖浏览器。 而选 Electron 的技术理由,这一章前面几节已经给全了------接下来讲清楚它内部怎么转。
二、Electron 的进程模型:看懂它,后面全通
Electron 的运行结构一句话:一个主进程 + 若干个渲染进程 + 每窗口一座 preload 桥。 下文所有方案选型、所有踩坑,都建立在这个模型上。
2.1 主进程(Main Process)
每个 Electron 应用只有一个主进程,它是程序入口(package.json 的 main 字段指向它)。主进程的职责是"管应用"而不是"画界面":
- 创建窗口:new BrowserWindow,控制大小、菜单、标题栏样式;
- 管理生命周期:应用启动(whenReady)、全部窗口关闭、激活(macOS 点击 Dock);
- 系统能力:托盘(Tray)、系统通知(Notification)、全局快捷键、菜单;
- 注册自定义协议 :这也是后面若依实战里最核心的一段------把 API 请求转发到内网后端、把静态资源从本地磁盘喂给页面,全部写在主进程里。
主进程拥有完整的 Node.js 能力 ------文件读写、起 HTTP 服务、转发网络请求。记住结论:主进程是桌面应用里唯一"全能"的进程,其他进程要办事都得求它。
主进程还有个不起眼但影响第一印象的细节:窗口默认 show: true,内容还没渲染完,用户先看到一片白。生产级应用普遍的做法是先隐藏窗口,等渲染就绪再显示:
js
const win = new BrowserWindow({ show: false, /* 其他配置 */ })
win.once('ready-to-show', () => win.show())
"双击图标先闪一片白、启动感觉卡"这类反馈,一半原因就出在没写这两行。启动体验的问题后面单独说,这里先记住:窗口显示时机是可以控的,别交给默认值。
2.2 渲染进程(Renderer Process)与 preload
每个 BrowserWindow 对应一个渲染进程,跑的就是 HTML / CSS / JS。若依的整个 Vue 应用------路由、axios、Element Plus------全部跑在渲染进程里,体验和浏览器里几乎一致。
安全设计上,渲染进程默认是"阉割版 Node":不能直接 require,摸不到文件系统。如果页面需要主进程的能力(读本地配置、调托盘),只能走 preload 这座桥。
preload 在页面加载前执行,是渲染进程里唯一拥有受限 Node 能力的脚本。官方推荐的用法是 contextBridge.exposeInMainWorld 把白名单 API 暴露给页面:
js
// electron/preload.js ------ 白名单式暴露,不整锅端
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('desktopAPI', {
// 页面只能调用这两个函数,其他一概摸不到
getVersion: () => ipcRenderer.invoke('app:get-version'),
openDialog: () => ipcRenderer.invoke('app:open-dialog')
})
页面侧拿到的 window.desktopAPI 就两个函数,桥上有多少能力由 preload 决定,这是 Electron 安全模型的边界线。 三个 webPreferences 关键项的关系也在这里定死:contextIsolation: true 隔离页面与 preload 的 JS 世界、nodeIntegration: false 关掉页面直接拿 Node、sandbox 视项目情况开启。若依的 Vue 代码在隔离世界里的表现和浏览器里完全一样,不需要任何适配。
2.3 IPC:两个进程之间唯一的对话通道
主进程与渲染进程不共享内存,靠 IPC(ipcMain / ipcRenderer)传消息。API 分两代:老式的 send / on 是"发出去就不管",新式的 invoke / handle 是"请求-响应",日常开发推荐后者:
js
// 主进程
const { ipcMain } = require('electron')
ipcMain.handle('app:get-version', () => app.getVersion())
// 渲染进程(经 preload 暴露后)
const v = await window.desktopAPI.getVersion()
invoke / handle 的好处是异常可以穿透 :主进程 handler 里 throw 的 Error,渲染进程侧 await 会原样收到,不用再手工定义"错误码 + 错误消息"的返回约定。如果渲染进程要主动推消息给主进程(比如页面关闭前保存状态),用 ipcRenderer.send 配 ipcMain.on,一个方向一种 API,别混用:
js
// 渲染进程:单向上报(不关心返回值)
ipcRenderer.send('page:before-close', { path: '/index' })
// 主进程:监听上报
ipcMain.on('page:before-close', (event, payload) => {
console.log('页面即将关闭,当前路由:', payload.path)
})
日常开发九成的场景 invoke / handle 就够了,send / on 留给"上报类"消息。双向都塞进 handle 不是不行,但会混淆"谁在回答谁"的语义,排查时多一层心智成本。
两个老生常谈但真的会出事的点,这里用粗体钉死:
- 永远不要 nodeIntegration: true 一刀切。它等于把整个 Node 塞给页面,页面一旦被 XSS,攻击者拿到的是整台机器的控制权。
- contextIsolation 必须保持开启。Vue 应用与 preload 的世界彼此隔离,桥上的白名单 API 是唯一合法入口。
2.4 主进程与渲染进程的能力对照
两个进程"能干什么"差别极大,一张表对照清楚,后面看代码就不会晕:
| 能力 | 主进程 | 渲染进程(页面侧) |
|---|---|---|
| DOM / window / Vue | 无 | 有 |
| Node 全量 API(fs、net、child_process) | 有 | 无(除非 preload 白名单暴露) |
| 窗口 / 托盘 / 菜单 / 协议 | 有 | 无 |
| 网络请求(axios / fetch) | 有(net 模块) | 有(页面 fetch) |
| 本地存储 | 无 localStorage | 有 localStorage / Cookie |
把四节串起来:主进程管应用、渲染进程画界面、preload 做桥、IPC 传话------四个角色定位清楚后,后面的所有改造都是在"往主进程加能力、经 preload 按需暴露给页面"。整体结构如图2所示。

图2 Electron 三进程架构图
三、把 Web 应用"装进"桌面的三条路线
这是本篇最值钱的一章。很多教程只告诉你要打包,不告诉你**加载方式选错会直接白屏,而且报错信息毫无提示**。三条路线我都结合实际项目验证过,先把结论放在最前面:
管理系统选自定义协议;file:// 直接排除,不用试。
3.1 路线 A:file:// 直接加载 dist ------ 实测必白屏
直觉上最"桌面"的方案:build:prod 产出 dist,主进程 loadFile('dist/index.html')。我在若依项目上实测了这条路,三个原因叠加,必白屏。
原因一:构建产物是绝对路径。 若依项目配置了子路径部署(Vite 的 base: '/IPESAgent/'),实际打出来的 index.html 长这样:
html
<link rel="icon" href="/IPESAgent/favicon.ico">
<script type="module" crossorigin src="/IPESAgent/static/js/index-vWY2K_XB.js"></script>
<link rel="stylesheet" crossorigin href="/IPESAgent/static/css/index-Du6B1dXm.css">
在 file:// 页面里,/IPESAgent/static/js/... 会被解析成 file:///IPESAgent/static/js/...------指向磁盘根目录下的 IPESAgent 文件夹,而不是 dist 目录。资源全部 404,白屏。
原因二:type="module" 受 CORS 约束。 就算把 base 改成 './' 让路径相对化,<script type="module"> 在 Chromium 里强制走 CORS 校验,而 file:// 的 origin 是 opaque(null),模块脚本一律被拦 ,DevTools 里报 Access to script ... from origin 'null' has been blocked by CORS policy。"改个 base 就能用 file://"是流传最广的错误结论之一,这条我专门验证过:相对路径照样拦。
原因三:history 路由没有服务端兜底。 若依用 createWebHistory,刷新和深链依赖服务端把任意路径回退到 index.html(Nginx 的 try_files 干的就是这个)。文件系统没有这层逻辑,刷新即 404。
顺带说一句:若依这类管理系统的token 存在 Cookie 里 (js-cookie 写 Admin-Token),而 file:// 页面的 document.cookie 基本不可用------这是第四个雷,只是被前三个抢先炸了。一条路线四个雷,这就是 file:// 被直接排除的原因。
3.2 路线 B:主进程里起一个本地 HTTP 服务
主进程用 Node 起静态服务(serve-handler、express 都行),窗口 loadURL('http://127.0.0.1:xxxx/')。
这条路线功能上是通的:HTTP origin 正规,模块加载、history fallback、Cookie 全部正常。示意实现(对比用,本项目不采用这条路线):
js
// electron/main.js ------ 本地 HTTP 路线示意
const http = require('http')
const serveHandler = require('serve-handler')
const server = http.createServer((req, res) => {
serveHandler(req, res, {
public: 'dist',
rewrites: [{ source: '**', destination: '/index.html' }] // history 路由兜底
})
})
// port 0 = 系统随机分配,避免端口冲突;退出时 server.close() 优雅关停
server.listen(0, () => {
win.loadURL(`http://127.0.0.1:${server.address().port}/`)
})
但工程上别扭:要自己管端口分配、要处理防火墙弹窗、要防止多实例抢端口、退出时要优雅关停服务。相当于在桌面应用里内嵌并运维一个小 Nginx,每一件都是额外的心智负担。它能用,但它把简单问题复杂化了。
3.3 路线 C:自定义协议(app://)------把 Nginx 搬进主进程
Electron 允许应用注册自己的协议,思路特别干净:让主进程扮演 Nginx,把生产环境的两条 location 用代码复刻一遍。
若依项目生产环境的 Nginx 配置恰好长这样:
nginx
# 静态资源:SPA fallback
location /IPESAgent/ {
try_files $uri $uri/ /IPESAgent/index.html;
}
# 业务接口:转发后端并剥掉前缀
location /IPESAgent-api/ {
proxy_pass http://127.0.0.1:8040/;
}
Electron 侧的对应实现,主进程注册一个特权协议,处理器里分两个分支(骨架示意,完整实现在下篇):
js
// electron/main.js ------ 骨架示意:协议注册 + 双分支处理
const { app, protocol, net, BrowserWindow } = require('electron')
const path = require('path')
const fs = require('fs')
const DIST_DIR = path.join(__dirname, '../dist') // 构建产物目录
// 注册特权:standard(按 http 语义解析路径)、secure(安全上下文)
protocol.registerSchemesAsPrivileged([
{
scheme: 'app',
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true }
}
])
app.whenReady().then(() => {
protocol.handle('app', (request) => {
const url = new URL(request.url) // app://IPESAgent/xxx 或 app://IPESAgent-api/xxx
// 分支一:API 前缀 → 转发内网后端(剥掉前缀,等价 nginx 的 proxy_pass)
if (url.hostname === 'IPESAgent-api') {
return net.fetch('http://192.168.x.x:8040' + url.pathname + url.search, {
bypassCustomProtocolHandlers: true // 关键:绕过自定义协议,否则会再进一次 handler
})
}
// 分支二:静态资源 + SPA fallback(等价 nginx 的 try_files)
let filePath = path.join(DIST_DIR, url.pathname === '/' ? 'index.html' : url.pathname)
if (!fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
filePath = path.join(DIST_DIR, 'index.html') // history 路由兜底
}
// 读文件按 MIME 类型返回,细节(MIME 表、缓存头)在下篇完整给出
})
const win = new BrowserWindow({ /* webPreferences: contextIsolation 等 */ })
win.loadURL('app://IPESAgent/index.html')
})
页面里 axios 的 baseURL='/IPESAgent-api'、router 的 createWebHistory('/IPESAgent/')、js-cookie 的 Admin-Token------一行都不用改,它们看到的环境和 Nginx 部署时一模一样 。这就是这条路线最大的价值:桌面端改造不侵入业务代码。
为什么用 url.hostname 区分两个分支而不是看 pathname 前缀?因为 app://IPESAgent/index.html 和 app://IPESAgent-api/login 的权威部分(host)天然不同 ,恰好对应 Nginx 里两条 location;如果只注册 app 一个 host、靠 pathname 前缀判断,/IPESAgent-api 这个前缀会被前端 base 和 API 前缀同时占住,判断逻辑容易写串。用 host 承载"服务标识"、用 pathname 承载"资源路径",这个约定能把协议处理器的分支写得又短又稳。
3.4 三条路线怎么选:先看项目"绑定了什么"
对比表如下,选型公式在表后:
| 对比维度 | file:// | 本地 HTTP | 自定义协议 app:// |
|---|---|---|---|
| 前端改动量 | 大(base、hash 路由、绝对 API、token 存储都要动) | 无 | 无 |
| history 路由刷新 | 需改 hash 模式 | 需自己写 fallback | 天然支持 |
| Cookie | 基本不可用 | 正常 | 注册 standard + secure 后正常 |
| 端口管理 | 无 | 要管 | 无 |
| 集成复杂度 | 低,但修白屏的坑很高 | 中 | 中 |
选型公式:只要前端项目同时存在「绝对 base + history 路由 + 相对 API 前缀 + Cookie 存储」这四样中的任意两三样,自定义协议就是唯一零改动方案。 若依恰好四条占满。
这张表值得每次做选型前翻出来对照一遍:选型错误的代价不是在写代码阶段付的,而是在上线之后改不动的时候付的。
如果项目是 hash 路由 + 绝对地址 API + localStorage,file:// 也不是不能凑合------但没必要,同等工作量下自定义协议更稳。三条路线的取舍关系如图3所示。

图3 三条加载路线对比图
四、Vue3 + Vite 项目里的 Electron 工程化
4.1 工程形态选型
三个主流选择:electron-vite 全家桶、vite-plugin-electron 插件式、纯手写脚本。对比一下:
- electron-vite:独立的构建体系,主进程 / preload / 渲染进程三个构建目标一视同仁,适合从零搭桌面项目。
- vite-plugin-electron:挂在现有 Vite 配置上的插件,main / preload 作为额外入口,开发时由 Vite dev server 同时构建主进程代码并拉起 Electron 窗口,改主进程还能自动重启。
- 纯手写:electron 入口用 esbuild / tsc 单独编译,加 npm-run-all 串流程。灵活但样板代码多。
我选 vite-plugin-electron ,理由直接:对若依这种"已有成熟 Vite 工程、只想加一层桌面壳"的项目,**侵入性最小**是第一位。整个前端工程的构建、代理、环境变量全部原样保留,Electron 只是多挂了两个入口。
4.2 插件配置骨架
js
// vite.config.js ------ 在现有配置上挂 Electron 入口,用环境变量开关
import electron from 'vite-plugin-electron'
// 是否以桌面端模式启动(npm run dev:electron 时置为 true)
const isElectron = process.env.VITE_ELECTRON === 'true'
export default defineConfig(({ mode, command }) => {
return {
plugins: [
...createVitePlugins(env, command === 'build'),
// 桌面端模式才挂载:只构建 main 一个入口,preload 不参与构建
...(isElectron ? [electron([{ entry: 'electron/main.js' }])] : [])
]
}
})
三个细节值得说透。一是环境变量开关 :纯 Web 开发时 npm run dev 完全不受影响,只有 dev:electron 脚本(内部就是 set VITE_ELECTRON=true&& vite)才会拉起桌面窗口。二是 preload 为什么不进插件 :preload 保持 CJS(.cjs),由主进程用 app.getAppPath() 直接引用源文件路径、完全不参与构建------若依项目的 package.json 是 "type": "module",main 用 ESM 语法交给 Vite 构建,preload 用 CJS 原地待命,这个分工能避开 ESM 工程接入 Electron 的两个连环坑(preload 的 ESM 受 sandbox 限制;Vite 会把 new URL('xx.cjs') 当资产内联成 data: URL,见第七章)。三是插件版本 :本项目实测用 vite-plugin-electron 0.29.1 ,最新版 1.1.x 与 Vite 6 不兼容(构建时 rollup 报 Unknown input options: platform),升级到 Vite 7+ 才能用 1.1.x。
改动就这么多。配套的目录形态:
项目根目录
├── electron/ # 新增:桌面端主进程与 preload
│ ├── main.js # ESM 语法,走 Vite 构建
│ └── preload.cjs # CJS 语法,主进程直接引用,不参与构建
├── src/ # 若依前端,原样不动
├── dist/ # 构建产物
├── vite.config.js # 加 vite-plugin-electron
└── package.json # 加 main 字段、dev:electron 脚本
4.3 开发与打包两条链路
开发模式 :npm run dev 起 Vite dev server,窗口 loadURL('http://localhost:5173'),热更新照常。打包模式 :npm run build 产出 dist,窗口走自定义协议加载本地产物,再用 electron-builder 把主进程代码 + dist + 依赖打成安装包。
两条链路共用同一份前端代码,差异只在主进程里"加载什么 URL"这一行:
js
// 主进程里判断开发/生产的经典写法
if (process.env.NODE_ENV === 'development') {
win.loadURL('http://localhost:5173') // 开发:走 Vite dev server
} else {
win.loadURL('app://IPESAgent/index.html') // 生产:走自定义协议 + dist
}
开发模式下窗口白屏,第一件事不是查代码,是核对 dev server 真的在 5173 上 :端口被占用时 Vite 会静默换端口(本文项目实测从 80 被顶到 82),loadURL 写死的端口就成了死链接。窗口里按 Ctrl+Shift+I 开 DevTools,Network 面板一眼就能看到资源是从哪个地址请求失败的。本文项目现已把端口固定为 5173 + strictPort(见第六章),冲突时直接报错。
链路关系如图4所示。

图4 开发与打包双链路图
4.4 electron-builder 打包配置
打包阶段的配置集中在 package.json 的 build 字段,一个内网管理系统常用的最小配置:
json
{
"build": {
"appId": "com.example.ruoyi-desktop",
"productName": "若依桌面端",
"directories": { "output": "release" },
"files": ["electron/**", "dist/**"],
"win": {
"target": [{ "target": "nsis", "arch": ["x64"] }],
"icon": "build/icon.ico"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true
}
}
}
产物形态按需选,四种常见目标:
| target | 形态 | 适用场景 |
|---|---|---|
| dir | 免安装目录 | 内网直接拷贝分发、测试阶段 |
| portable | 单文件 exe | 免安装但要好分发 |
| nsis | 安装向导 | 正式交付,写注册表、建桌面快捷方式 |
| zip | 压缩包 | 配合内部推送工具 |
files 字段要特别注意 :默认会把整个项目目录打进去(含 node_modules),显式声明 ["electron/**", "dist/**"] 能让包体干净很多。打包产物体积以 60--100MB 量级为正常预期,别看到 80MB 就以为配置错了。
应用图标也别漏:build/icon.ico 建议准备 256×256 含多尺寸的 ico 文件,缺图标时 electron-builder 会用 Electron 默认图标打包------装出来的应用顶着别人的 Logo,内网分发时非常尴尬。图标属于"不做也能跑、做了才像产品"的那类配置。
4.5 版本矩阵(实测)
截至本文撰写,npm 上的实测最新版本:
| 包 | 实测最新版 | 用途与建议 |
|---|---|---|
| electron | 45.0.0 | 桌面运行时,锁死具体版本,别用 ^ 前缀 |
| electron-builder | 26.18.0 | 打包主力(nsis / portable / dir) |
| vite-plugin-electron | 最新 1.1.2 ,本项目用 0.29.1 | dev 联动、主进程热重启;1.1.x 实测与 Vite 6 不兼容(见第七章) |
| @electron-toolkit/utils | 4.0.0 | is.dev 等小工具,可选 |
开发机 Node 版本:electron 45 的 npm 安装器实测要求 Node ≥ 22.12.0 (在 Node 20.18.0 上安装时二进制下载失败,报 ERR_REQUIRE_ESM,细节见第七章)。本文项目已用 nvm 切到 Node 24.12.0 。注意这个约束来自 electron 的安装器,不是来自 Vite------Vite 6 对 Node 18/20/22/24 都支持,但装了 electron 45 之后,Node 下限由 electron 决定。
五、动手前的环境准备
5.1 Node 与镜像源
Node 升级用 nvm-windows 一步到位(安装后重开终端再操作):
bash
nvm install 24
nvm use 24
node -v # 预期 v24.x.x(electron 45 的安装器要求 Node ≥ 22.12.0,实测 20.18.0 会装失败)
npm -v
bash
# 国内强烈建议先切镜像:Electron 二进制 100MB+,默认源极易下载超时
npm config set registry https://registry.npmmirror.com
# Electron 二进制镜像建议写进项目 .npmrc,一劳永逸:
# electron_mirror=https://repo.huaweicloud.com/electron/
# 本机实测 npmmirror 的二进制 CDN 被限速到 0.02MB/s,
# 华为云镜像直连约 1MB/s;哪个快用哪个,以实测为准
npm install
5.2 若依项目现状核对清单
动手前我把实践项目的现状逐项核对了一遍,实测结果如下表------这 6 项里每一项都影响桌面端的实现方式,下篇会逐条落地:
| 核对项 | 本项目实测值 | 对桌面端的影响 |
|---|---|---|
| dev 端口 | 已改为 5173 + strictPort: true(修正前:配置 80 被同机两个前端挤到 82) | 端口漂移会导致 Electron loadURL 写死端口后白屏,必须固定 |
| 后端地址 | 192.168.x.x:8040,接口在根路径 / |
API 分支转发目标,前缀必须剥掉 |
| 登录验证码 | captchaEnabled: true |
联调时每次登录都要看验证码,建议让后端临时关掉 |
| 构建 base | /IPESAgent/(绝对路径) |
file:// 必白屏,桌面端必须走自定义协议 |
| token 存储 | Cookie(Admin-Token,js-cookie) |
自定义协议必须注册 standard + secure 才能正常读写 Cookie |
| 动态菜单 | 后端 getRouters 下发,前端按路径匹配 views |
桌面端不改变此机制;菜单空白是下篇重点排查项 |
整体目标架构如图5所示。

图5 若依桌面端目标架构图
5.3 最小 Electron 骨架:先把窗口点亮
上篇先不碰若依,用一个最小骨架把工具链验证通。这一步专门用来排除环境问题 ,别一上来就集成若依,出问题没法定位。如果你是在真实项目里跟着本系列落地,这份骨架已经按第六章接进了若依项目(electron/ 目录),装完依赖直接跑 npm run dev:electron 就能看到效果。
json
// package.json
{
"name": "electron-hello",
"version": "1.0.0",
"main": "main.js",
"scripts": { "start": "electron ." },
"devDependencies": { "electron": "45.0.0" }
}
js
// main.js
const { app, BrowserWindow } = require('electron')
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
contextIsolation: true,
nodeIntegration: false
}
})
win.loadFile('index.html')
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
html
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Electron Hello</title>
</head>
<body>
<h1>Electron 桌面端骨架运行成功</h1>
</body>
</html>
5.4 验证工具链
bash
npm install
npm start
看到标题为 "Electron Hello" 的窗口,说明 Node → npm → Electron 二进制 → 窗口创建 整条链路通了。三个最常见的失败点提前说:窗口没弹出来多半是 electron 二进制没下载成功(看 5.1 镜像);npm install 报权限错误多半是 registry 没切;报了 Node 版本相关错误就是 5.1 的 nvm 没生效。每解决一个都重跑 npm start,一次只动一个变量。
验证通过后不要急着往下堆功能。这套骨架在若依项目里的落地版本(vite-plugin-electron 接入、dev:electron 脚本、固定端口)见第六章;下篇的正式改造------挂协议处理器、接登录链路、打安装包------就从这个状态继续。
到这里,上篇的目标全部达成:方案定了(自定义协议)、模型懂了(三进程)、工具链验证了(窗口亮起来)。剩下的就是把若依装进去,那就是下篇的事。
六、上篇落地的项目实际改动
按"理实结合"的原则,上篇的方案没有停在纸面上,我把它落进了真实的若依 Vue3 项目。本章记录改了什么、为什么改、验证结果是什么,你可以在自己的项目里逐条对照复现。
6.1 改动文件清单
| 文件 | 改动 | 对应章节 |
|---|---|---|
package.json |
新增 main 字段、dev:electron 脚本;devDependencies 增加 electron / vite-plugin-electron |
4.5、5.1 |
vite.config.js |
端口固定 5173 + strictPort、关闭自动开浏览器、条件挂载 electron 插件、清理 ELECTRON_RUN_AS_NODE | 4.2、4.3、第七章 |
.npmrc |
新增:electron 二进制镜像(install.js 卡死根治法) | 5.1、第七章 |
electron/main.js |
新增:最小主进程骨架(窗口 + IPC + dev 链路加载) | 5.3 |
electron/preload.cjs |
新增:contextBridge 白名单暴露 getVersions | 2.2、2.3 |
src/** |
零改动 | 3.3 |
前端业务代码一行没动。 这正是第三章选自定义协议路线的意义所在------上篇阶段连 dist 打包都还没用到,先把"工具链 + dev 链路"跑通,业务层完全无感。
6.2 关键改动内容
package.json 的三处:
json
{
"main": "dist-electron/main.js",
"scripts": {
"dev": "vite",
"dev:electron": "set VITE_ELECTRON=true&& vite"
},
"devDependencies": {
"electron": "45.0.0",
"vite-plugin-electron": "0.29.1"
}
}
vite.config.js 的三处(完整配置在 4.2 节):
js
import electron from 'vite-plugin-electron'
const isElectron = process.env.VITE_ELECTRON === 'true'
// 1. 插件:仅桌面端模式挂载(preload 不挂进插件,由主进程直接引用源文件)
plugins: [
...createVitePlugins(env, command === 'build'),
...(isElectron ? [electron([{ entry: 'electron/main.js' }])] : [])
],
// 2. 端口:固定 + 冲突报错
server: {
port: 5173,
strictPort: true,
host: true,
open: false // 桌面端由 Electron 窗口承载页面
}
electron/main.js(完整可跑版本):
js
import { app, BrowserWindow, ipcMain } from 'electron'
import path from 'node:path'
// 开发地址:优先取 vite-plugin-electron 注入的 VITE_DEV_SERVER_URL,
// 兜底固定 5173(与 vite.config.js 的 server.port 一致)
const DEV_URL = process.env.VITE_DEV_SERVER_URL || 'http://localhost:5173'
// preload 是 CJS 源文件、不参与构建:
// app.getAppPath() 开发模式=项目根目录,打包模式=app.asar 根目录,两种模式都能命中。
// 别用 new URL('./xx.cjs', import.meta.url):Vite 会把 .cjs 当资产内联成 data: URL
const PRELOAD_PATH = path.join(app.getAppPath(), 'electron', 'preload.cjs')
// IPC 链路验证:渲染进程可经 preload 获取内置版本信息
ipcMain.handle('app:get-versions', () => ({
electron: process.versions.electron,
chromium: process.versions.chrome,
node: process.versions.node
}))
function createWindow() {
const win = new BrowserWindow({
width: 1280,
height: 800,
show: false, // 先隐藏,渲染就绪再显示(对应 2.1 的白屏优化)
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
preload: PRELOAD_PATH
}
})
win.once('ready-to-show', () => win.show())
win.webContents.on('did-finish-load', async () => {
console.log('[electron] 页面加载完成:', win.webContents.getURL())
console.log('[electron] 页面标题:', await win.webContents.executeJavaScript('document.title'))
})
win.loadURL(DEV_URL) // 开发链路:加载 Vite dev server
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
electron/preload.cjs:
js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('desktopAPI', {
getVersions: () => ipcRenderer.invoke('app:get-versions')
})
注意两个格式细节:若依项目的 package.json 是 "type": "module",所以 main 用 ESM 语法(import)、由 Vite 构建成 dist-electron/main.js ;而 preload 用 .cjs 后缀保持 CommonJS,不进插件、不参与构建 ,由主进程用 app.getAppPath() 拼路径直接引用源文件------preload 的 ESM 支持受 sandbox 限制,CJS 最稳。main 走构建、preload 走直引,这个分工记住就行。
6.3 实测验证
实测环境:Node 24.12.0 / npm 11.6.2 / Windows。首次安装时在 Node 20.18.0 上失败(原因与解法见第七章注意事项),切换 Node 并加 .npmrc 镜像后通过。安装与启动:
bash
# 1. 项目根目录加 .npmrc,写死 Electron 二进制镜像(免去每次设环境变量)
# electron_mirror=https://repo.huaweicloud.com/electron/
# 2. 安装依赖
npm install --save-dev electron@45.0.0 vite-plugin-electron@0.29.1
# 3. 验证二进制真的下载成功(install.js 卡死时 npm 不报错)
npx electron --version # 预期 v45.0.0
# 4. 桌面端模式启动
npm run dev:electron
真实终端输出(关键行):
VITE v6.4.1 ready in 1066 ms
➜ Local: http://localhost:5173/
dist-electron/main.js 1.45 kB │ gzip: 0.83 kB
[electron] 窗口已显示
[electron] 页面加载完成: http://localhost:5173/
[electron] 页面标题: 知识产权导航监控与企业资质全景研判智能体平台
验证了三件事:dev server 起在 5173 而不是 80/82 (端口修正生效);Electron 窗口自动弹出并加载成功 (页面标题是若依系统标题);preload/IPC 链路可用 ------窗口里按 Ctrl+Shift+I 打开 DevTools,Console 执行 await window.desktopAPI.getVersions(),返回 { electron: "45.0.0", chromium: "...", node: "..." }。
一个容易误判的点:本次实测日志里同时出现了 http proxy error: /captchaImage → ECONNREFUSED 192.168.x.x:8040。这是后端内网机当时未开机 导致的,页面本身(HTML/JS/CSS)全部来自本地 Vite,加载不受影响,只有验证码图区域是空的------环境问题不是代码问题,后端恢复后刷新页面即可,别当 bug 排查。
还有一次报错同样值得记录:某次启动时终端直接抛 Error: Port 5173 is already in use ------原因是上一个 dev 进程还占着 5173。strictPort: true 按设计直接报错退出,而不是像从前那样悄悄换端口,看到它不用慌,把占用 5173 的旧进程或旧窗口关掉重启即可------这条报错恰恰证明端口修正生效了。
6.4 上篇结束时你能看到的页面效果
上篇结束后,真实可见的效果是一个 Electron 桌面窗口里跑着完整的若依前端:登录页、验证码、登录跳转、菜单显示,和浏览器里完全一致------因为走的就是 Vite dev 链路(4.3 的开发分支)。可以截两张图放进文章(如图6所示):
这里澄清一个高频误解:桌面窗口不是"跑在命令提示符里"的 。终端窗口只是整条 dev 链路的控制台和父进程 ------它承载 Vite dev server(5173)与主进程日志,vite-plugin-electron 在里面构建完 dist-electron/main.js 后,用 electron.exe 拉起真正的桌面进程,界面由 Electron 自带的 Chromium 渲染在独立的系统窗口里 (所以才有窗口边框、任务栏图标、能直接 Win+Shift+S 截屏)。两个窗口是父子进程关系:关掉终端窗口 = 整条链路全停 ;只关桌面窗口则 dev server 还活着,想再开窗口就重新执行一次 npm run dev:electron。

窗口内按 Ctrl+Shift+I 打开 DevTools,Console 输入 allow pasting 后,执行 await window.desktopAPI.getVersions() 的返回结果,佐证 preload/IPC 链路。


图6 Electron 窗口运行若依前端(dev 链路实测)
七、注意事项
这一章按"因为......所以......"的逻辑写,每条都是实际会撞上的坑。
file:// 白屏的排查顺序。 白屏先开 DevTools 看 Network:资源 404 说明绝对路径问题(原因一);报 Access to script ... from origin 'null' has been blocked by CORS policy 说明模块拦截(原因二);页面能开但刷新 404 说明 history 缺 fallback(原因三)。三个原因对应的现象不同,别混着排查。
自定义协议的四个特权项缺一不可。 standard 决定路径按 http 语义解析;secure 决定页面是安全上下文(剪贴板、部分媒体 API 依赖它,Cookie 也要求它);supportFetchAPI 让页面 fetch 能走该协议;stream 决定资源以流式返回。protocol.handle 的 API 转发分支里,net.fetch 必须传 bypassCustomProtocolHandlers: true,否则请求会被自己的协议处理器再拦一次,表现为接口一直 pending。这个坑我踩过一次,表现特别隐蔽:页面不报错、Network 里请求一直转圈、后端日志也看不到请求进来。
Electron 二进制下载失败(实测卡死十分钟)。 这次在真实项目里遇到了升级版:electron 45 的安装器 @electron/get 5.x 已经改版,ELECTRON_MIRROR 环境变量在本机没有生效,install.js 卡在下载环节十分钟没有任何文件落地。根治法是在项目根目录加 .npmrc 写死镜像(@electron/get 会读这份配置):
ini
# .npmrc ------ 实测 npmmirror 二进制 CDN 被限速时,换华为云镜像
electron_mirror=https://repo.huaweicloud.com/electron/
终极兜底是手动下载 zip 解压到 node_modules/electron/dist 并写 path.txt。装完必跑一次 npx electron --version 验证二进制真的在------install.js 卡死时 npm 不报错,不验证就是带着雷往下走。
还有个隐蔽诱因值得单独说:本机配置了网络代理时,Node 系下载器默认不读取系统级设置,只认终端里的环境变量,所以会出现"浏览器能下、命令行卡住"的情况。 。实测同一条 npmmirror 链接:浏览器/系统代理走 7892 时秒开,curl 和 @electron/get 直连只有 0.03MB/s,158MB 的包等于永远下不完。开着代理工具的同学,安装前给当前终端设置 HTTPS_PROXY / HTTP_PROXY 指向本地代理端口,再执行 npm install。
vite-plugin-electron 版本与 Vite 的匹配(实测)。 插件最新版 1.1.x 内部会给构建配置注入 platform: "node"(这是 rolldown 的选项),在 Vite 6(rollup 内核)下构建主进程直接报 Unknown input options: platform。Vite 6 项目用 0.29.1,Vite 7+ 才用 1.1.x。装依赖前先想清楚这两个的版本搭配,报错信息里没有半个字提到版本问题,很容易查错方向。
Vite 会把 .cjs 当资产内联(实测)。 主进程里写 new URL('./preload.cjs', import.meta.url) 会被 Vite 构建时内联成 data: URL ,运行时报 ERR_INVALID_URL_SCHEME: The URL must be of scheme file。正确写法是 path.join(app.getAppPath(), 'electron', 'preload.cjs')------开发模式 getAppPath() 是项目根目录,打包后是 app.asar 根目录,两种模式通吃。凡是"构建产物里要按路径找的文件",都别用 URL 构造器去引。
ELECTRON_RUN_AS_NODE 环境变量(隐蔽坑)。 部分 CI / 自动化环境会设置 ELECTRON_RUN_AS_NODE=1,此时 electron.exe 以纯 Node 模式运行,主进程里 import { BrowserWindow } from 'electron' 报 The requested module 'electron' does not provide an export named 'BrowserWindow',窗口永远起不来。表现和"装错了包"一模一样 。自查一条命令:echo $env:ELECTRON_RUN_AS_NODE,有值就在启动脚本或 vite.config.js 里清掉。
版本锁死与漂移。 electron 用 ^ 前缀会随次版本漂移,打包机与开发机装出不同大版本时行为不一致。electron、electron-builder 一律写死精确版本,升级时显式改版本号、跑一遍回归。
Node 版本下限(实测踩坑)。 electron 45 的安装脚本依赖 @electron/get 5.x,这个包已经是纯 ESM 且声明 Node ≥ 22.12.0 。在 Node 20.18.0 上 npm install electron@45.0.0 会"装完"(只给 EBADENGINE 警告),但二进制根本没下载,运行时报 ERR_REQUIRE_ESM 和 "Electron failed to install correctly"。解法:nvm 切到 22.12+(本项目实测用 24.12.0 通过),删掉 node_modules/electron 重装。版本下限由 electron 的安装器决定,别只盯着 Vite 的要求看。
端口冲突。 本文项目原本 server.port: 80 没设 strictPort,同机三个前端把端口顶到 80/81/82。Electron 开发模式 loadURL 写错端口就是白屏,而且没有明显报错。修正后(5173 + strictPort)再遇端口占用会直接抛 Error: Port 5173 is already in use ------这是预期行为 ,把占着 5173 的旧进程或旧窗口关掉重启即可。dev 端口固定 + strictPort: true,冲突时直接报错,好过悄悄换端口(本文项目已按第六章修正)。
验证码拖慢联调。 若依后端 sys.account.captchaEnabled 默认开启,联调阶段每次登录都要看图输码,自动化测试也做不动。桌面端开发期间建议让后端临时关掉,上线前再开回。
单实例锁。 用户双击图标两次会起两个桌面应用实例,内网系统里这是最常见的"不知道为什么有两个窗口"。主进程开头加 app.requestSingleInstanceLock(),拿不到锁就退出,并在 second-instance 事件里把已有窗口聚焦到前台。几行代码,解决一个高频体验问题。
asar 打包与路径。 electron-builder 默认把应用资源打进 app.asar 压缩包,主进程代码里 __dirname 依然有效 (Electron 的 fs 透明支持 asar 内路径),但不要在打包后往应用目录写文件 ------那是只读资源区。运行期要落盘的(配置、缓存、日志)统一写 app.getPath('userData')。
内存预期管理。 Electron 常驻内存典型值 150--300MB(Chromium 多进程),这是架构决定的,不是 bug。管理系统场景下完全可接受;如果目标机器内存紧张到 2GB 以下,才需要回头考虑 Tauri。
中文路径与空格路径。 electron-builder 打包对输出目录里的中文 / 空格敏感,出现过产物目录乱码和打包失败的案例。项目路径和打包输出目录尽量保持纯英文无空格(本文项目路径满足这一点)。
杀毒软件与 SmartScreen。 未签名的 Electron 产物在 Windows 上会触发 SmartScreen"未知发布者"提示,个别国产安全软件对 electron-builder 产物误报也时有发生。正式分发前申请代码签名证书,内网环境至少要做白名单报备------这不是技术 bug,但用户体验上它就是 bug。
老机器渲染花屏。 个别内网老机器的显卡驱动有兼容问题,表现为窗口花屏、字体碎裂或窗口内容不刷新。主进程开头调 app.disableHardwareAcceleration() 可以一招解决,代价是渲染性能回落到软件合成,管理系统场景完全够用。这个开关建议做成可配置项,新机器不关、老机器关掉。
app:// 下的跨域请求。 自定义协议页面里如果还有访问第三方资源的场景(比如页面里嵌了外部图片、外部 iframe),记得把 corsEnabled: true 加进 privileges------否则页面发起的跨源请求会被浏览器安全策略拦住,报错信息和普通 CORS 拦截一模一样,很容易查错方向。
排查入口要提前留。 开发阶段在 BrowserWindow 配置里开 devTools: true,快捷键 Ctrl+Shift+I 始终可用;主进程日志用 console.log 即可,开发模式下直接进终端,打包模式下加 --enable-logging 参数落日志文件。桌面端的 bug 大多出在"页面看不到的地方",没有 DevTools 和日志等于盲修。
跨平台窗口行为差异。 window-all-closed 之后 macOS 惯例是不退出应用(用户点 Dock 重新激活),Windows / Linux 才退出。写死退出逻辑,macOS 打包版就会出现"关了窗口应用还活着"的怪异行为------加 process.platform !== 'darwin' 判断即可(5.3 骨架里就是这么写的)。内网系统暂时只出 Windows 版也建议保留这个判断,省得将来补平台时返工。
Electron 版本对照查询。 选版本前先到官方 Releases 页看该版本内置的 Chromium / Node 版本号。前端依赖对 Chromium 有下限要求时(比如某些组件库要求 Chrome 100+),版本对照表就是硬约束,不是参考信息。反过来,想用新 Node 特性写主进程,也得先确认目标 Electron 内置的 Node 版本够不够。
安全基线。 contextIsolation: true、nodeIntegration: false、不加载远程页面、preload 只暴露白名单 API------这四条是桌面应用的默认安全底线,与"页面是否可信"无关,与"被攻击后的爆炸半径"有关。
八、本文总结
回到开头的问题:前端项目怎么变成桌面应用。这篇的结论可以压缩成四句判断,直接带走:
- 管理系统桌面化首选 Electron。要的是"环境一致性"和"前端复用",不是极致体积;这两个诉求 Electron 给得最彻底。
- 加载方式选自定义协议。只要项目同时绑定了绝对 base、history 路由、相对 API 前缀、Cookie 存储中的任意两三样,file:// 就是死路,本地 HTTP 是绕路,自定义协议是唯一零改动方案------它本质是把 Nginx 的 location 用代码复刻进主进程。
- 开发期走 dev server,打包期走协议 + dist,两条链路共享同一份前端代码,差别只在"加载什么 URL"一行。
- 什么时候不该选 Electron:目标机器内存极度紧张(2GB 以下)选 Tauri;纯展示壳不想维护主进程代码可以选 NW.js 凑合;团队是 .NET 技术栈用 WPF + CEF。选择永远跟着约束走,不跟着潮流走。
九、更多操作
更多前端实战内容,请看 Vue 个人专栏。
本文属于 Vue 企业级实战系列,持续更新 Vue2/Vue3、工程化、性能优化、跨域解决方案等干货,欢迎关注我的 CSDN 专栏:
本专栏聚焦 Vue(Vue2/Vue3)企业级实战开发。涵盖组件封装、组合式 API、前端工程化、状态管理、性能优化、项目架构,搭配 Element、ECharts 业务实战。汇集真实项目开发经验与避坑方案,帮你搭建完整 Vue 技术体系,持续更新硬核实战内容。
本系列的下一篇文章《若依管理系统桌面端应用(Electron 桌面应用实战)》会把这篇文章的全部结论落到真实项目上:自定义协议完整实现(含 MIME 表与缓存头)、Cookie 与登录链路在桌面端的处理、动态菜单空白排查、electron-builder 打包出 nsis 安装包,全部步骤照做即可复现。
延伸阅读与官方文档:
- Electron 官方文档:https://www.electronjs.org/docs
- electron-builder 文档:https://www.electron.build
- vite-plugin-electron:https://github.com/electron-vite/vite-plugin-electron
- electron-vite:https://electron-vite.org
- 若依官方文档:http://doc.ruoyi.vip
如果本文对你有帮助,欢迎点赞、收藏、评论,你的支持是我持续输出实战干货的动力!
如果你在项目中也遇到类似问题,欢迎留言交流,分享你的场景与解决方案。