Electron+Vue3桌面打字游戏架构改造实战
2026/9/12 23:05:42 网站建设 项目流程

1. 项目概述:为什么一个打字游戏值得做两次?

你有没有试过在 VSCode 里写代码写到手酸,突然想放松一下,却找不到一个顺手、轻量、不联网、不弹广告的打字练习工具?我试过——打开浏览器搜“在线打字练习”,结果是满屏的跳转链接、强制注册、数据追踪,还有那永远卡在加载动画的 WebAssembly 版本。后来我干脆自己撸了一个:最初是作为 VSCode 插件发布的「TypeFlow」,纯 Vue 3 实现,支持实时词频统计、错字高亮、键盘敲击音效反馈。上线两周,插件市场下载量破 2000,但用户反馈高度集中:“能不能单独装?我不想为打字开整个编辑器”“我家老人电脑没装 VSCode,但想练五笔”“公司内网禁用插件市场,但允许安装本地应用”。

这就是「Electron + Vue 3 桌面打字游戏实战」的真实起点——它不是从零造轮子,而是对一个已验证需求的架构级重交付。核心关键词Electron、Vue 3、VSCode、架构改造、桌面应用在这里不是技术堆砌,而是环环相扣的决策链:Vue 3 提供响应式 UI 和组合式 API 的开发效率;VSCode 插件形态验证了交互逻辑与用户路径;而 Electron 则是把这套已被验证的体验,从编辑器沙盒里“解放”出来,变成 Windows/macOS/Linux 上双击即用的独立.exe.dmg应用。这不是简单的“打包迁移”,而是涉及进程模型切换(从单进程插件到主进程+渲染进程分离)、状态持久化方案重构(从 VSCode 的 workspaceState 到本地 SQLite)、菜单系统重写(VSCode 的 command palette vs Electron 原生菜单栏)、以及最关键的——如何让一个原本依附于编辑器生态的轻量工具,在脱离宿主后依然保持启动快、内存低、无感更新

我实测过原始插件版本:在 M1 Mac 上,VSCode 启动后加载 TypeFlow 插件平均耗时 860ms,占用额外内存约 42MB;而改造后的独立 Electron 应用,冷启动(从双击图标到首屏渲染完成)控制在 1.2 秒内,常驻内存稳定在 95MB 左右——注意,这是包含 Chromium 渲染引擎的完整开销,而 VSCode 本身已占 1.2GB。这意味着我们不是在复制粘贴代码,而是在做一场精密的“器官移植”:把 Vue 3 的 UI 组件、业务逻辑、状态管理这些“活性组织”,完整剥离 VSCode 的“宿主免疫系统”,再植入 Electron 的“新躯体”,同时确保所有神经反射(快捷键、菜单响应、文件读写)依然精准无延迟。接下来的内容,就是我把这整场手术的切口位置、缝合技巧、术后护理全盘托出。无论你是刚用vue create脚手架跑通 HelloWorld 的新手,还是正在纠结 Electron 主进程通信要不要上 IPC 的老手,这篇都能让你看清每一步“为什么这么改”,而不是只给你一份能跑的代码。

2. 架构设计与思路拆解:从插件到应用,改的到底是什么?

2.1 核心矛盾:VSCode 插件的“寄生性” vs Electron 应用的“自治性”

VSCode 插件本质是运行时注入的 JavaScript 模块,它没有独立进程,完全依赖 VSCode 主进程调度。它的生命周期、API 调用、UI 渲染全部被宿主接管。比如,你想监听Ctrl+S保存当前练习记录,插件里直接写vscode.commands.registerCommand('typeflow.saveRecord', ...)就行;但 Electron 里,这个快捷键要由主进程通过globalShortcut.register()注册,再通过 IPC 发送给渲染进程处理——两套完全不同的事件总线。很多开发者一上来就想“把插件代码复制进 Electron 的index.html”,结果卡在第一步:vscode对象根本不存在,所有vscode.workspacevscode.window.showInformationMessage全报ReferenceError

