1. 方案选型:UniApp 为什么不能直接吐出一个 exe
先说结论,省得有人翻到一半才发现方向错了:UniApp 打包项目为桌面端 exe 文件,官方并没有一条一键命令。你在 HBuilderX 的"发行"菜单里翻遍,能看到 App、小程序、H5、快应用,唯独找不到"Windows 桌面应用"。这不是版本没更新,而是这套框架的编译目标里压根就没有原生桌面端这一项。
我最早接这类需求是在一个内部管理系统上,前端用 UniApp 写了一套,移动端跑得挺顺,结果甲方临时提了个要求:车间那几台工控机也要用,而且必须是双击就能开的 exe,不许装浏览器、不许配环境。当时我的第一反应是找官方文档,翻了半天确认没有原生方案,第二反应才是——那就把 H5 产物套个壳。
这个思路其实很朴素。UniApp 编译到 H5 之后,产物就是一堆标准的 HTML、CSS、JS 静态文件,跟用 Vue 或者 React 写出来的网页没有本质区别。既然是网页,那所有能把网页变成桌面应用的技术路线就都能用。所谓"打包成 exe",准确的表述是:把 UniApp 的 H5 构建产物,装进一个自带浏览器内核的桌面容器里,再把这个容器编译成 Windows 可执行文件。
1.1 UniApp 各端编译产物的真实形态
要理解为什么只能套壳,得先搞清楚 UniApp 到底编译出了什么。这套框架的本质是一套源码、多套编译器,你写的.vue文件在构建时会走不同的编译管线,产出完全不同的东西。
编译到 App 端时,产物是 JS Bundle 加原生渲染层的组合,运行在 DCloud 自己的原生容器里,所以能调用摄像头、蓝牙、NFC 这些系统能力。编译到小程序端时,产物是小程序规范的wxml、wxss、js,交给各平台的宿主环境去跑。而编译到 H5 端时,走的是最标准的一条路——Vue 的 Web 编译流程,最后吐出来一个index.html加上static/目录,里面是压缩过的 js 和 css。
这三者的差别,决定了它们能被"再加工"的程度。App 端的产物依赖原生容器,你没法把它塞进别的壳里;小程序产物依赖宿主;只有 H5 产物是纯粹的标准 Web 资源,浏览器能跑,任何 Chromium 内核的容器也都能跑。所以桌面端这条路,唯一的入口就是 H5。
这里有个认知偏差要纠正一下。很多人以为"套壳"是低端做法,觉得不如原生。实际在企业内部工具、数据看板、工控界面这类场景里,套壳反而是性价比最高的选择:一套代码维护,移动端和桌面端共用业务逻辑,样式复用率能到八成以上,只有涉及系统能力的那部分需要单独写。你要是真用 C# 或者 Qt 重写一遍,工期至少翻三倍,后续改需求还得改两处。这笔账怎么算都不划算。
1.2 四种桌面壳方案的横向对比
能做这件事的容器不止一个,我前后试过四种,各有各的脾气。下面这张表是我自己踩完坑之后整理的,参数都是实测值,不是抄文档。
| 方案 | 内核 | 空壳安装包体积 | 最低系统要求 | 原生能力 | 上手难度 | 适用场景 |
|---|---|---|---|---|---|---|
| Electron | 自带 Chromium + Node.js | 约 75–130 MB | Win7 SP1 及以上 | 极强,Node 生态全可用 | 低 | 企业工具、需要文件/打印/串口 |
| Tauri | 系统 WebView2 | 约 3–10 MB | Win10 1803 及以上 | 较强,但需写 Rust 或 sidecar | 中高 | 轻量展示、追求小体积 |
| NW.js | 自带 Chromium + Node.js | 约 90–140 MB | Win7 及以上 | 强,与 Electron 类似 | 低 | 老项目迁移 |
| CEF 自编译 | 自带 Chromium | 约 60 MB 起 | 视编译配置 | 强,但需 C++ 功底 | 高 | 有定制内核需求的团队 |
Electron 的优势在于"什么都不用想"。它把 Chromium 和 Node.js 一起打进安装包,用户机器上不需要任何前置依赖,双击就开。代价就是体积,一个空项目打出来 70 多 MB 起步,加上业务代码轻松破百。内存占用也不客气,单窗口跑起来 150 MB 到 300 MB 是常态。
Tauri 是最近两年很热的选择,思路是复用系统自带的 WebView2 运行时,所以安装包能压到几 MB。听起来很美,但有两个硬伤:一是 Win7 和 Win8 上装不了新版 WebView2,老设备直接出局;二是你要调系统能力,得写 Rust,或者额外挂一个 Node 的 sidecar 进程,调试链路一下子变长。我在一个只需要展示报表的项目里用过,体验不错;但凡涉及文件批量处理、本地数据库、硬件对接,我都会回到 Electron。
NW.js 和 Electron 是同一时代的东西,API 设计上更偏向"直接在页面里用 Node",但社区活跃度和生态明显弱一截,新项目没有理由选它,除非你手上有个跑了很多年的老工程。
提示:选型时先问三个问题——目标机器最低是什么系统?安装包体积有没有硬性上限?业务里需不需要读写本地文件、调用打印机或者串口?三个答案里只要有一个偏向"老系统""无所谓体积""需要原生能力",直接上 Electron,别犹豫。
1.3 我的选型结论与适用边界
我现在的默认选择是Electron + electron-builder,理由不是它最好,而是它的坑最少、资料最多、出问题能查到答案。企业内网环境尤其如此,你没法指望运维去给几百台机器装 WebView2 运行时,自带内核虽然笨重,但省心。
适用边界也得说清楚。这套方案不适合三类场景:一是对安装包体积极度敏感的分发场景,比如要放到官网上让陌生人下载,一百多兆的包会劝退不少人;二是需要极致启动速度的场景,Electron 冷启动通常要 1 到 3 秒,比原生慢;三是需要深度对接硬件驱动的场景,虽然 Node 能做,但遇到厂商只提供 C++ SDK 的时候会很别扭。
反过来说,如果你的项目是内部管理系统、数据可视化大屏、收银或工位终端、离线可用的业务工具,那这套路子非常稳。我做过一个门店收银的桌面端,跑在几百台机器上两年多,除了偶尔的内存泄漏要靠定时重启兜底,没出过大问题。
2. 先把 H5 产物做对:九成的坑都埋在这一步
很多人卡在 Electron 那一步,折腾半天主进程配置,最后发现白屏的根因在 HBuilderX 打包 H5 时的路径配置上。这一章我建议你逐条对照,别跳。
2.1 manifest.json 里必须改的三个配置项
UniApp 项目根目录下有个manifest.json,里面有个h5节点。默认配置是给 Web 服务器部署用的,直接拿去套壳必翻车。
第一个要改的是运行的基础路径,也就是publicPath。默认值是/,意思是所有资源从域名根目录开始找。这在服务器上没问题,但在 Electron 里页面是通过file://协议加载的,/static/js/app.js会被解析成磁盘根目录下的static文件夹,必然 404。改成./或者空字符串,让资源走相对路径。
{ "h5": { "publicPath": "./", "router": { "mode": "hash", "base": "./" }, "title": "某某业务系统" } }第二个要改的是路由模式。UniApp 支持hash和history两种。history模式好看,地址栏干净,但它依赖服务器把所有路径重写到index.html。桌面端没有服务器,用户按 F5 刷新或者跳转到二级页面之后再刷新,直接就是一个空白页加大大的 404。套壳场景必须用hash模式,地址栏里那个#不影响任何功能,但能救命。
第三个是标题。title写什么,桌面窗口的标题栏就显示什么。这个不改的话窗口标题会是一串英文的项目名,交付时甲方第一眼就会觉得不专业。
如果你用的是 CLI 方式创建的项目,也就是vue-cli那套,那publicPath可能要写在vue.config.js里:
module.exports = { publicPath: './', productionSourceMap: false }productionSourceMap关掉是为了减小体积,顺便防止源码被别人扒出来,虽然不是强安全措施,但聊胜于无。
2.2 构建产物验收:别急着往壳里塞
打包完成之后,产物在unpackage/dist/build/h5目录下。我强烈建议在做任何 Electron 相关操作之前,先单独验收这批文件。
验收方法是起一个本地静态服务,最简单的:
npx serve unpackage/dist/build/h5 -p 5000然后浏览器打开http://localhost:5000,把主要页面走一遍,登录、列表、详情、表单提交、文件上传,全部过一遍。这一步能过滤掉绝大多数业务逻辑问题。
接着做第二步验收,这一步更关键:直接双击index.html用浏览器打开,走file://协议。这时候如果页面能正常显示,说明路径配置是对的。如果白屏,按 F12 看控制台,十有八九是一堆net::ERR_FILE_NOT_FOUND,那就是publicPath没改对。
注意:有些 UniApp 版本对
publicPath的处理不太一致,改完 manifest 之后可能还要检查index.html里 script 标签的 src。如果看到src="/static/js/..."这种带前导斜杠的,说明配置没生效,要么是缓存,要么是配置文件位置不对。清理unpackage目录重新打包一次。
2.3 那些在 H5 里能跑、进壳里就废的 API
UniApp 的 API 有一层跨端封装,同一个uni.xxx在不同端行为不同。H5 端有一批 API 是"降级实现"或者"直接不支持"的,在浏览器里可能只是不生效、不报错,进了 Electron 之后问题会更明显。
典型的有这么几个。uni.chooseImage在 H5 端是唤起<input type="file">,Electron 里也能用,但拿到的路径是blob:或者file://虚拟路径,后端不接受这种格式。解决办法是在壳里重写这个方法,走主进程的原生文件对话框,返回真实磁盘路径。
uni.getSystemInfo在 H5 端返回的是浏览器 UA 解析出来的信息,platform字段会是"windows"之类的,但model是空字符串。如果你的业务代码用model做判断,进壳里就会出问题。
uni.setStorageSync在 H5 端写的是localStorage,容量上限大概 5 MB。Electron 里这个限制依然存在,而且它绑定在用户数据目录下,卸载不干净会有残留。如果项目要存大量数据,趁早换成走主进程的 JSON 文件或者 SQLite。
还有一个容易被忽略的点:uni.request在 Electron 里仍然受同源策略约束。因为渲染进程本质就是 Chromium,浏览器该拦的它一样拦。你本地file://协议发请求,目标服务器如果没有返回Access-Control-Allow-Origin,请求会被直接掐掉。这个问题的解法放在第 4 章展开。
3. 搭一个能用的 Electron 壳工程
H5 产物验收通过,接下来就是壳。这部分我建议从空目录开始搭,别用那些现成的"uni-app 转 electron"模板。模板看着省事,实际用起来版本锁定死、目录结构不透明,出了问题你连从哪查都不知道。
3.1 目录结构与依赖版本
我自己习惯的结构是这样,主进程代码和渲染资源彻底分开:
desktop-shell/ ├── package.json ├── main.js ├── preload.js ├── build/ │ └── icon.ico └── renderer/ └── (把 h5 产物整体放进来)renderer目录就是unpackage/dist/build/h5整个拷贝过来的。这么做的好处是构建脚本可以自动同步,不用每次手动挪文件。
package.json里的依赖只有三个是必须的:
{ "name": "internal-tool-desktop", "version": "1.0.0", "main": "main.js", "scripts": { "start": "electron .", "build:win": "electron-builder --win", "sync": "rimraf renderer && xcopy /E /I ..\\unpackage\\dist\\build\\h5 renderer" }, "devDependencies": { "electron": "28.3.3", "electron-builder": "24.13.3" } }Electron 版本我建议锁在大版本内的具体小版本号,不要用^或者latest。原因很现实:Electron 每个大版本都会跟进 Chromium 的更新,有些版本在特定 Windows 系统上有渲染兼容问题,浮动版本号意味着今天能跑的代码明天重装依赖就跑不了。我吃过这个亏,最后把所有项目的 electron 都钉死在具体版本上。
至于选 28 还是更新的版本,取决于目标系统的下限。28 版还支持 Win7 和 Win8;如果要照顾更老的机器,可能得退到 22 甚至更低。Win10 及以上随便用新版。
3.2 主进程窗口配置的实操细节
main.js是整个壳的入口,我贴一份经过多个项目验证的基础配置:
const { app, BrowserWindow, Menu, ipcMain, dialog } = require('electron') const path = require('path') let mainWindow = null const gotLock = app.requestSingleInstanceLock() if (!gotLock) { app.quit() } else { app.on('second-instance', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.focus() } }) } function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 800, minWidth: 1024, minHeight: 700, show: false, icon: path.join(__dirname, 'build/icon.ico'), webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, sandbox: false } }) Menu.setApplicationMenu(null) mainWindow.loadFile(path.join(__dirname, 'renderer/index.html')) mainWindow.once('ready-to-show', () => { mainWindow.show() }) mainWindow.on('closed', () => { mainWindow = null }) } app.whenReady().then(createWindow) app.on('window-all-closed', () => { app.quit() })几个配置值单独解释一下。
contextIsolation: true和nodeIntegration: false是安全基线,必须这么设。这意味着渲染进程里的页面代码拿不到require,也访问不到 Node 的 API,所有原生能力必须通过preload.js显式暴露。早年很多人图省事把nodeIntegration开成true,结果页面里随便一段第三方脚本就能读写磁盘,风险极大。
show: false配合ready-to-show事件,是为了避免窗口先显示一个白框再刷出内容。不加这个的话,冷启动时会有一两秒的纯白窗口,观感很差。
requestSingleInstanceLock是单实例锁,防止用户重复双击开了五个窗口。second-instance事件里把已有窗口拉到前台,符合用户直觉。
Menu.setApplicationMenu(null)把默认菜单栏干掉。默认菜单里有 DevTools、开发者工具、重新加载这些项,对最终用户来说完全是噪音,而且暴露了技术栈。如果你需要保留调试能力,可以在这里按环境变量判断,开发环境保留菜单,生产环境清掉。
3.3 preload 暴露原生能力的正确姿势
preload.js是渲染进程和主进程之间唯一的桥梁。核心原则只有一条:暴露最小必要接口,不要暴露通用的fs或者child_process。
const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('desktopAPI', { isDesktop: true, saveFile: (defaultName, content) => ipcRenderer.invoke('file:save', defaultName, content), openFile: (options) => ipcRenderer.invoke('file:open', options), print: (options) => ipcRenderer.invoke('printer:print', options), getPrinters: () => ipcRenderer.invoke('printer:list'), request: (options) => ipcRenderer.invoke('net:request', options) })这里用了contextBridge.exposeInMainWorld,把接口挂在window.desktopAPI上。页面代码里直接if (window.desktopAPI) { ... }就能判断当前是不是跑在桌面端,移动端和 H5 端不受影响,这是跨端代码最干净的写法。
为什么不直接把fs暴露出去?因为一旦暴露,页面里任何一个 XSS 漏洞都会变成任意文件读写。而只暴露saveFile这种业务级接口,攻击面就小得多。这个区别就像"给你一把钥匙"和"告诉你钥匙在哪",安全性完全不是一个量级。
4. 原生能力打通:文件、打印、请求代理
H5 端跑在浏览器沙箱里,很多事做不了。进了桌面壳就得补上,这是套壳方案真正的价值所在。下面三个是需求里出现频率最高的。
4.1 文件读写的最小可用闭环
最常见的是导出功能,比如把表格数据导成 Excel 或者 CSV,让用户自己选保存位置。
const fs = require('fs').promises const { ipcMain, dialog } = require('electron') ipcMain.handle('file:save', async (event, defaultName, content) => { const { canceled, filePath } = await dialog.showSaveDialog({ title: '保存文件', defaultPath: defaultName, filters: [ { name: 'Excel 文件', extensions: ['xlsx'] }, { name: 'CSV 文件', extensions: ['csv'] } ] }) if (canceled || !filePath) { return { ok: false, reason: 'canceled' } } try { const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content) await fs.writeFile(filePath, buffer) return { ok: true, path: filePath } } catch (err) { return { ok: false, reason: err.message } } })页面侧调用:
const result = await window.desktopAPI.saveFile('报表.xlsx', fileBuffer) if (result.ok) { uni.showToast({ title: '已保存到 ' + result.path }) }有个细节值得说:把Buffer从渲染进程传给主进程,走 IPC 的时候会自动序列化,几兆的文件没问题,但如果是几十兆的大文件,IPC 传输会有明显卡顿。这种场景建议让主进程自己生成文件,只把参数传过去,别传内容。
心得:
showSaveDialog的defaultPath如果只写文件名,会默认落在用户上次选的目录或者我的文档;如果要指定完整路径,得传绝对路径。另外filters里的扩展名不要带点,写xlsx不写.xlsx,否则某些 Windows 版本下过滤器不生效。
4.2 打印场景:静默打印和打印机枚举
收银、仓储、医院这类场景,打印机是刚需。Electron 的webContents.print能做两件事:弹出系统打印对话框让用户选,或者指定设备名静默打印。
先拿打印机列表:
ipcMain.handle('printer:list', async (event) => { const win = BrowserWindow.fromWebContents(event.sender) const printers = await win.webContents.getPrintersAsync() return printers.map(p => ({ name: p.name, displayName: p.displayName, isDefault: p.isDefault })) })静默打印:
ipcMain.handle('printer:print', async (event, options) => { const win = BrowserWindow.fromWebContents(event.sender) return new Promise((resolve) => { win.webContents.print({ silent: true, printBackground: true, deviceName: options.deviceName, margins: { marginType: 'none' }, pageSize: { width: 80000, height: 60000 } }, (success, failureReason) => { resolve({ success, failureReason }) }) }) })pageSize的单位是微米,80000微米等于 80 毫米,这是热敏小票纸的常见宽度。如果你打的是 A4,就别传这个字段,让系统用默认值。
silent: true意味着直接扔给打印机,不弹框。这个模式下deviceName必须准确匹配,写错一个字符就会静默失败,回调里的failureReason是唯一线索。我建议首次部署时先用silent: false跑一遍,让用户在对话框里确认打印机正常,然后再切静默。
4.3 跨域请求的主进程代理方案
前面提过,uni.request在 Electron 里依然受同源策略限制。有人会去搜"关闭 webSecurity",那是在webPreferences里加webSecurity: false。这招确实管用,一行代码解决所有跨域,但它等于把浏览器的安全防线拆了,任何页面里的第三方脚本都能随便发请求。内部工具风险小一点,但我还是不建议,习惯一旦养成,迟早出事。
正解是在主进程里做代理,用 Node 的net模块发请求,天然没有跨域概念:
const { net } = require('electron') ipcMain.handle('net:request', async (event, options) => { return new Promise((resolve) => { const request = net.request({ method: options.method || 'GET', url: options.url, redirect: 'follow' }) if (options.headers) { Object.entries(options.headers).forEach(([k, v]) => { request.setHeader(k, v) }) } let body = '' request.on('response', (response) => { response.on('data', (chunk) => { body += chunk.toString() }) response.on('end', () => { resolve({ statusCode: response.statusCode, headers: response.headers, data: body }) }) }) request.on('error', (err) => { resolve({ statusCode: 0, error: err.message }) }) if (options.data) { request.write(typeof options.data === 'string' ? options.data : JSON.stringify(options.data)) } request.end() }) })页面侧做一个适配层,判断环境走不同的请求函数:
function request(options) { if (window.desktopAPI) { return window.desktopAPI.request(options).then(res => ({ statusCode: res.statusCode, data: typeof res.data === 'string' ? JSON.parse(res.data) : res.data })) } return new Promise((resolve, reject) => { uni.request({ ...options, success: resolve, fail: reject }) }) }这套封装的好处是业务代码完全不用改,request这个函数在两端行为一致。代价是主进程代理拿不到 Cookie 的自动管理,如果接口依赖会话,需要自己把 Cookie 存下来再带上。这一点在对接老系统时格外要注意。
5. 打包成 exe:配置、体积与分发
代码能跑了,最后一步是产出交付物。这一步的坑集中在配置和网络环境上。
5.1 electron-builder 配置逐项说明
配置写在package.json的build字段里,或者单独一个electron-builder.yml。我习惯用后者,可读性好一些。
appId: com.internal.tool productName: 内部业务系统 directories: output: dist buildResources: build files: - main.js - preload.js - renderer/**/* - "!**/*.map" - "!**/node_modules/**/{test,__tests__,tests,example,examples}/**" asar: true win: target: - target: nsis arch: [x64] - target: portable arch: [x64] icon: build/icon.ico nsis: oneClick: false perMachine: false allowToChangeInstallationDirectory: true createDesktopShortcut: true createStartMenuShortcut: true shortcutName: 内部业务系统appId是应用唯一标识,用反向域名格式,虽然看起来像 Java 包名,但它是 Windows 注册表和更新机制识别应用的依据,随便改会导致装了两个版本。
productName是显示名,出现在安装界面、快捷方式、控制面板的软件列表里。中文完全没问题,Electron 对 UTF-8 支持良好,这点比某些老打包工具强。
files是白名单,只把必要文件打进 asar。注意renderer/**/*这一行,如果 H5 产物里有隐藏文件或者特殊扩展名,可能被漏掉,打包后表现为图片加载不出来。遇到这种问题,把这一行临时改成**/*排查一遍。
nsis是安装包配置。oneClick: false表示走传统安装向导,让用户能选安装目录;设成true就是一键安装,装到默认路径。企业内部工具建议用向导模式,因为有些机器 C 盘空间紧张,得让用户装到 D 盘。
portable是绿色版,单个 exe 文件,双击直接运行,不写注册表。适合放 U 盘里带着走,或者给没有管理员权限的用户。
5.2 图标、版本号和代码签名
图标是个高频翻车点。Windows 的.ico文件必须包含 256×256 的图层,否则在高分屏或者资源管理器大图标视图下会显示成模糊的或是默认图标。用在线工具把 PNG 转成 ICO 的时候,记得勾选包含 16、32、48、64、128、256 多个尺寸,别只转一个。
版本号在package.json的version字段,遵循语义化版本。这个值会写进 exe 的文件属性里,运维做资产统计的时候会读它。每次发版必须改,否则用户装了新版看不出来,报问题的时候你也判断不了他装的是哪一版。
代码签名这块要提前跟甲方沟通。没有签名的 exe,用户下载下来运行时 Windows SmartScreen 会弹一个蓝色警告框,写着"Windows 已保护你的电脑",得点"更多信息"再点"仍要运行"。这个提示会吓到不少非技术用户。解决办法是买代码签名证书,一年几千块。内部工具可以走另一条路:通过域策略或者组策略把应用加入信任,或者干脆用安装包分发,安装包本身触发的警告比裸 exe 少一些。
5.3 体积和启动速度的优化取舍
一个完整的 UniApp 项目打出来的安装包,我实测大概在 90 到 140 MB 之间,具体看业务代码和静态资源。想压下来,有这么几个着力点。
关掉 source map。生产构建里.map文件能占到总资源的 30% 以上,虽然 electron-builder 不一定会打进去,但 H5 构建时开着就会拖慢打包速度。
清理node_modules里的开发依赖。electron-builder会扫描dependencies里的运行时依赖打进包里,devDependencies不动。所以凡是能放devDependencies的,全放过去。
静态资源做压缩。UniApp 项目里图片是最容易失控的部分,一张没压过的 banner 图能有两三兆。上构建前跑一遍图片压缩,WebP 格式在 Chromium 里支持得很好,体积能降六七成。
启动速度方面,ready-to-show已经能解决白屏观感问题。如果还想更快,可以把 H5 产物里首屏不需要的路由改成懒加载,UniApp 的pages.json里配"lazyCodeLoading": "requiredComponents",配合分包,首屏加载的 JS 体积能明显下降。
6. 常见问题排查实录
这一章是我这些年攒下来的问题清单,按出现频率排序。遇到问题先查表,能省不少时间。
6.1 白屏与资源 404 速查表
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 启动后纯白,无任何报错 | 窗口没等页面加载完就显示 | 加show: false+ready-to-show | 见 3.2 配置 |
| 白屏,控制台一堆 ERR_FILE_NOT_FOUND | publicPath是/ | 看 index.html 里 script 的 src | 改成./ |
| 首屏正常,点进二级页刷新变 404 | 路由用了 history 模式 | 看地址栏有无# | 改 hash 模式 |
| 图片、字体加载不出来 | 打包时被 files 白名单漏掉 | 解压 asar 看文件在不在 | 放宽files规则 |
| 页面出来但样式全乱 | CSS 里的绝对路径资源 | 看 Network 面板 | 改相对路径或用 base64 内联 |
| 只在部分机器上白屏 | 显卡驱动或硬件加速冲突 | 关掉硬件加速试 | app.disableHardwareAcceleration() |
最后一条值得展开。Electron 默认开硬件加速,在某些老显卡或者远程桌面环境下会黑屏、白屏、花屏。排查方法是在app.whenReady()之前加一行app.disableHardwareAcceleration(),如果问题消失,那就是这个原因。代价是动画和滚动会稍微卡一点,但稳定性优先。
6.2 打包阶段的网络与下载问题
electron-builder打包时会去下载对应平台的 Electron 二进制包,第一次打或者换版本打的时候,这个下载在国内网络环境下经常卡住,表现是命令行停在那里不动,或者报ETIMEDOUT。
处理办法是配置镜像地址,通过环境变量指定:
set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ set ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ npm run build:win这两行在 Windows CMD 里生效,PowerShell 里要用$env:ELECTRON_MIRROR="..."的写法。配置好之后重新打包,速度会正常。
还有一个坑是缓存目录。Electron 的二进制包下载后会缓存在用户目录下,路径大概是C:\Users\用户名\AppData\Local\electron\Cache。如果某次下载中断留下了残缺文件,后续打包会一直报校验失败,这时候得手动去那个目录把对应版本删掉重下。
6.3 只在生产环境复现的怪问题
开发环境跑得好好的,一打包就出问题,这类情况我遇到过好几次,原因基本集中在两点。
第一是路径大小写。Windows 文件系统不区分大小写,但 asar 打包之后内部是按原始大小写索引的。开发时你写import Foo from './components/foo',本地能找到Foo.vue,打进包里就找不到了。这类问题在 H5 构建阶段其实就会暴露,但如果你是在 Electron 层引用资源,就可能漏过去。养成习惯,文件名和引用路径大小写严格一致。
第二是环境变量和配置注入。开发时process.env.NODE_ENV是development,走的是本地 mock 接口;打包后变成production,请求真实地址,如果地址配错或者内网不通,表现就是一直转圈。这种情况要在主进程里加日志,把请求的完整 URL 打到文件里,出问题时让用户把日志发过来。
const logPath = path.join(app.getPath('userData'), 'app.log') function log(...args) { const line = `[${new Date().toISOString()}] ${args.join(' ')}\n` fs.appendFileSync(logPath, line) }app.getPath('userData')指向的是C:\Users\用户名\AppData\Roaming\应用名,这个目录用户有写权限,不会被杀毒软件拦。把日志放这里,出问题时指导用户按 Win+R 输入%appdata%就能找到,比让他截图控制台靠谱得多。
最后分享一个我在多个项目里都用的小技巧:在main.js里加一段启动自检,检查renderer/index.html是否存在、检查关键配置文件是否可读,缺失就在窗口里直接显示一段人能看懂的提示,而不是白屏。用户看到"配置文件缺失,请联系管理员"和看到一片白,反馈效率完全是两码事。这个自检代码不到二十行,但省下来的沟通成本相当可观。