将现有的Vue 3项目 (Vue CLI) 使用Electron 打包全流程
博客摘要
本文记录了将一个基于 Vue CLI 5 和 Vue 3 的现有 Web 项目,通过 vue-cli-plugin-electron-builder 插件打包成离线可用的 Windows 桌面应用 (.exe) 的全过程。
Part 1: 核心流程
本部分涵盖了从零开始集成到成功打包的基础步骤。
1.1 项目背景
- 前端框架: Vue 3
- 脚手架: Vue CLI 5
- 语言: TypeScript
package.json依赖: 包含vue-router,pinia,element-plus,echarts等。
1.2 步骤一:集成 Electron
对于 Vue CLI 5 项目,最无缝的集成方式是使用 vue-cli-plugin-electron-builder。
在项目所在终端中使用下面的命令
vue add electron-builder
该插件会自动安装 electron 和 electron-builder,创建 src/background.ts(主进程文件),并在 package.json 中添加 electron:serve 和 electron:build 脚本。
执行后会出现electron:serve和electron:build的脚本,如下图所示

src/background.ts如下所示

1.3 步骤二:支持离线使用(路由配置)
Electron 打包后的应用通过 file:// 协议加载。Vue Router 的 history 模式 (createWebHistory) 在此模式下无效。
-
修改点:
src/router/index.ts -
操作: 必须使用
hash模式。// import { createRouter, createWebHistory } from 'vue-router' // 错误 import { createRouter, createWebHashHistory } from 'vue-router' // 正确 const router = createRouter({ // history: createWebHistory(), // 错误 history: createWebHashHistory(), // 正确 routes })
1.4 步骤三:开发环境运行
执行此命令会同时启动 Vue Dev Server 和一个 Electron 窗口,该窗口加载 Dev Server 地址,支持热重载。
npm run electron:serve

1.5 步骤四:修改打包配置
在 vue.config.js文件中可以配置打包文件的一些参数,这个需要手动添加。
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
// 将 lintOnSave 设置为 false
lintOnSave: false,
devServer: {
client: {
overlay: false, // 关闭警告和错误的遮罩层
}
},
// Electron Builder 的配置
pluginOptions: {
electronBuilder: {
// 1. 告诉 Electron Builder 使用这个 preload 脚本
preload: 'src/preload.ts',
// 2. 确保开启了上下文隔离(这是默认和推荐的)
contextIsolation: true,
nodeIntegration: false,
// 传递给 electron-builder 的配置
builderOptions: {
// --- 1. 必须配置 ---
/**
* 应用程序的唯一 ID (Application ID)
* 格式通常是 com.company.appname
* 这对于代码签名和 macOS 上的更新至关重要
*/
"appId": "com.yourcompany.vue-demo2",
/**
* 最终生成的可执行文件和安装包的名称
*/
"productName": "软件名称",
// --- 2. 推荐配置 ---
"copyright": "Copyright © 2025 Your Name", // 版权信息
// --- 3. Windows (NSIS) 配置 ---
"win": {
/**
* 应用程序图标 (必须是 .ico 格式)
* 建议将图标文件放在 public 目录下
*/
"icon": "public/favicon.ico",
"target": [
{
"target": "nsis", // 打包为 NSIS 安装程序
"arch": ["x64"] // 仅构建 64 位
}
]
},
"nsis": {
/**
* 是否一键安装 (设为 false 则显示安装向导)
* (解决你之前路径错误的问题后,建议设为 false)
*/
"oneClick": false,
"allowToChangeInstallationDirectory": true, // 允许用户选择安装路径
"createDesktopShortcut": true, // 创建桌面快捷方式
"createStartMenuShortcut": true // 创建开始菜单快捷方式
},
// --- 4. macOS 配置 (可选) ---
// "mac": {
// "icon": "public/app.icns" // .icns 格式
// },
// --- 5. Linux 配置 (可选) ---
// "linux": {
// "icon": "public/app.png"
// }
}
}
}
})
1.6 步骤五:生产环境打包
执行此命令会先将 Vue 应用构建到 dist 目录,然后 electron-builder 会将其与 Electron 外壳打包,最终在 dist_electron 目录生成可执行的安装包。
npm run electron:build

打包的结果在 dist_electron 路径下,就是下图中框出来的那个安装包。

点击其即可进入到安装流程

