Vue 106 ,上篇:前端桌面端应用实现方案( Electron 框架详细介绍,Vue + Electron )

前言

这篇解决一个问题:把一个 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 Develop 实战专栏

本专栏聚焦 Vue(Vue2/Vue3)企业级实战开发。涵盖组件封装、组合式 API、前端工程化、状态管理、性能优化、项目架构,搭配 Element、ECharts 业务实战。汇集真实项目开发经验与避坑方案,帮你搭建完整 Vue 技术体系,持续更新硬核实战内容。

本系列的下一篇文章《若依管理系统桌面端应用(Electron 桌面应用实战)》会把这篇文章的全部结论落到真实项目上:自定义协议完整实现(含 MIME 表与缓存头)、Cookie 与登录链路在桌面端的处理、动态菜单空白排查、electron-builder 打包出 nsis 安装包,全部步骤照做即可复现。

延伸阅读与官方文档:

如果本文对你有帮助,欢迎点赞、收藏、评论,你的支持是我持续输出实战干货的动力!

如果你在项目中也遇到类似问题,欢迎留言交流,分享你的场景与解决方案。

相关推荐
柳杉1 小时前
别再手抠特效帧了:一个能"看图改图"的像素特效库 + Claude Code Skill
前端·javascript
青少儿编程课堂1 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会C/C++考级:分层图最短路解析
c++·python·算法·bfs·信息学竞赛
前端小D2 小时前
React Native项目开发(iOS侧为例)
javascript·react native·react.js
xixingzhe22 小时前
本地桌面客户端入门开发
前端
不磕了还不行2 小时前
用 Vue + Element UI 把元宝搜索的返回值做成“能点开检查的证据卡片”
前端·vue.js·ui
全栈练习生2 小时前
大模型原理之自适应推理
python·ai
实名上网宋凯宣10 小时前
Django
后端·python·django
Csvn11 小时前
WASM
前端