首页 / 资讯中心 / 文章详情

UniApp 打包 Windows exe 实战:Electron 套壳与原生能力

UniApp 打包 Windows exe 实战:Electron 套壳与原生能力 ★ FEATURED ARTICLE
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 MBWin7 SP1 及以上极强Node 生态全可用低企业工具、需要文件/打印/串口Tauri系统 WebView2约 3–10 MBWin10 1803 及以上较强但需写 Rust 或 sidecar中高轻量展示、追求小体积NW.js自带 Chromium Node.js约 90–140 MBWin7 及以上强与 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 里能跑、进壳里就废的 APIUniApp 的 API 有一层跨端封装同一个uni.xxx在不同端行为不同。H5 端有一批 API 是降级实现或者直接不支持的在浏览器里可能只是不生效、不报错进了 Electron 之后问题会更明显。典型的有这么几个。uni.chooseImage在 H5 端是唤起input typefileElectron 里也能用但拿到的路径是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: falseready-to-show见 3.2 配置白屏控制台一堆 ERR_FILE_NOT_FOUNDpublicPath是/看 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_MIRRORhttps://npmmirror.com/mirrors/electron/ set ELECTRON_BUILDER_BINARIES_MIRRORhttps://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\应用名这个目录用户有写权限不会被杀毒软件拦。把日志放这里出问题时指导用户按 WinR 输入%appdata%就能找到比让他截图控制台靠谱得多。最后分享一个我在多个项目里都用的小技巧在main.js里加一段启动自检检查renderer/index.html是否存在、检查关键配置文件是否可读缺失就在窗口里直接显示一段人能看懂的提示而不是白屏。用户看到配置文件缺失请联系管理员和看到一片白反馈效率完全是两码事。这个自检代码不到二十行但省下来的沟通成本相当可观。
阅读完成 · 觉得有帮助?
咨询建站