Administrator
Administrator
发布于 2025-10-26 / 6 阅读
0
0

将现有的Vue 3项目 (Vue CLI) 使用Electron 打包全流程

将现有的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

该插件会自动安装 electronelectron-builder,创建 src/background.ts(主进程文件),并在 package.json 中添加 electron:serveelectron:build 脚本。

执行后会出现electron:serve和electron:build的脚本,如下图所示

image-20251026222321169

src/background.ts如下所示

image-20251026223534211

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

image-20251026223748421

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

image-20251026225855262

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

image-20251026225956460

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

image-20251026230005426


Part 2: Bug 修复

在执行 electron:serveelectron: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 目录时,因文件被占用而失败。

  • 解决方案:

    1. 打开 Windows 任务管理器 (Task Manager)。
    2. 找到并结束所有相关的应用进程(例如 vue-demo2.exeelectron.exe)。
    3. (可选)手动删除 dist_electron 文件夹。
    4. 重新运行 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'

  • 问题诊断:

    1. electron-builder 默认可能抓取了 public/favicon.ico,但这个文件尺寸太小(如 32x32)。
    2. 即使用户提供了一个大尺寸文件,也可能只是简单地将 .png 重命名为 .ico,导致文件格式不正确
  • 解决方案:

    1. 准备一张高分辨率的 .png 源图(如 512x512)。

    2. 使用专业工具(如 IrfanViewGIMP 或可靠的在线转换器 CloudConvert)将其转换为一个多层、有效的 .ico 文件(确保包含 256x256 层)。

      PNG转ICO批量转换器 | 线上 免费 这个网站的转化还是能用的,其他的一些在线转换不是下载不了,就是假转换(重命名拓展名)

    3. 将新图标(例如 app.ico)放入 public/ 目录。

    4. 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.tscreateWindow 函数中,创建窗口后立即调用 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 数据)也能触发保存对话框,但这种方式的用户体验更像是在操作网页,而不是一个原生的桌面应用。具体体现在:

    1. 窗口标题暴露了技术细节: 如图中红框所示,保存窗口的标题栏直接显示了 blob:http://localhost:8080/... 这样的 Blob URL。这看起来非常不专业,让用户意识到他们保存的是一个临时的网页数据,破坏了原生应用的沉浸感。
    2. 文件类型不友好: “保存类型”默认为“所有文件 (.)”,无法为用户提供有用的过滤器(例如 JSON Files (*.json))。

    image-20251026231721609

  • 方案: 必须使用 Electron 的 IPC 进程间通信(Renderer -> Preload -> Main)。

    1. vue.config.js:开启 preload 脚本。

      pluginOptions: {
        electronBuilder: {
          preload: 'src/preload.ts' // 指定 preload 脚本
          // ...
        }
      }
      
    2. 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 });
        }
      });
      
    3. src/background.ts(主进程):监听 invoke 事件,调用 dialogfs

      '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()
          })
        }
      }
      
      
    4. 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;
        }
      }
      
    5. 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>
      
    6. 效果如下

      image-20251026232156714

结论

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


评论