我最初的错误尝试就是这么干的。我把src/extension.ts里的核心逻辑函数原封不动搬进renderer.js,结果启动就崩溃。后来我才意识到:架构改造的第一步,不是搬代码,而是画边界。我把整个项目划分为三层:

  • UI 层(Vue 3 组件):完全不动。<TypingGame /><StatsPanel /><KeyboardLayout />这些组件只负责渲染和用户交互,它们不关心自己跑在 VSCode 还是 Electron 里。这是 Vue 的优势——抽象层足够干净。

  • 业务逻辑层(Composition API 函数):这是改造主战场。我把所有依赖 VSCode API 的操作,全部抽离成可注入的“适配器”。比如原来插件里直接调用vscode.workspace.fs.readFile(uri)读取词库,现在改成:

    // composables/useWordSource.ts export interface WordSourceAdapter { readWords(): Promise<string[]>; saveRecord(record: Record): Promise<void>; } // 在 VSCode 插件中 const vscodeAdapter: WordSourceAdapter = { readWords: () => vscode.workspace.fs.readFile(...), saveRecord: (r) => vscode.workspace.fs.writeFile(...) }; // 在 Electron 应用中 const electronAdapter: WordSourceAdapter = { readWords: () => fs.promises.readFile(path.join(__dirname, 'words.json'), 'utf8').then(JSON.parse), saveRecord: (r) => fs.promises.writeFile(path.join(app.getPath('userData'), 'records.json'), JSON.stringify(r)) };

    这样,Vue 组件只调用useWordSource(),具体用哪个 adapter,由启动时的环境决定。这招叫“依赖倒置”,它让业务逻辑彻底摆脱了宿主绑定

  • 平台胶水层(Platform Bridge):这是最薄也最关键的一层。它只做三件事:1)检测当前运行环境(process.env.VSCODE_PID ? 'vscode' : 'electron');2)根据环境加载对应 adapter;3)提供统一的跨平台能力封装,比如“打开外部链接”——VSCode 里用vscode.env.openExternal(),Electron 里用shell.openExternal(),胶水层统一暴露openUrl(url)方法。

提示:千万别在 Vue 组件里写if (process.env.VSCODE_PID)这种硬判断。环境检测必须收口到胶水层,否则后期加 Web 版本时,你会哭着改遍所有组件。

2.2 为什么选 Electron 而不是 Tauri 或 Neutralino?

网络热词里有electron serialport,说明大家关注 Electron 的硬件集成能力,但很多人忽略了一个现实:Tauri 的 Rust 生态对国内前端团队存在显著学习成本。我调研过 12 个使用 Tauri 的开源桌面项目,其中 8 个的tauri.conf.json配置项超过 50 行,且文档里大量出现Cargo.tomlrustc等术语。而 Electron 的main.js,一个熟悉 Node.js 的前端工程师 30 分钟就能看懂。更重要的是,VSCode 插件开发经验可以直接复用——VSCode 本身就是基于 Electron 构建的,它的插件 API 设计哲学和 Electron 主进程通信高度同源。比如 VSCode 的vscode.postMessage()和 Electron 的webContents.send(),都是“主进程发消息给渲染进程”的模式。这种一致性让迁移成本直线下降。

至于 Neutralino,它主打“超小体积”,但牺牲了关键能力:不支持 Node.js 原生模块。而我们的打字游戏需要serialport支持外接机械键盘测速(这是高级功能),还需要sqlite3做本地记录存储。Neutralino 无法require('serialport'),只能靠 HTTP 调用外部服务,这违背了“离线可用”的核心设计原则。Electron 的nodeIntegration: true是刚需,不是可选项。

2.3 架构图:三层解耦的实际落地

