我最初其实只是想在 VSCode 里写一个给自己用的打字练习页面,Vue 3 起项目、本地起个 Vite 服务,浏览器一开就能练。但随着功能越加越多——词库要管理、成绩要保存、快捷键要全局响应、甚至想把它发给朋友直接用——我发现纯 Web 页面的形态已经撑不住了。于是我开始做一轮架构改造:把整个 Vue 3 项目迁到 Electron 壳子里,从浏览器里的小页面,变成一个真正可打包、可分发的独立桌面打字游戏。
这一轮改造踩了不少坑,也把 Electron 主进程、预加载脚本、渲染进程的边界重新理了一遍。如果你也有一个“在 VSCode 里写好的 Vue 项目”,想扩展成独立桌面应用,这篇文章应该能帮你少走很多弯路。我会从架构设计、改造步骤、实操细节一直讲到打包分发和问题排查,尽量把关键决策背后的“为什么”也说清楚。
1. 为什么要把打字游戏从网页变成独立应用
1.1 浏览器里做打字工具的局限性
很多人觉得打字练习这种工具,Web 页面已经够用了。确实,单从“打字”这个核心动作来看,浏览器完全能承载:监听键盘事件、渲染高亮字符、计算速度和正确率,这些都不难。但当你想把工具做得“像个正经应用”时,浏览器就开始别扭了。
首先是窗口形态。浏览器标签页有地址栏、书签栏、各种插件图标,这些对打字练习来说全是干扰。单独开一个干净的窗口不是不行,但体验很割裂。其次是系统能力,比如全局快捷键。我希望在写代码、开会、看文档的时候,按一个组合键就能唤起打字练习窗口,这在浏览器里基本做不到。再有就是离线使用和资源管理,词库文件、练习记录、音效素材如果都放在网页服务端,意味着每次使用都依赖网络和服务稳定性,这对一个个人工具来说太重了。
更重要的一点是分发。浏览器方案想发给朋友用,要么部署一个网址,要么让他装一套本地开发环境。部署网址要考虑服务器成本,本地环境对非技术朋友来说几乎等于劝退。独立桌面应用打包成一个安装包发过去,双击就能装,这个体验是 Web 方案给不了的。
1.2 为什么选 Electron + Vue 3,而不是换技术栈重写
在做方案选型的时候,我认真考虑过几个方向:完全重写成原生应用、用 Tauri、用 Electron。原生应用开发成本太高,而且我手里的核心资产是已经写好的 Vue 3 代码,重写意味着丢掉大半年的逻辑沉淀。
Tauri 确实更轻量,打包体积小、内存占用低,但它要求 Rust 后端,而且系统 WebView 在各个平台的行为差异很大。我的目标平台里有国产 Linux 系统,WebView 的行为一致性让我很担心。Electron 虽然“重”,但它的运行环境是自带 Chromium,行为完全可控。对我这个已经用 Vue 3 积累了完整业务逻辑的项目来说,Electron 是“改造成本最低、跨平台一致性最好”的选择。
最终确定的技术栈是:Vue 3 组合式 API 负责渲染层,Electron 负责桌面宿主层,electron-vite 统一开发与构建,electron-builder 负责打包分发。一句话总结这次改造的核心思路:不是把 Web 项目重写成桌面项目,而是给 Web 项目套一个桌面壳,再把系统能力通过安全的通道开放给它。
1.3 改造之前先想清楚:哪些逻辑留在渲染层,哪些必须上收
这是我这次改造最重要的经验之一。很多人在做 Electron 改造时,最容易犯的错就是把渲染进程当成 Node.js 环境来用,直接require('fs')读文件、写文件。这在开发时可能没问题,但一旦开启安全配置,Node 环境被隔离,代码立即崩溃。
注意:Electron 的安全模型默认是 contextIsolation 开启、nodeIntegration 关闭。渲染层不是 Node 环境,无法直接读文件、访问系统 API。所有系统能力的调用都必须通过 IPC 通道转发给主进程。
所以在动手改之前,我先画了一张数据流图:哪些数据从系统流向页面(词库文件、成绩记录、系统语言),哪些指令从页面流向系统(保存记录、打开外链、注册快捷键)。凡是涉及文件的,统一走主进程;凡是页面内部状态,比如当前打到了哪个字符、速度统计,留在 Vue 里。这个边界越早划清楚,后面的改造越顺利。
2. 核心架构设计与关键实现
2.1 三层进程架构:main / preload / renderer
Electron 应用天然分成三层:主进程(main)、预加载脚本(preload)、渲染进程(renderer)。主进程运行在 Node.js 环境,负责窗口创建、生命周期管理、系统能力调用;渲染进程运行在 Chromium 环境,承载页面 UI;预加载脚本是两者之间的桥梁,通过 contextBridge 把安全的方法暴露给页面。
打字游戏在这三层里的职责划分如下:
| 层级 | 职责 |
|---|---|
| 主进程 | 读取词库文件、保存练习成绩、注册全局快捷键、获取系统语言、管理窗口 |
| 预加载脚本 | 通过 contextBridge 暴露api.readWords()、api.saveRecord()、api.getLocale()等方法 |
| 渲染进程 | 渲染词库内容、监听键盘输入、计算打字速度、绘制统计图表 |
这里要特别强调预加载脚本的价值。有人觉得框架已经提供了 IPC,为什么还要多一层 preload?因为直接暴露 IPC 会给渲染层一个完全开放的通道,页面代码一旦被注入脚本,攻击者可以调用任何主进程方法。通过 preload 暴露白名单方法,能严格控制页面能调用什么。我自己只暴露出必要的 5-6 个方法,其他一律不放行。
2.2 Vue 3 组合式 API 在打字场景中的设计
之前用选项式 API 写打字逻辑时,数据、计算属性、方法分散在 data、computed、methods 三个区域,随着功能增加,代码跳来跳去,非常难受。这次趁着架构改造,我把整个逻辑层切到了组合式 API。这乍看跟 Electron 没关系,但实际上对“架构改造”来说,逻辑组织方式的调整直接影响了后续的跨进程协作。
我把打字游戏的核心逻辑拆成了三个 composable:
useTypingEngine:负责词库内容解析、当前输入字符定位、命中判定、错误统计。useTimer:负责练习计时、暂停/继续、超时中断。useStats:负责速度、正确率、历史记录的聚合计算。
以useTypingEngine为例,组合式的好处体现在“状态和方法自然内聚”。我可以让当前字符索引、已输入字符串这些状态和 onKeyInput 方法放在同一个函数作用域里,相关逻辑一眼就能看全。选项式写法中数据和方法的割裂感,在复杂交互场景里确实会成为心智负担。
// src/renderer/src/composables/useTypingEngine.js import { ref, computed } from 'vue' export function useTypingEngine(words) { const currentIndex = ref(0) const inputBuffer = ref('') const errorCount = ref(0) const currentChar = computed(() => words[currentIndex.value] || '') const accuracy = computed(() => { const total = currentIndex.value + errorCount.value return total === 0 ? 100 : Math.round((currentIndex.value / total) * 10000) / 100 }) function reset() { currentIndex.value = 0 inputBuffer.value = '' errorCount.value = 0 } function handleInput(char) { if (char === currentChar.value) { currentIndex.value++ inputBuffer.value = '' } else { errorCount.value++ } } return { currentIndex, inputBuffer, currentChar, accuracy, reset, handleInput } }这里把状态和操作封装在一起,主进程过来的词库内容只要塞进words参数,整个打字引擎就能跑起来。相比选项式 API 的分散结构,这种封装让“渲染层内部逻辑”和“跨进程数据交换”的边界更清晰。
2.3 词库、成绩、系统能力:哪些能力必须走 IPC
把系统能力统一收归主进程之后,我遇到一个本质问题:怎么判断一个能力该留在渲染层,还是该上收到主进程。我的判断标准很简单——渲染层是否需要等待结果,以及这个操作是否涉及系统资源。
比如读取词库文件,渲染层需要拿到文件内容才能渲染,而且这涉及文件系统,必须走 IPC。保存成绩记录也一样,虽然渲染层可以先在内存里存着,但为了持久化,必须写文件。获取系统语言则是典型的“一次读取、多处使用”场景,我在应用启动时读取一次,缓存到主进程,通过 preload 暴露getLocale()方法,渲染层首次挂载时调用一次即可。
// src/preload/index.js import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('api', { readWords: () => ipcRenderer.invoke('words:read'), saveRecord: (record) => ipcRenderer.invoke('record:save', record), getLocale: () => ipcRenderer.invoke('app:get-locale'), openExternal: (url) => ipcRenderer.invoke('shell:open-external', url), registerShortcut: (accelerator, callback) => { ipcRenderer.on('shortcut:triggered', callback) return ipcRenderer.invoke('shortcut:register', accelerator) } })提示:
ipcRenderer.invoke是异步的,渲染层拿到的是一个 Promise。如果首次调用时数据还没准备好,页面要先给一个 loading 状态,别让用户在空白界面干等。
2.4 安全配置和渲染进程权限控制
Electron 官方文档强调的安全配置,我在这次改造中全部用上了。contextIsolation: true保证页面上下文和预加载脚本上下文隔离;nodeIntegration: false彻底关闭渲染层的 Node 能力;sandbox: true则进一步限制渲染进程的系统调用权限。这些都是老生常谈,但真正落地时有一个常被忽略的点:在开发环境被浏览器插件注入脚本后,如果你没有开隔离,页面直接能访问 Node API,风险极高。
// src/main/index.js const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: join(__dirname, '../preload/index.js'), contextIsolation: true, nodeIntegration: false, sandbox: true } })3. 实操:从 VSCode 里的 Web 页面到 Electron 独立窗口
3.1 搭建 electron-vite 项目并保留原有 Vue 代码
如果你之前是用 Vite 创建的 Vue 项目,那迁移到 electron-vite 的成本非常低。electron-vite 可以视作专为 Electron 打造的 Vite 封装,它把 main、preload、renderer 三个构建目标拆开,开发时启动一个 Vite Dev Server 给渲染层用,同时编译 main 和 preload。
我当时的迁移步骤非常简单。先用脚手架创建新项目:
npm create @quick-start/electron@latest typing-game-electron -- --template vue然后把原来 Vue 项目里的src目录复制到新项目的src/renderer/src下,再把原来的index.html移到src/renderer/index.html。electron-vite 的默认目录结构是:
src/ ├── main/ │ └── index.js ├── preload/ │ └── index.js └── renderer/ ├── index.html └── src/ ├── App.vue ├── main.js └── composables/如果你的 Vue 项目里用了路由,特别是 history 模式,在 Electron 打包后要改成 hash 模式,否则文件协议下刷新会找不到路径。这一点我在第四章还会具体讲。
3.2 主进程窗口与开发/生产加载逻辑
Electron 窗口创建后,需要决定加载什么内容。开发环境下加载 Vite Dev Server 的地址,生产环境下加载打包后的index.html文件。electron-vite 在开发环境下会把 Dev Server 地址放在环境变量里,最简单的判断就是看这个变量是否存在。
// src/main/index.js import { app, shell, BrowserWindow } from 'electron' import { join } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' function createWindow() { const mainWindow = new BrowserWindow({ width: 1200, height: 800, show: false, autoHideMenuBar: true, webPreferences: { preload: join(__dirname, '../preload/index.js'), contextIsolation: true, nodeIntegration: false, sandbox: true } }) mainWindow.on('ready-to-show', () => mainWindow.show()) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } } app.whenReady().then(() => { electronApp.setAppUserModelId('com.typing.game') createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) })开发模式下,electron-vite 会启动一个 Dev Server,Electron 直接加载这个 URL。此时你在 VSCode 里改代码,Renderer 窗口会像普通 Vue 项目一样热更新,开发体验几乎和纯网页项目没有差别。生产模式则直接加载打包后的index.html,不依赖任何本地服务。
3.3 键盘事件、输入法与打字判定的处理
打字游戏的核心是键盘事件,但 Electron 里的键盘事件比浏览器要复杂一些。首当其冲的是中文输入法。如果用户开着中文输入法,那么keydown事件会被输入法拦截,你拿到的可能是拼音字母,而不是最终的汉字,打字判定就会出错。
我的方案是在打字引擎里监听compositionstart和compositionend。输入法组合期间,忽略所有keydown,等compositionend拿到最终字符再做判定。只有未进入组合状态时,才把keydown拿到的字符交给handleInput处理。
window.addEventListener('keydown', (event) => { if (isComposing.value) return useTypingEngine.handleInput(event.key) }) window.addEventListener('compositionstart', () => { isComposing.value = true }) window.addEventListener('compositionend', (event) => { isComposing.value = false const char = event.data if (char) useTypingEngine.handleInput(char) })另一个坑是快捷键冲突。Electron 菜单默认带有后退、刷新这些快捷键,Ctrl+R在打字过程中如果被触发,页面会刷新,毁掉当前练习。我的处理是创建一个无菜单栏的窗口,同时在窗口里拦截刷新快捷键。具体的拦截方式我在第四章会写。
3.4 系统语言、快捷键与外部链接等桌面能力接入
桌面应用的独有优势在于能调用系统能力,这些在浏览器里无法实现。我挑了三个最有价值的来接入:获取系统语言、注册全局快捷键、打开外部链接。
系统语言用于词库 i18n 展示。主进程里调用app.getLocale()拿到当前系统语言,通过 IPC 返回给渲染层,Vue 这边根据语言动态切换界面文案。这里有一个小细节:getLocale()返回的是类似zh-CN、en-US的字符串,但不同系统格式不完全一样,建议统一做一次格式化处理。
全局快捷键用于随时唤起练习窗口。我选的是CommandOrControl+Shift+T,主进程用globalShortcut.register注册。回调里判断窗口如果最小化或隐藏,就恢复并聚焦;如果已经打开,就把输入焦点自动放到打字区域,省去用户手动点击。
外部链接的打开方式也值得一提。渲染层遇到target="_blank"的链接,Electron 默认会在应用内新开一个窗口,体验很怪异。我的处理是在主进程监听setWindowOpenHandler,统一交给系统默认浏览器打开:
mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url) return { action: 'deny' } })3.5 electron-builder 打包与国产系统分发注意事项
打包我用的 electron-builder,配置在electron-builder.yml里。基础的打包目标很清晰:
appId: com.typing.game productName: TypingGame win: target: - nsis mac: target: - dmg linux: target: - AppImage - deb category: Utility真的开始分发时,我发现每个平台都有自己的脾气。Windows 上 NSIS 安装包要考虑安装目录的写权限,如果应用要保存成绩文件,不能写到安装目录,要用app.getPath('userData')。macOS 上要考虑签名和公证,否则用户打开会弹“已损坏”或“无法验证开发者”。Linux 上是依赖库的问题,不同发行版缺少的共享库不一样。
这里要重点说说国产系统分发。我在分发目标里加入了对银河麒麟系统的适配,这是基于已有用户反馈做的调整。在银河麒麟这类基于 Linux 的国产系统上,Electron 版本的选择要格外谨慎,部分旧版本 Electron 依赖的 Chromium 组件和系统基础库不兼容,可能直接闪退。我的做法是尽量使用 LTS 版本的 Electron,并在一个干净的国产系统环境里做冒烟测试。打包格式上,deb包在国产系统上的兼容性比 AppImage 更好,因为 AppImage 对 FUSE 的依赖在某些精简系统上无法满足。
注意:如果你的应用要在国产系统分发,务必在真实系统里验证一次完整的安装、启动、使用、退出流程。仅靠交叉打包但没有任何实机验证,很容易出现“开发环境完全正常、目标系统一启动就崩”的情况。
4. 常见问题排查与调试技巧
4.1 白屏、空白窗口与资源路径问题
Electron 应用最常见的故障就是打开后白屏,原因集中在两个地方:生产环境下加载路径不对,或者开发环境下 Dev Server 没有启动成功。
如果是生产模式白屏,先用开发者工具看 Console 里的报错。最常见的是资源路径问题,比如index.html里引用的 JS/CSS 路径是绝对路径/assets/...,在file://协议下找不到。解决方法是把打包配置里的base改成'./',让 Vite 生成相对路径。
electron-vite 里可以在electron.vite.config.js中给 renderer 配置:
export default defineConfig({ main: { ... }, preload: { ... }, renderer: { base: './' } })还有一个隐蔽的路径问题是__dirname。在 Electron 主进程中,开发模式和打包后__dirname指向的目录完全不同。我用app.getAppPath()和app.getPath('userData')来区分代码目录和数据目录,坚决不把数据写到__dirname下,这个习惯帮我避开了很多打包后的麻烦。
4.2 中文输入法与全局快捷键冲突
中文输入法的坑不只是组合状态影响判定,它还会吃掉全局快捷键。比如用户在别的应用里开着中文输入法,按下Ctrl+Shift+T,可能会被输入法或者那个应用的快捷键拦截,Electron 的globalShortcut注册未必能生效。
我的排查思路是两步:先确认注册返回值,register方法返回false说明注册失败,通常是快捷键冲突;再就是尽量选择不那么常用的组合键,避开输入法自身的切换键。比如Ctrl键在多数输入法里是切换中英文的快捷键,Ctrl+Shift组合也容易和输入法切换冲突。我最后选了F8这种功能键,冲突概率大大降低。如果你一定要用组合键,建议绕过Ctrl+Shift这种高频组合。
4.3 打开外链和加载远程资源的正确姿势
桌面应用里打开外链,一定要通过shell.openExternal,不要直接在当前窗口里跳转。前面我在setWindowOpenHandler里做了统一处理,但还有一个场景:应用里如果嵌入了远程资源,比如一个帮助文档网页,那就要特别小心安全策略。
我最初在打字游戏的“帮助”面板里直接加载了一个远程 URL,配合webview标签使用。Electron 官方对webview的评价是“不稳定,不建议使用”,我实测也确实遇到了焦点丢失和样式错乱的问题。后来我把帮助内容改成了本地 Markdown 渲染,再用shell.openExternal引导用户访问完整文档。如果你的应用需要加载远程内容,尽量放在主窗口之外,且对远程页面做严格的权限隔离。
4.4 用 VSCode 和 Playwright 调试 Electron 应用
Electron 应用虽然可以开 DevTools 调试渲染层,但主进程和 preload 的调试体验往往被忽略。实际上,用 VSCode 调试 Electron 非常简单,我配置了一个.vscode/launch.json:
{ "version": "0.2.0", "configurations": [ { "name": "Debug Main Process", "type": "node", "request": "launch", "cwd": "${workspaceFolder}", "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", "windows": { "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" }, "args": ["."], "outputCapture": "std" } ] }配置好之后,F5 就能在主进程代码里打断点,preload 脚本也可以在这里调试。渲染层在 VSCode 里用“JavaScript Debug Terminal”连接上 Chromium 调试端口,两边可以同时断点,整个应用的调用链一目了然。
我还用 Playwright 的 Electron 支持做了一套冒烟测试,自动打开应用、模拟输入一串字符、断言打字结果和成绩保存是否正常。这比手工回归靠谱得多,每次改完架构代码,跑一遍测试就能知道有没有回归。
| 问题现象 | 常见原因 | 解决方案 |
|---|---|---|
| 窗口白屏 | 生产环境资源路径错误 | 将 Vite base 配置为'./' |
| 渲染层拿不到 Node API | 安全配置隔离了 Node | 通过 preload 暴露方法,不要直接require |
| 中文输入法误判 | 键盘事件被输入法拦截 | 监听compositionstart/end,组合期间忽略输入 |
| 全局快捷键无响应 | 快捷键冲突或注册失败 | 检查register返回值,改用低频功能键 |
| 外链在应用内打开 | 未处理setWindowOpenHandler | 拦截后调用shell.openExternal |
| 打包后数据写不进去 | 写入安装目录无权限 | 改用app.getPath('userData')存储数据 |
| 国产系统启动崩溃 | Electron 版本与系统库不兼容 | 使用 LTS 版本,并在真实系统环境验证 |
个人体会是,这种从 Web 到桌面的架构改造,最花时间的不是写代码,而是重新建立一套“哪里该做什么”的心智模型。我刚接触 Electron 时,总觉得不用 Node API 就亏了,后来才意识到,渲染层就该老老实实干 UI 的事,系统能力通过 IPC 调主进程,反而让整个应用边界清晰、好维护。
最后分享一个小技巧:在做这类改造前,可以先把所有系统能力列成一张表,备注清楚“由谁提供、由谁消费、通过什么通道”。这张表画明白了,改造就已经完成了一半。后面遇到问题,回到这张表检查,基本都能定位。