做 VSCode 打字扩展做到第三版的时候,我终于扛不住了。Electron + Vue 3 这个组合看起来是"重新造轮子",但真正把这个桌面打字游戏的架构从 VSCode 扩展的壳子里抽出来之后,我才发现之前积累的打字引擎、词库、成绩统计逻辑全都还在,缺的只是适合自己的运行环境。最初我在 VSCode 里用 Webview 写打字练习,功能能跑,但用户要练字必须先打开编辑器、命令面板输入命令,UI 还被压在 tab 栏和状态栏中间,焦点一跑按键全丢,这类体验问题在扩展容器里基本无解。于是我把项目整个重构成一个独立应用:主进程用 Electron 管理窗口和系统能力,渲染层交给 Vue 3 重写,并把 VSCode 扩展里那套逻辑完整迁移过来。这篇文章就把这次架构改造的完整思考、踩坑和落地细节记录下来,适合正在评估"要不要把 Web 工具迁到 Desktop"、或者想用 Electron 做小工具产品的同学参考。
1. 为什么一个好好的 VSCode 打字扩展要推翻重来
1.1 Webview 的天花板:渲染区域和焦点控制根本不达标
我在 VSCode 扩展里实现打字练习的方式,估计不少人能猜到:创建一个 Webview Panel,在 HTML 里渲染一段英文文本,监听 keydown 事件,逐个比对按键。这套方案最大的问题是 VSCode 的 Webview 容器本质上是一个受限的 iframe,渲染区域永远被编辑器框架包围。我试过通过 CSS 把面板撑满、隐藏侧边栏和状态栏,但 tab 栏和活动栏删不掉,分屏模式下布局还会跳动,所谓的"全屏沉浸"根本做不到。
焦点问题更致命。Webview 一旦失焦,keydown 事件就全部失效,而 VSCode 本身会拦截大量的编辑器快捷键。比如单引号、花括号这类符号,正常输入不需要触发任何组合键,但在扩展里经常被 editor 的命令吞掉。我当时的解决方式是临时的:在 keydown 事件里调用 preventDefault,甚至尝试注册局部按键绑定,但这类 hack 行为在新版本 VSCode 里很容易被破坏,用户升级之后问题反复出现。
1.2 打字训练这个场景天生需要"反编辑器"的能力
打字训练软件和代码编辑器的诉求本质上是对着干的。编辑器希望尽可能多地用快捷键完成操作,打字软件则希望用户每次击键都落到被检核的字符上;编辑器需要多窗口、多面板同时工作,打字训练需要全屏、无干扰、背景置顶;编辑器把数据存在工作区或者全局存储里,打字软件则需要把练习记录、自定义词库存在用户能感知的固定位置。
我当时在扩展的 issue 里收到几条印象很深的用户反馈。有人说为了练打字,必须先把一个 VSCode 工程打开,再在命令面板搜扩展名,路径实在太深。还有人希望练习窗口能悬浮在教程视频上面,用全局快捷键随时呼出和隐藏,这在扩展机制里是完全做不到的。这些问题积累到一定程度,就不是修补扩展代码能解决的了,而是平台边界本身的问题。
1.3 独立应用也不是银弹:换壳前要算清楚的三笔账
说句公道话,独立应用不是没有代价,否则我在第一版就能直接做 Electron。第一笔账是 UI 工程量。VSCode 扩展可以直接复用编辑器的主题、字体、组件风格,独立应用里这些全部要自己搭,光是一个符合直觉的设置页面就够写几天。第二笔账是升级链路。扩展市场能自动更新,安装一个 VSIX 或者 marketplace 条目就行,独立桌面应用要用户自己重新下载,或者自己接 auto-updater,这是一笔不小的维护成本。第三笔账是系统兼容性。VSCode 帮开发者处理了 Windows、macOS、Linux 三端的各种差异,Electron 虽然屏蔽了大量底层细节,但窗口管理、通知、开机自启这些能力还是要自己处理平台差异。
我后来评估下来依然决定改造,原因很简单:打字训练这个场景的核心价值在"沉浸、即时、高频",而这三样东西 VSCode 给不了。改造的本质不是重写,是把运行外壳从 VSCode 容器换成 Electron,把渲染层从 Webview 换成 Vue 3,同时保留扩展时代积累的业务逻辑。想清楚这一点,架构的改造方向就明确了。
2. Electron + Vue 3 的架构边界:三个进程各管一摊
2.1 主进程的职责:窗口、快捷键与应用生命周期
改造后的应用采用了 Electron 标准的三层模型,第一层是主进程。主进程负责创建和管理 BrowserWindow、注册全局快捷键、处理应用生命周期(ready、window-all-closed、activate、will-quit)、以及所有涉及文件系统和系统能力的 IPC 接口。
以主窗口为例,我这里直接给出改造后使用的配置:
const { app, BrowserWindow, globalShortcut, ipcMain } = require('electron') const path = require('path') const Store = require('electron-store') const store = new Store() function createWindow() { const win = new BrowserWindow({ width: 1000, height: 700, minWidth: 800, minHeight: 600, frame: false, show: false, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } }) win.loadFile(path.join(__dirname, 'dist/index.html')) win.once('ready-to-show', () => win.show()) return win } app.whenReady().then(() => { createWindow() globalShortcut.register('CommandOrControl+Shift+T', () => { const win = BrowserWindow.getAllWindows()[0] if (!win) return win.isVisible() ? win.hide() : win.show() }) }) app.on('will-quit', () => globalShortcut.unregisterAll())这里有两个很容易犯的错误值得提前说。第一个是show: false加ready-to-show再调用show(),目的是避免白屏闪烁,如果你直接loadFile然后show,几乎每次启动都会有一瞬间的空白。第二个是frame: false之后,窗口的移动和关闭按钮全部要自己用 CSS 和主进程 IPC 实现,后面我会单独讲。
2.2 渲染进程的边界:Vue 3 只负责界面和游戏状态
第二层渲染进程里跑的是 Vue 3 应用,整个打字游戏的界面、状态、动画都在这层完成。虽然标题里写的是 Vue 3,改造时我也认真考虑过要不要用 React、Svelte 或者直接原生 DOM。之所以选 Vue 3,是因为组合式 API 对"状态机 + 派生状态"这类逻辑的表达特别好用:打字游戏的核心数据是当前字符索引、错误数、已用时间,这些是响应式基础;每个字符应该显示成什么颜色、文本块应该滚动到哪个位置、按钮是否可用,这些是典型的 computed 派生状态。用 Vue 3 写,代码会非常贴近业务的语言。
这里的关键架构原则是:渲染进程不碰任何 Node.js API,不直接读文件,不做任何系统级操作。所有需要访问主进程能力的操作,统一走 preload 暴露的接口。这样做的直接好处是,前端代码可以在纯浏览器环境下开发和调试,Vite 起一个 dev server 就能改 UI,不用每次重启 Electron。
2.3 preload 与 contextBridge:隔离安全的通信通道
第三层是 preload 脚本,它是主进程和渲染进程之间的桥。Electron 安全的最佳实践是开启contextIsolation: true、关闭nodeIntegration,然后通过contextBridge暴露一个白名单 API。我给出了改造后的 preload 完整代码:
const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('typingAPI', { saveRecord: (record) => ipcRenderer.invoke('record:save', record), loadRecords: () => ipcRenderer.invoke('record:load'), loadCustomText: () => ipcRenderer.invoke('text:loadCustom'), saveCustomText: (text) => ipcRenderer.invoke('text:saveCustom', text), onToggleShortcut: (callback) => { const listener = () => callback() ipcRenderer.on('shortcut:toggle', listener) return () => ipcRenderer.removeListener('shortcut:toggle', listener) }, windowAction: (action) => ipcRenderer.invoke('window:action', action) })为什么不用ipcRenderer.send而是用ipcRenderer.invoke?因为invoke天然支持异步返回,主进程ipcMain.handle可以返回一个 Promise。保存记录、读取文本这些操作都是异步 IO 的典型场景,invoke/handle让调用方可以用await直接拿结果,代码逻辑和普通函数调用一样,这是我在扩展时代用手写回调协议完全比不了的体验。
对应地,主进程这边注册处理器:
ipcMain.handle('record:save', (event, record) => { const records = store.get('records', []) records.push({ ...record, id: Date.now() }) store.set('records', records) return { ok: true } }) ipcMain.handle('window:action', (event, action) => { const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] if (!win) return if (action === 'minimize') win.minimize() if (action === 'close') win.close() if (action === 'toggleMaximize') { win.isMaximized() ? win.unmaximize() : win.maximize() } })3. 打字引擎迁移:输入捕获与成绩计算的改造细节
3.1 键盘事件:从 webview 局部监听变为应用级捕获
打字游戏最核心的输入捕获逻辑,在 VSCode 扩展里和 Electron 里写起来差别其实不大,都是监听 keydown 然后比对目标字符,但事件源的可靠度完全不同。扩展时代,我的 keydown 绑定在 webview 内部的一个 div 上,一旦这个 div 失去焦点,事件就断了。为了抢焦点,我得在每次点击其他区域后重新focus(),这个行为在编辑器里非常"打架"。
Electron 应用里,keydown 直接监听在window上,只要渲染进程窗口是激活的,事件就一定到。我把之前的编辑器相关 hack 代码全部删掉,只保留纯业务逻辑。处理逻辑大概是这样的:
function handleKeydown(event) { if (state.phase === 'finished') return if (event.key === 'Escape') { state.phase = 'paused' timer.stop() return } if (event.key.length !== 1) return const expected = currentCodePoints[state.cursor] if (event.key === expected) { state.cursor += 1 if (state.cursor === currentCodePoints.length) { finishSession() } } else { state.errors += 1 errorSet.add(state.cursor) } }有一点值得提,文本的split('')对英文完全没问题,但如果你想做中文拼音训练或者混合文本,必须用Array.from()或Intl.Segmenter按码点切分,否则遇到 emoji 或代理对字符会切出半个。我当时没注意,用户在自定义词库里放了几个表情符号,结果整个光标索引全对不上。
3.2 字符分片渲染:用 Vue 的 v-for 替代手写 DOM 更新
扩展时代,我每次按键都要手动操作 DOM,给正确字符加 class、移除错误字符的 class,还要手动更新 scrollTop。改造成 Vue 3 之后,这个逻辑简化成了一种"渲染即状态的投影"的方式。
<p ref="textAreaRef" class="typing-text" tabindex="0" @keydown="handleKeydown" > <span v-for="(char, index) in renderedChars" :key="index" :class="charClass(index)" >{{ char }}</span> </p>对应的派生状态:
const renderedChars = computed(() => Array.from(state.text)) const charClass = (index) => ({ 'char-current': index === state.cursor, 'char-correct': index < state.cursor, 'char-error': index < state.cursor && state.errorSet.has(index) })这里最爽的一点是:我不需要关心错误字符如何"变绿变红",只需要维护好state.cursor和state.errorSet两个状态,Vue 的响应式系统会自动完成 DOM diff。实际体验中,即便是快速连续输入,Vue 3 的更新性能也完全够用,没有出现打字领先光标的情况。
光标定位和自动换行是一对容易打架的难点。我的做法是给当前字符一个特殊 class,在里面加一个::after模拟下划线光标,再用watch监听state.cursor,当光标跑出可视区域时,让参与渲染的容器scrollTop移动到当前行的位置。这里踩过一个坑:如果文本容器有padding,行高的计算必须把 padding 排除,否则滚动永远差几像素。
3.3 WPM、正确率与错误热区的统计口径
成绩统计看起来简单,其实口径差异很大。我在扩展时代被用户吐槽过两次。第一次是我把 WPM 按"总击键数 / 5 / 分钟"算,结果用户快速乱按错误键,WPM 反而很高,这显然不对。正确的 WPM 应该只计算正确击键数,公式:
WPM = (correctKeystrokes / 5) / (elapsedSeconds / 60)其中correctKeystrokes是"已正确输入的字符数",而不是用户敲了多少下键盘。这样连续错键不会提高成绩,反而会拉低正确率,更接近真实的打字水平。
第二次问题是错误率的口径。如果按"错误键次数 / 总按键次数"计算,一次字符反复敲错五次会被重复惩罚,对用户不公平。我改成错误率按"包含错误击键的字符位置"来算:一个字符位置只要有任意一次错误击键,就算一个错误字符,最终错误率是错误字符数除以总字符数。这样一个难词敲错三次和敲错一次,在错误率上是一样的,但会在"错误热区"里记录更多次数,用来生成高频错误词表。
计算逻辑在 Vue 3 里我用一个简单的state对象维护:
const stats = reactive({ correctKeystrokes: 0, totalErrors: 0, errorSet: new Set(), startTimestamp: 0, elapsedSec: 0 })会话结束时把快照交给主进程存储,渲染进程只负责展示,不负责持久化,这样职责比较清楚。
4. Vue 3 组合式 API 重构游戏状态机
4.1 用 ref 和 reactive 描述游戏的四态流转
打字游戏的界面状态不是一两个布尔值能描述的,它是典型的有限状态机。改造时我用一个phase字段表示游戏阶段:
const phase = ref('ready') // ready | running | paused | finished const gameState = reactive({ text: '', cursor: 0, errorSet: new Set(), phase: 'ready', correctKeystrokes: 0, totalErrors: 0, startTimestamp: 0 })阶段之间的流转我写在一个transition函数里,避免散落在各个事件处理器中:
function transition(nextPhase) { switch (nextPhase) { case 'running': if (gameState.phase === 'ready' || gameState.phase === 'paused') { if (!gameState.startTimestamp) { gameState.startTimestamp = Date.now() } else { // 从暂停恢复时,需要补偿暂停期间的时间 gameState.startTimestamp += Date.now() - pauseStartTimestamp } } break case 'paused': pauseStartTimestamp = Date.now() break case 'finished': gameState.elapsedSec = (Date.now() - gameState.startTimestamp) / 1000 break } gameState.phase = nextPhase }为什么单独写一个 transition?因为字打得快的时候,各种事件可能交叉触发,blur导致暂停、键盘事件又触发恢复,如果状态直接散在事件里改,很容易出现"paused 状态下还在计时""finished 后还能继续输入"这类 bug。用一个集中的状态流转函数,所有修改 phase 的路径都过同一扇门,才能保证一致性。
4.2 计时器竞态:倒计时与暂停的坑
打字游戏的计时器是最容易出 bug 的地方。扩展时代我用setInterval每秒更新一次elapsed,问题在于setInterval在后台标签页会被浏览器节流,导致时间不准。Electron 渲染进程虽然默认没有节流,但窗口最小化或者系统睡眠时还是有偏差,而且暂停/恢复逻辑处理不好会累积误差。
我的解法是"不攒时间,只记快照"。渲染进程里维护startTimestamp和pauseStartTimestamp两个时间戳,而不维护一个不断自增的计数字段。需要展示时,实时用Date.now() - startTimestamp计算;暂停时记录pauseStartTimestamp,恢复时把暂停时长从startTimestamp里补回来。这样无论窗口怎么切、系统怎么睡眠,只要时间戳是对的,计算出的elapsedSec一定准确。
const elapsedSec = computed(() => { if (phase.value === 'running') { return (Date.now() - gameState.startTimestamp) / 1000 } if (phase.value === 'paused') { return (pauseStartTimestamp - gameState.startTimestamp) / 1000 } return gameState.elapsedSec })显示层是一个 60fps 的requestAnimationFrame循环去读elapsedSec,不触发 Vue 的响应式更新,只在 DOM 上直接改数字文本,这样既不会让响应式系统崩溃,也能保持秒表流畅。
4.3 动态加载自定义词库:computed 派生文本流
VSCode 扩展时代,自定义词库是通过workspace.fs读取 JSON 文件,然后同步到 Webview。改造后,我用window.typingAPI.loadCustomText()从主进程读文件,文本内容进入一个ref,再由 computed 派生字符数组。
const customText = ref('') const exerciseText = computed(() => { if (mode.value === 'lesson') return builtinLesson if (mode.value === 'custom') return customText.value return builtinLesson })这里有个细节,当用户切换练习模式时,cursor、errorSet、correctKeystrokes必须全部重置,否则会显示上一个模式的进度。我把重置逻辑封装成resetGameState(),在watch(exerciseText)里自动调用。很多新手最容易遗漏的是:文本本来是一样的,切换模式不重置看起来没效果,一旦文本变化,残留的 cursor 就会超出字符串长度导致渲染异常。
动态词库还需要支持"单词模式"——用户选择一个词库文件后,系统按单词而不是按整段文字出题。这个我用一个wordList的 computed 派生,每次会话开始时从词库随机抽取固定数量的单词拼成练习文本。随机抽取要避免同一个单词反复出现太多,我用了一个shuffle加take的组合,代码很简单,但比扩展时代手写的随机逻辑稳定很多。
5. 独立应用才有的几张牌:全局快捷键、无边框窗口与本地存储
5.1 globalShortcut 让练习窗口随叫随到
独立应用最明显的一个能力升级是全局快捷键。扩展时代的用户想切出打字窗口,必须回到 VSCode;现在只要按CommandOrControl+Shift+T,无论当前在哪个应用里,练习窗口都会立刻弹出,再按一次就隐藏,整个过程不打断写作、看视频、开会。
实现方式我在主进程代码里已经展示,就是globalShortcut.register。这里有几个坑必须说明。第一,全局快捷键是系统级资源,注册失败不会抛异常,而是返回false,我一开始没有判断返回值,结果在某个 Linux 桌面环境上快捷键一直没反应。正确做法是:
const ok = globalShortcut.register('CommandOrControl+Shift+T', handler) if (!ok) { console.warn('[Typer] 全局快捷键注册失败,可能被其他应用占用') }第二,快捷键必须在app.whenReady()之后注册,而且如果应用没有设置app.setLoginItemSettings开机自启,用户每次手动启动应用后快捷键才生效。第三,退出前一定要globalShortcut.unregisterAll(),否则某些平台上会残留系统级监听。
5.2 无边框窗口的拖拽、置顶与缩放
为了让打字界面更像一个"训练工具"而不是"普通应用",我把窗口做成了无边框样式。无边框的第一个麻烦是窗口拖不动了,解决方案是在标题栏区域加 CSS 属性:
.titlebar { -webkit-app-region: drag; height: 40px; user-select: none; } .titlebar button { -webkit-app-region: no-drag; }记住:-webkit-app-region: drag的子元素默认也是可拖拽区域,所以所有可交互控件(按钮、输入框、下拉框)必须显式设置为no-drag,否则鼠标点击事件会被窗口系统吃掉。
置顶功能用win.setAlwaysOnTop(true)实现。我把这个能力开放给了用户,允许在设置面板里选择"练习窗口置顶"。实测下来,置顶窗口 + 半透明背景是练习时最舒服的组合,尤其是跟着视频教程练指法的时候,窗口悬浮在视频旁边,不占额外屏幕空间。
自适应缩放方面,因为窗口最小宽度是 800px,文本区域我做了max-width: 820px居中,字号用clamp()做响应式。字体大小在打字软件里是刚需,最好在设置里用一个 slider 控制字号,渲染进程通过 CSS 变量动态更新:
.typing-text { font-size: var(--typing-font-size, 24px); line-height: 1.8; }5.3 数据该存哪:electron-store 的取舍
扩展时代,练习记录存放在 VSCode 的globalState里,优点是零配置,缺点是用户完全感知不到数据存在哪里,想备份或者迁移非常困难。改造后我用了electron-store来做本地持久化,它是一个基于 JSON 文件的库,API 和 localStorage 几乎一样,但数据是落在用户数据目录下。
const Store = require('electron-store') const store = new Store({ name: 'typer-data', defaults: { records: [], customText: '', settings: { fontSize: 24, theme: 'dark', soundOn: true, alwaysOnTop: false } } })我选择 electron-store 而不是 SQLite 或者直接手写 JSON 文件,原因是打字练习的记录结构很简单,就是一个数组,用不着引入数据库依赖;而直接手写 JSON 文件要考虑写冲突、原子写入、迁移兼容,这些 electron-store 已经处理好了。
不过 electron-store 不是万能的,它同步读写文件,如果你存的数据特别大(比如上万条练习记录),每次store.set()都会有一次完整的 JSON 序列化和磁盘写入,虽然对打字应用来说性能足够,但如果你后续想做复杂统计(按日期、按词库分组查询),还是趁早上 SQLite,提前设计表结构。我在改造时预留了一个storage抽象层,渲染进程调saveRecord接口,底层是 electron-store 还是 sqlite,可以随时切换,不用改业务代码。
6. electron-builder 打包与上线后的实测排坑
6.1 打包配置:从 icon 到 asar 的完整清单
改造的最后一步是把应用打包成可分发的安装包。我用的是 electron-builder,配置写在package.json的build字段里,核心部分如下:
{ "build": { "appId": "com.typer.desktop", "productName": "Typer", "files": [ "dist/**", "main.js", "preload.js", "package.json" ], "directories": { "output": "release" }, "asar": true, "win": { "target": ["nsis"], "icon": "build/icon.ico" }, "mac": { "target": ["dmg"], "category": "public.app-category.education" }, "nsis": { "oneClick": false, "allowToChangeInstallationDirectory": true } } }这里最容易被忽略的是files字段。如果你用 Vite 构建 Vue 3,输出目录是dist,但如果你忘了把main.js和preload.js加进去,打包出来的应用一启动只有一个空窗口,因为主进程没了。我最初调试时犯了低级错误,用electron .启动没问题,一打包就白屏,查了半天才发现是files漏了preload.js。
asar: true会把应用代码打包进一个归档文件,安全性更高、启动更快,但代价是内部文件路径会变成app.asar/dist/index.html这种虚拟路径。主进程加载页面时绝对不能写死相对路径,要像我前面那样用path.join(__dirname, 'dist/index.html'),__dirname在 asar 环境下也能正确展开,这是打包后页面 404 问题的主要来源。
6.2 上线后我实际踩过的三个坑
第一个坑是全局快捷键注册冲突。我的应用使用CommandOrControl+Shift+T,有用户反馈说按了没反应。排查后发现他的系统上另一个截图工具已经占用了这个组合键。globalShortcut.register返回false,但我之前没有把这种失败暴露到 UI。现在我会在快捷键冲突时弹一个提示,让用户换一个键位。
第二个坑是打包后字体和静态资源的加载路径问题。我在 Vue 应用里引用了本地字体文件,开发模式用的是 Vite 的 base 路径/,打包之后变成了file://协议,如果 base 路径不对,字体和图片全部加载失败。解决办法是在 Vite 配置里设置base: './',保证所有的静态资源路径都是相对路径。
第三个坑是 macOS 上应用退出后 dock 图标不消失。因为我在window-all-closed事件里没有特殊处理 macOS 的惯例。标准做法是:
app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } })但打字工具比较特殊,全局快捷键是核心功能,即使用户关掉了所有窗口,快捷键仍可能触发新窗口弹出来。所以我改成了:关闭窗口不退出应用,只隐藏窗口,用户可以从托盘菜单真正退出。这样"随叫随到"的体验才完整。
6.3 架构改造后的性能表现与维护成本
改造完成跑了一段时间之后,我可以给出一些真实的对比数据。应用启动时间大约 1.2 秒到 1.8 秒,相比 VSCode 冷启动需要 3 到 5 秒,体验提升非常明显。内存占用方面,打包后的应用稳定在 150MB 到 250MB 之间,对现代电脑来说完全能接受,但确实比一个纯 Vue 网页要高,这是因为 Electron 的 Chromium 运行时占了很大一部分。
从维护成本来看,Vue 3 的响应式状态管理让核心逻辑的单元测试变得容易太多。我在扩展时代几乎没法写自动化测试,因为所有逻辑和 Webview DOM、编辑器 API 纠缠在一起;改造后我把打字引擎的逻辑抽成了纯 TypeScript 函数,输入是一个keydown事件对象,输出是新的状态快照,可以毫不费力地写测试用例。这里我只举一个例子:
it('输入正确字符时 cursor 前进一位', () => { const state = createInitialState('hello') const next = handleKey(state, { key: 'h' }) expect(next.cursor).toBe(1) })这类测试在改造前根本不敢想,也是这次架构改造最值回票价的一部分。对于还在 VSCode 扩展里憋功能、深受平台限制的同学,我的建议是先把业务逻辑沉淀成纯函数,让平台相关代码只做薄薄一层封装,这样无论以后是换 Web 端、移动端、还是 Electron,底层的打字引擎都可以原样带走,不被任何一个容器绑架。