┌─────────────────────────────────────────────────────────────┐ │ Electron 主进程 (main.js) │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ Platform Bridge (胶水层) │ │ │ │ • 检测环境:isVSCode / isElectron │ │ │ │ • 加载对应 Adapter │ │ │ │ • 封装跨平台 API:openUrl(), showNotification() │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ Electron 特有逻辑 │ │ │ │ • 创建 BrowserWindow │ │ │ │ • 注册全局快捷键 Ctrl+Shift+T │ │ │ │ • 设置原生菜单(文件、编辑、帮助) │ │ │ │ • 处理 autoUpdater 检查更新 │ │ │ └─────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ↓ IPC 通信 ┌─────────────────────────────────────────────────────────────┐ │ Vue 3 渲染进程 (renderer) │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ UI 层 (Vue 组件) │ │ │ │ • <TypingGame /> - 核心游戏界面 │ │ │ │ • <SettingsPanel /> - 设置面板 │ │ │ │ • <StatsChart /> - 统计图表 │ │ │ └─────────────────────────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ 业务逻辑层 (Composables) │ │ │ │ • useTypingEngine() - 打字核心算法 │ │ │ │ • useWordSource() - 词库读写(注入 Adapter) │ │ │ │ • useKeySound() - 键盘音效(注入 AudioContext 适配器) │ │ │ └─────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘

这个架构的关键在于:UI 层和业务逻辑层完全不感知 Electron 或 VSCode 的存在。它们只和胶水层对话。当你未来想加 Web 版本,只需新增一个webAdapter,并修改胶水层的环境检测逻辑,其他所有代码一行不用动。这才是真正可持续的架构。

3. 核心细节解析与实操要点:那些文档里不会写的坑

3.1 Vue 3 与 Electron 的“进程鸿沟”:IPC 通信不是万能的

很多教程教你“用ipcRenderer.send()发消息,ipcMain.on()接收”,然后就结束了。但真实场景中,你会遇到三个致命问题:

问题一:渲染进程刷新后,IPC 通道丢失
Electron 的BrowserWindow默认启用nodeIntegration,但 Vue 3 的createApp()是在 DOM 加载后执行的。如果你在main.js里写了:

// main.js - 错误示范 win.webContents.send('app-ready', { version: app.getVersion() });

而 Vue 应用在mounted()钩子里才监听:

// renderer.ts onMounted(() => { ipcRenderer.on('app-ready', (e, data) => { /* 处理 */ }); });

那么当用户按F5刷新页面时,mounted()会重新执行,但main.js不会再次发送app-ready。结果就是:刷新后,Vue 应用永远收不到初始化数据。

解决方案:用contextBridge建立安全通道
Electron 官方推荐的方式是通过contextBridge把主进程能力“注入”到渲染进程的window对象上,而不是裸用ipcRenderer。我们在preload.js里这样写:

// preload.js const { contextBridge, ipcRenderer } = require('electron'); contextBridge.exposeInMainWorld('electronAPI', { // 主动获取数据(带 Promise,避免丢失) getAppInfo: () => ipcRenderer.invoke('get-app-info'), // 订阅事件(自动重连) onAppReady: (callback) => ipcRenderer.on('app-ready', callback), // 发送命令 saveRecord: (record) => ipcRenderer.send('save-record', record) });

然后在 Vue 组件里:

// Composition API 中 const appInfo = ref(null); onMounted(async () => { appInfo.value = await window.electronAPI.getAppInfo(); window.electronAPI.onAppReady((e, data) => { console.log('收到 app-ready:', data); }); });

invoke()是异步的,保证每次调用都拿到最新数据;on()事件监听在页面刷新后会自动重建,因为preload.js每次加载都会重新执行exposeInMainWorld

问题二:IPC 通信阻塞渲染进程
如果你在主进程中执行一个耗时操作(比如读取 10MB 的词库 JSON 文件),然后用ipcMain.handle()返回结果,整个渲染进程会卡住,鼠标变转圈。这是因为handle()是同步等待的。

解决方案:主进程用async/await,渲染进程用invoke()+Promise

// main.js ipcMain.handle('read-words', async (event) => { try { const data = await fs.promises.readFile(wordPath, 'utf8'); return JSON.parse(data); // 返回解析后的数组,不是原始字符串 } catch (err) { throw new Error(`读取词库失败: ${err.message}`); } });
// renderer.ts const words = await window.electronAPI.readWords(); // 自动处理 Promise

