做 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(CommandOrControlShiftT, () { 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 reftextAreaRef classtyping-text tabindex0 keydownhandleKeydown span v-for(char, index) in renderedChars :keyindex :classcharClass(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现在只要按CommandOrControlShiftT无论当前在哪个应用里练习窗口都会立刻弹出再按一次就隐藏整个过程不打断写作、看视频、开会。实现方式我在主进程代码里已经展示就是globalShortcut.register。这里有几个坑必须说明。第一全局快捷键是系统级资源注册失败不会抛异常而是返回false我一开始没有判断返回值结果在某个 Linux 桌面环境上快捷键一直没反应。正确做法是const ok globalShortcut.register(CommandOrControlShiftT, 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 上线后我实际踩过的三个坑第一个坑是全局快捷键注册冲突。我的应用使用CommandOrControlShiftT有用户反馈说按了没反应。排查后发现他的系统上另一个截图工具已经占用了这个组合键。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底层的打字引擎都可以原样带走不被任何一个容器绑架。