Part 2: Bug 修复
在执行 electron:serve 和 electron:build 的过程中,也是遇到了一系列严重阻碍打包的 Bug。
Bug 1(electron:serve 阶段):ts-loader 版本冲突
-
错误信息:
TypeError: loaderContext.getOptions is not a function
at getLoaderOptions (E:...\node_modules\ts-loader\dist\index.js:...)
-
问题诊断:
vue-cli-plugin-electron-builder内部用于编译background.ts的 Webpack 配置,与@vue/cli-plugin-typescript引入的高版本ts-loader(v9.x) 不兼容。 -
解决方案: 降级并锁定
ts-loader版本。npm install ts-loader@~8.3.0 --save-dev
Bug 2 & 3(electron:build 阶段):TypeScript 类型声明冲突
-
错误信息 1:
TS1005: '?' expected.
error in node_modules/@types/node/stream/web.d.ts
-
错误信息 2:
TS1005: '?' expected.
error in node_modules/@types/lodash/common/common.d.ts
-
问题诊断: 根源相同。项目锁定的
typescript版本(~4.5.5)过低,而npm install自动安装了使用了更新 TS 语法的@types/node和@types/lodash(例如v18+)。typescript@4.5.5无法解析这些新类型声明文件(.d.ts)的语法。 -
解决方案: 降级并锁定
@types/node和@types/lodash到与typescript@4.5.5兼容的旧版本。# 1. 降级 @types/node (v16 的早期版本) npm install @types/node@~16.11.0 --save-dev # 2. 降级 @types/lodash (v4.14.18x) npm install @types/lodash@~4.14.180 --save-dev
Bug 4 (核心 Bug):makensis.exe 路径编码失败
-
错误信息:
ExecError: ...\makensis.exe exited with code ERR_ELECTRON_BUILDER_CANNOT_EXECUTE
!include: could not find: "E:\项目\AHP\vue测试\vue-demo2\node_modules\app-builder-lib\templates\nsis\include\StdUtils.nsh"
Command line defined: "PRODUCT_NAME=η" (乱码)
-
问题诊断:
electron-builder使用的 Windows 安装包制作工具NSIS (makensis.exe)无法处理非 ASCII 字符(如中文)的项目路径。它在尝试读取E:\项目\vue-demo2\...路径时,解析失败或解析为乱码,导致找不到必需的脚本。 -
解决方案: (唯一解)将整个 Vue 项目文件夹移动到一个纯英文(ASCII)路径下。
Bug 5:Access is denied (访问被拒绝)
-
错误信息:
remove E:...\dist_electron\win-unpacked\d3dcompiler_47.dll: Access is denied.
-
问题诊断: 在执行
npm run electron:build时,上一次构建或运行的应用 (.exe) 进程没有被完全关闭,导致electron-builder在尝试清理旧的dist_electron目录时,因文件被占用而失败。 -
解决方案:
- 打开 Windows 任务管理器 (Task Manager)。
- 找到并结束所有相关的应用进程(例如
vue-demo2.exe或electron.exe)。 - (可选)手动删除
dist_electron文件夹。 - 重新运行
npm run electron:build。
Bug 6 & 7:应用图标 (Icon) 配置错误
-
错误信息 1 (尺寸):
InvalidConfigurationError: ... favicon.ico must be at least 256x256
code: 'ERR_ICON_TOO_SMALL'
-
错误信息 2 (格式):
Fatal error: Unable to set icon
errorOut=Reserved header is not 0 or image type is not icon for 'E:...\public\favicon.ico'
-
问题诊断:
electron-builder默认可能抓取了public/favicon.ico,但这个文件尺寸太小(如 32x32)。- 即使用户提供了一个大尺寸文件,也可能只是简单地将
.png重命名为.ico,导致文件格式不正确。
-
解决方案:
-
准备一张高分辨率的
.png源图(如 512x512)。 -
使用专业工具(如
IrfanView、GIMP或可靠的在线转换器CloudConvert)将其转换为一个多层、有效的.ico文件(确保包含 256x256 层)。PNG转ICO批量转换器 | 线上 免费 这个网站的转化还是能用的,其他的一些在线转换不是下载不了,就是假转换(重命名拓展名)
-
将新图标(例如
app.ico)放入public/目录。 -
在
vue.config.js中显式配置图标路径:// vue.config.js module.exports = { pluginOptions: { electronBuilder: { builderOptions: { win: { icon: "public/app.ico" // 必须指定这个有效的 .ico 文件 } } } } };
-
Bug 8 (警告):ExtensionLoadWarning (Vue DevTools)
-
日志信息:
ExtensionLoadWarning: Warnings loading extension at ...
Unrecognized manifest key 'action'.
Permission 'scripting' is unknown...
-
问题诊断: 这是在
electron:serve期间发生的。electron-devtools-installer下载了最新版的 Vue DevTools,它使用了 Manifest V3 规范。而项目使用的 Electron (v13) 内核较旧,无法识别 V3 的某些字段。 -
解决方案: 安全地忽略此警告。它只在开发环境出现,不影响 Vue DevTools 的核心功能,也不影响生产环境打包。
Bug 9 (警告):libpng warning: iCCP: cHRM chunk ...
- 日志信息:
libpng warning: iCCP: cHRM chunk does not match sRGB - 问题诊断: 应用中加载的某个
.png文件(可能来自UI库或素材)的颜色配置文件(iCCP)存在轻微格式问题。 - 解决方案: 安全地忽略此警告。Chromium 的
libpng库会自动纠正并正确显示图像。
Part 3: 应用优化与功能实现
打包成功后,我们对应用进行了体验优化和功能扩展。
3.1 功能:移除顶部菜单栏
-
方案: 在
src/background.ts的createWindow函数中,创建窗口后立即调用win.removeMenu()。const win = new BrowserWindow({ ... }) win.removeMenu() // 添加此行
3.2 功能:生产环境禁用控制台
-
方案: 在
src/background.ts中,使用globalShortcut拦截快捷键。// src/background.ts import { app, globalShortcut, ... } from 'electron' const isDevelopment = process.env.NODE_ENV !== 'production' app.on('ready', async () => { await createWindow() // 只在生产环境禁用 if (!isDevelopment) { globalShortcut.register('CommandOrControl+Shift+I', () => false) globalShortcut.register('F12', () => false) } }) // 退出时注销快捷键 app.on('will-quit', () => { globalShortcut.unregisterAll() })
3.3 功能:实现“导入文件”
-
方案: 不需要 Electron API。直接在 Vue 组件中使用浏览器的标准 Web API。
const importProjectFromJson = () => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'application/json'; input.onchange = (event) => { const file = (event.target as HTMLInputElement).files?.[0]; if (!file) return; const reader = new FileReader(); reader.onload = async (e) => { try { const text = e.target?.result as string; if (!text) { throw new Error('无法读取文件内容。'); } const data = JSON.parse(text); // 业务逻辑..... }; reader.onerror = () => { ElMessage.error('读取文件时发生错误。'); }; reader.readAsText(file); }; input.click(); };
3.4 功能:实现“导出文件”(调用系统保存对话框)
-
背景: 虽然使用浏览器自带的 API(例如通过
<a>标签下载 Blob 数据)也能触发保存对话框,但这种方式的用户体验更像是在操作网页,而不是一个原生的桌面应用。具体体现在:- 窗口标题暴露了技术细节: 如图中红框所示,保存窗口的标题栏直接显示了
blob:http://localhost:8080/...这样的 Blob URL。这看起来非常不专业,让用户意识到他们保存的是一个临时的网页数据,破坏了原生应用的沉浸感。 - 文件类型不友好: “保存类型”默认为“所有文件 (.)”,无法为用户提供有用的过滤器(例如
JSON Files (*.json))。

- 窗口标题暴露了技术细节: 如图中红框所示,保存窗口的标题栏直接显示了
-
方案: 必须使用 Electron 的 IPC 进程间通信(Renderer -> Preload -> Main)。
-
vue.config.js:开启preload脚本。pluginOptions: { electronBuilder: { preload: 'src/preload.ts' // 指定 preload 脚本 // ... } } -
src/preload.ts(新建):使用contextBridge暴露安全 API。// src/preload.ts import { contextBridge, ipcRenderer } from 'electron'; /** * 在 window 对象上暴露一个安全的方法,用于 Vue 组件调用 */ contextBridge.exposeInMainWorld('electronAPI', { /** * 调用主进程打开保存文件对话框 * @param fileData 要保存的数据 (字符串或 Buffer) * @param defaultFileName 默认的文件名 * @returns Promise,包含操作结果 { success: boolean, message?: string } */ saveFile: (fileData: string | Buffer, defaultFileName: string) => { // 使用 'invoke' 发送请求并等待主进程的异步响应 return ipcRenderer.invoke('save-file-dialog', { fileData, defaultFileName }); } }); -
src/background.ts(主进程):监听invoke事件,调用dialog和fs。'use strict' import { app, protocol, BrowserWindow, dialog, ipcMain } from 'electron' import * as fs from 'fs' // 导入 Node.js File System import * as path from 'path' // 导入 Node.js Path import { createProtocol } from 'vue-cli-plugin-electron-builder/lib' import installExtension, { VUEJS3_DEVTOOLS } from 'electron-devtools-installer' const isDevelopment = process.env.NODE_ENV !== 'production' // Scheme must be registered before the app is ready protocol.registerSchemesAsPrivileged([ { scheme: 'app', privileges: { secure: true, standard: true } } ]) // 2. 在 app.on('ready') 之前,添加 IPC 处理器 ipcMain.handle('save-file-dialog', async (event, args) => { const { fileData, defaultFileName } = args; // 3. 从事件中获取触发此操作的窗口 const webContents = event.sender const browserWindow = BrowserWindow.fromWebContents(webContents) if (!browserWindow) { return { success: false, message: 'Browser window not found.' }; } // 4. 打开系统保存对话框 const { canceled, filePath } = await dialog.showSaveDialog(browserWindow, { title: '保存文件', // 对话框标题 defaultPath: defaultFileName, // 默认文件名 filters: [ // 文件类型过滤器 { name: 'JSON Files', extensions: ['json'] }, // { name: 'Text Files', extensions: ['txt'] }, // { name: 'All Files', extensions: ['*'] } ] }); // 5. 处理用户操作 if (canceled || !filePath) { // 用户取消了保存 return { success: false, message: 'Save operation canceled.' }; } // 6. 用户确认保存,使用 Node.js fs 模块写入文件 try { // fs.promises.writeFile 异步写入文件 await fs.promises.writeFile(filePath, fileData); // 返回成功信息 return { success: true, message: `File saved to ${filePath}` }; } catch (error: any) { console.error('Failed to save file:', error); return { success: false, message: `Failed to save file: ${error.message}` }; } }); async function createWindow() { // Create the browser window. const win = new BrowserWindow({ width: 1200, height: 900, webPreferences: { // Use pluginOptions.nodeIntegration, leave this alone // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info nodeIntegration: (process.env .ELECTRON_NODE_INTEGRATION as unknown) as boolean, // contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION, // Required for contextBridge contextIsolation: true, // The preload script will be automatically injected by the plugin // based on your vue.config.js setting preload: path.join(__dirname, 'preload.js') } }) // 2. 在创建窗口后立即移除菜单栏 win.removeMenu() if (process.env.WEBPACK_DEV_SERVER_URL) { // Load the url of the dev server if in development mode await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL as string) if (!process.env.IS_TEST) win.webContents.openDevTools() } else { createProtocol('app') // Load the index.html when not in development win.loadURL('app://./index.html') } } // Quit when all windows are closed. app.on('window-all-closed', () => { // On macOS it is common for applications and their menu bar // to stay active until the user quits explicitly with Cmd + Q if (process.platform !== 'darwin') { app.quit() } }) app.on('activate', () => { // On macOS it's common to re-create a window in the app when the // dock icon is clicked and there are no other windows open. if (BrowserWindow.getAllWindows().length === 0) createWindow() }) // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.on('ready', async () => { if (isDevelopment && !process.env.IS_TEST) { // Install Vue Devtools try { await installExtension(VUEJS3_DEVTOOLS) } catch (e:any) { console.error('Vue Devtools failed to install:', e.toString()) } } createWindow() }) // Exit cleanly on request from parent process in development mode. if (isDevelopment) { if (process.platform === 'win32') { process.on('message', (data) => { if (data === 'graceful-exit') { app.quit() } }) } else { process.on('SIGTERM', () => { app.quit() }) } } -
src/electron-api.d.ts(新建)为了在 Vue 组件中安全地使用
window.electronAPI并获得类型提示,在src/目录下创建一个类型声明文件。// src/electron-api.d.ts // 1. 定义我们暴露的 API 接口 export interface IElectronAPI { saveFile: (fileData: string | Buffer, defaultFileName: string) => Promise<{ success: boolean, message?: string }>; } // 2. 扩展全局的 Window 接口 declare global { interface Window { electronAPI: IElectronAPI; } } -
Vue 组件(渲染进程):调用
window上的 API。<template> <div> <button @click="handleSave">保存数据到文件</button> </div> </template> <script setup lang="ts"> // (如果使用了 Element Plus 等 UI 库,可以在这里 import 消息提示) // import { ElMessage } from 'element-plus' const handleSave = async () => { // 1. 准备你要保存的数据 const myData = { id: 1, name: "这是一个测试数据", timestamp: new Date().toISOString() }; // 2. 将数据转换为字符串 const dataString = JSON.stringify(myData, null, 2); // 3. 检查 'electronAPI' 是否已挂载到 window if (window.electronAPI) { try { // 4. 调用 preload 脚本中暴露的 saveFile 方法 const result = await window.electronAPI.saveFile(dataString, 'my-data.json'); if (result.success) { console.log('文件保存成功:', result.message); // ElMessage.success('文件保存成功!'); } else { // 用户取消或保存失败 (不一定是错误) console.warn('文件保存操作未完成:', result.message); // ElMessage.warning('用户取消了保存'); } } catch (error) { console.error('保存文件时发生 IPC 错误:', error); // ElMessage.error('保存文件时发生内部错误'); } } else { console.error('Electron API (window.electronAPI) 未找到。'); } } </script> -
效果如下

-
结论
打包 Vue 3 项目到 Electron 涉及了前端工程化、TypeScript 版本管理、Electron 主/渲染进程通信以及 electron-builder 的打包配置。最大的障碍是环境配置(TS 版本冲突)和打包工具的限制(中文路径)。通过逐一解决这些 Bug,最终我们成功构建了一个体验良好、功能完备的离线桌面应用,并且该安装包可以完全独立分发。