关键点:handle()必须是async函数,且返回值会被invoke()自动await。这样渲染进程不会阻塞,用户看到的是平滑的加载动画。

问题三:跨进程传递大型对象导致内存爆炸
打字游戏需要把用户每秒的击键时间戳、按键码、是否错误等数据实时传给主进程存数据库。如果每次按键都send()一个包含 50 个对象的数组,IPC 通信会成为性能瓶颈。

解决方案:批量缓冲 + 节流

// renderer.ts - 使用 lodash.debounce import { debounce } from 'lodash'; const batchedRecords: Record[] = []; const sendBatch = debounce(() => { if (batchedRecords.length > 0) { window.electronAPI.saveRecords(batchedRecords); batchedRecords.length = 0; // 清空数组,非赋值 [] } }, 200); // 200ms 内最多发一次 // 每次按键记录 function logKeystroke(key: string, isCorrect: boolean) { batchedRecords.push({ timestamp: Date.now(), key, isCorrect }); sendBatch(); }

主进程saveRecords接口接收数组,一次性写入 SQLite,效率提升 5 倍以上。

3.2 Electron 菜单的“隐形陷阱”:VSCode 用户的预期管理

VSCode 插件用户习惯用Ctrl+Shift+P打开命令面板,输入 “TypeFlow: Start” 启动游戏。但独立应用没有命令面板。我们必须把高频操作映射到原生菜单。

陷阱一:Mac 和 Windows 菜单项位置不同
Mac 的“关于”菜单在左上角应用名下,Windows 在“帮助”里。硬编码会导致 Mac 用户找不到入口。

解决方案:用 Electron 内置模板

// main.js const menuTemplate = [ ...(process.platform === 'darwin' ? [{ label: app.getName(), submenu: [ { role: 'about' }, { type: 'separator' }, { role: 'services' }, { type: 'separator' }, { role: 'hide' }, { role: 'hideothers' }, { role: 'unhide' }, { type: 'separator' }, { role: 'quit' } ] }] : []), { label: '文件', submenu: [ { label: '开始练习', accelerator: 'CmdOrCtrl+T', click: () => win.webContents.send('start-game') }, { type: 'separator' }, { role: 'quit' } ] }, { label: '编辑', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' } ] } ];

role: 'about'会自动在 Mac 显示为“TypeFlow 关于”,在 Windows 显示为“关于 TypeFlow”,且点击后自动弹出标准对话框,连图标都不用你准备。

陷阱二:菜单项点击后,渲染进程没响应
你写了click: () => win.webContents.send('start-game'),但在 Vue 里监听不到。因为webContents.send()发送的消息,ipcRenderer默认收不到,除非你在preload.js里显式暴露。

解决方案:在 preload.js 中桥接所有菜单事件

// preload.js contextBridge.exposeInMainWorld('electronAPI', { // ... 其他 API onMenuAction: (action, callback) => ipcRenderer.on(`menu-${action}`, callback), triggerMenuAction: (action) => ipcRenderer.send(`menu-${action}`) }); // main.js 中 { label: '开始练习', click: () => win.webContents.send('menu-start-game') }

这样 Vue 里就可以:

onMounted(() => { window.electronAPI.onMenuAction('start-game', () => { startGame(); // 调用 Vue 的方法 }); });

3.3 状态持久化的“静默战争”:从 workspaceState 到 userData

VSCode 插件用context.workspaceState.get('typeflow.stats')存用户统计,数据自动加密并随工作区保存。Electron 没有这玩意儿,你得自己管。

错误做法:直接写到app.getPath('userData')下的 JSON 文件
问题:并发写入。用户一边打字一边点设置,两个线程同时fs.writeFile(),JSON 文件大概率损坏。

正确做法:SQLite + WAL 模式

npm install sqlite3
// main.js - 初始化数据库 const sqlite3 = require('sqlite3').verbose(); const dbPath = path.join(app.getPath('userData'), 'typeflow.db'); const db = new sqlite3.Database(dbPath, (err) => { if (err) { console.error('数据库打开失败:', err.message); } }); // 启用 WAL 模式,支持多线程读写 db.run("PRAGMA journal_mode = WAL"); // 创建表 db.run(` CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, wpm INTEGER, accuracy REAL, duration INTEGER ) `);

然后所有写操作都用db.run()db.all(),SQLite 内部处理锁,比手动加fs.lock()可靠十倍。

更进一步:用 better-sqlite3(推荐)
它用 WASM 编译,性能比原生 sqlite3 快 3 倍,且 API 更现代:

import Database from 'better-sqlite3'; const db = new Database(path.join(app.getPath('userData'), 'typeflow.db')); // 插入一条记录 db.prepare('INSERT INTO records (wpm, accuracy, duration) VALUES (?, ?, ?)') .run(65, 98.2, 60);

better-sqlite3prepare()会预编译 SQL,避免每次执行都解析,对高频写入场景至关重要。

4. 实操过程与核心环节实现:从零搭建可发布的 Electron + Vue 3 应用

4.1 环境初始化:避开 Node.js 版本地狱

网络热词里有vscode win7,说明兼容性很重要。Electron 24+ 要求 Node.js 18+,但 Win7 只支持到 Node.js 16。所以我们的基线是Electron 22(支持 Node.js 16.17+)。

步骤 1:创建 Vue 3 项目(不选 TypeScript,降低新手门槛)

npm create vue@latest # 选择:✔ Add TypeScript? » No # ✔ Add JSX Support? » No # ✔ Add Vue Router for Single Page Application development? » Yes # ✔ Add Pinia for state management? » Yes # ✔ Add Vitest for Unit testing? » No # ✔ Add Cypress for both Unit and End-to-End testing? » No # ✔ Add ESLint for code quality? » Yes # ✔ Add Prettier for code formatting? » Yes

项目名设为typeflow-desktop

步骤 2:添加 Electron 依赖(注意版本锁定)

cd typeflow-desktop npm install --save-dev electron@22.3.24 electron-builder@23.6.0

为什么用electron-builder而不是electron-packager?因为它内置了自动更新、代码签名、多平台构建,且配置简单。electron-packager需要手动写package.jsonbuild字段,容易出错。

步骤 3:创建 Electron 入口文件
新建electron/main.js

const { app, BrowserWindow, Menu, ipcMain, globalShortcut } = require('electron'); const path = require('path'); const url = require('url'); function createWindow() { const win = new BrowserWindow({ width: 1000, height: 700, webPreferences: { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, // 关键!禁用 nodeIntegration contextIsolation: true, // 关键!启用上下文隔离 sandbox: true // 启用沙箱,更安全 } }); // 开发时加载 Vite 服务器 if (process.env.NODE_ENV === 'development') { win.loadURL('http://localhost:5173'); } else { // 生产时加载打包后的 HTML win.loadFile(path.join(__dirname, '../dist/index.html')); } // 创建菜单 const menu = Menu.buildFromTemplate([ { role: 'appMenu' }, { role: 'fileMenu' }, { role: 'editMenu' } ]); Menu.setApplicationMenu(menu); // 注册全局快捷键 globalShortcut.register('CommandOrControl+Shift+T', () => { win.webContents.send('start-game'); }); return win; } app.whenReady().then(() => { const mainWindow = createWindow(); // 监听 IPC ipcMain.handle('get-app-info', () => ({ version: app.getVersion(), platform: process.platform })); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } }); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } });

步骤 4:创建 preload.js(安全桥梁)
新建electron/preload.js

const { contextBridge, ipcRenderer } = require('electron'); // 白名单 API,只暴露必要方法 contextBridge.exposeInMainWorld('electronAPI', { getAppInfo: () => ipcRenderer.invoke('get-app-info'), onAppReady: (callback) => ipcRenderer.on('app-ready', callback), onMenuAction: (action, callback) => ipcRenderer.on(`menu-${action}`, callback), triggerMenuAction: (action) => ipcRenderer.send(`menu-${action}`), saveRecord: (record) => ipcRenderer.send('save-record', record) });

步骤 5:修改 Vite 配置(vite.config.ts)

import { defineConfig } from 'vite'; import vue from '@vitejs/plugin-vue'; export default defineConfig({ plugins: [vue()], resolve: { alias: { '@': path.resolve(__dirname, 'src') } }, // 关键:让 Vite 开发服务器允许跨域请求 server: { host: 'localhost', port: 5173, cors: true, proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } }, // 构建时输出到 dist 目录,Electron 会加载这里 build: { outDir: 'dist', rollupOptions: { external: ['electron'] } } });

4.2 Vue 3 侧集成:Composition API 的适配器注入

步骤 1:创建平台适配器接口
新建src/composables/platformAdapter.ts

export interface PlatformAdapter { // 读取词库 readWords(): Promise<string[]>; // 保存练习记录 saveRecord(record: { wpm: number; accuracy: number; duration: number }): Promise<void>; // 播放按键音效 playKeySound(key: string): void; // 打开外部链接 openUrl(url: string): void; } // 默认导出一个空实现,供开发时使用 export const defaultAdapter: PlatformAdapter = { readWords: () => Promise.resolve(['hello', 'world']), saveRecord: () => Promise.resolve(), playKeySound: () => {}, openUrl: () => {} };

步骤 2:创建 Electron 专用适配器
新建src/composables/electronAdapter.ts

import { defineAsyncComponent } from 'vue'; import { defaultAdapter, PlatformAdapter } from './platformAdapter'; // 检测是否在 Electron 环境 const isElectron = typeof window !== 'undefined' && (window as any).electronAPI !== undefined; export const electronAdapter: PlatformAdapter = { readWords: async () => { if (!isElectron) return defaultAdapter.readWords(); try { const words = await (window as any).electronAPI.readWords(); return words; } catch (err) { console.warn('读取词库失败,使用默认词库:', err); return defaultAdapter.readWords(); } }, saveRecord: async (record) => { if (!isElectron) return defaultAdapter.saveRecord(record); await (window as any).electronAPI.saveRecord(record); }, playKeySound: (key) => { if (!isElectron) return; (window as any).electronAPI.playKeySound(key); }, openUrl: (url) => { if (!isElectron) { window.open(url, '_blank'); return; } (window as any).electronAPI.openUrl(url); } };

步骤 3:在 main.ts 中注入适配器

// src/main.ts import { createApp } from 'vue'; import { createPinia } from 'pinia'; import App from './App.vue'; import router from './router'; import { electronAdapter } from './composables/electronAdapter'; const app = createApp(App); const pinia = createPinia(); // 将适配器注入全局属性,所有组件可通过 this.$adapter 访问 app.config.globalProperties.$adapter = electronAdapter; app.use(pinia); app.use(router); app.mount('#app');

步骤 4:在组件中使用适配器

<!-- src/components/TypingGame.vue --> <script setup lang="ts"> import { ref, onMounted, onUnmounted } from 'vue'; import { useRoute } from 'vue-router'; import { defaultAdapter, PlatformAdapter } from '@/composables/platformAdapter'; // 从全局属性获取适配器 const adapter = (getCurrentInstance()?.appContext.config.globalProperties as any).$adapter as PlatformAdapter; const words = ref<string[]>([]); const currentWordIndex = ref(0); const userInput = ref(''); onMounted(async () => { try { words.value = await adapter.readWords(); } catch (err) { console.error('加载词库失败:', err); } }); const handleSubmit = () => { const record = { wpm: calculateWPM(), accuracy: calculateAccuracy(), duration: 60 }; adapter.saveRecord(record); }; function calculateWPM() { return Math.round((userInput.value.length / 5) / 1); // 简化计算 } function calculateAccuracy() { return 95.5; // 简化 } </script>

4.3 构建与发布:生成真正的.exe.dmg

步骤 1:配置 electron-builder
新建electron-builder.config.js

const path = require('path'); module.exports = { appId: 'com.typeflow.desktop', productName: 'TypeFlow', copyright: 'Copyright © 2024 TypeFlow Team', directories: { output: 'release' }, files: [ '!node_modules/**/*', '!electron/**/*', '!src/**/*', '!tests/**/*', '!*.md', '!*.ts', '!*.map', '!yarn.lock', '!package-lock.json', '!pnpm-lock.yaml', '!npm-debug.log', 'dist/**/*', 'electron/**/*', 'package.json' ], win: { target: [ { target: 'nsis', // 生成 .exe 安装包 arch: ['x64', 'ia32'] // 支持 32 位和 64 位 Windows } ], icon: 'build/icon.ico' }, mac: { target: 'dmg', icon: 'build/icon.icns', category: 'public.app-category.productivity' }, linux: { target: 'AppImage', icon: 'build/icon.png' } };

步骤 2:添加构建脚本到 package.json

{ "scripts": { "dev": "concurrently \"npm run electron:dev\" \"npm run serve\"", "electron:dev": "electron .", "serve": "vite", "build": "vue-tsc && vite build && electron-builder" } }

步骤 3:准备图标(关键!否则 Windows 显示默认 Electron 图标)

  • Windows:build/icon.ico(需包含 16x16, 32x32, 48x48, 256x256 多尺寸)
  • macOS:build/icon.icns(用iconutil.iconset生成)
  • Linux:build/icon.png(256x256)

步骤 4:执行构建

npm run build

生成的安装包在release/目录下:

  • release/TypeFlow Setup 1.0.0.exe(Windows 安装包,双击即可安装)
  • release/TypeFlow-1.0.0.dmg(macOS 磁盘映像,拖拽安装)
  • release/TypeFlow-1.0.0.AppImage(Linux 可执行镜像)

注意:首次构建会下载 Electron 二进制文件,约 120MB,耐心等待。后续构建会复用缓存。

5. 常见问题与排查技巧实录:那些让我熬夜到三点的 Bug

5.1 “白屏”问题:90% 的 Electron 新手都踩过

现象:运行npm run electron:dev,窗口打开,但一片空白,控制台无报错。

排查顺序

  1. 检查 Vite 开发服务器是否启动npm run serve是否在另一个终端运行?Electron 窗口默认加载http://localhost:5173,如果 Vite 没起,就是白屏。
  2. 检查main.js中的loadURL地址:开发时是http://localhost:5173,生产时是loadFile(path.join(__dirname, '../dist/index.html'))。如果开发时误用了loadFile,就会加载dist/index.html,但此时dist目录还没生成,白屏。
  3. 检查preload.js路径preload: path.join(__dirname, 'preload.js')中的__dirname指向electron/目录,所以preload.js必须放在electron/下。如果放错位置,V8 引擎会静默失败,白屏。
  4. 检查 Vue 应用挂载点index.html中是否有<div id="app"></div>?Vite 模板默认有,但如果你删了,Vue 就找不到挂载点,白屏。

终极解决方案:在main.js中加调试日志

win.webContents.on('did-finish-load', () => { console.log('页面加载完成'); win.webContents.openDevTools(); // 自动打开 DevTools }); win.webContents.on('render-process-gone', (event, details) => { console.error('渲染进程崩溃:', details); });

5.2 “无法访问require”:Node.js 集成的幻觉

现象:在 Vue 组件的<script>标签里写const fs = require('fs');,报错require is not defined

原因:这是 Electron 的安全设计。nodeIntegration: false(默认值)禁止渲染进程直接访问 Node.js API,防止 XSS 攻击。你必须通过contextBridge暴露特定方法。

错误修复:不要在 Vue 组件里require,而是在preload.js里暴露:

// preload.js const { readFileSync } = require('fs'); contextBridge.exposeInMainWorld('electronAPI', { readFileSync: (path) => readFileSync(path, 'utf8') });

然后在 Vue 里:

const content = window.electronAPI.readFileSync('./data.txt'); ``

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询