尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从VSCode扩展到Electron+Vue3:打字游戏架构改造实战

从VSCode扩展到Electron+Vue3:打字游戏架构改造实战 很多人看到这个项目名第一反应都是打字游戏为什么要塞进 VSCode又为什么最后要拆出来改成 Electron Vue 3 的独立桌面应用我最初只是想在开发间隙练练手速所以顺手做了一个 VSCode 扩展把游戏跑在编辑器内置的 webview 里。后来发现天花板太明显才决定做一轮架构改造把游戏逻辑保留下来只把平台层整个替换掉。这篇文章不打算写成一个“练手 demo”的教程我会重点聊三件事VSCode 扩展和 Electron 应用在架构上到底差在哪、如何把游戏引擎和界面壳子解耦、以及从打包到自动化测试会遇到哪些真实问题。如果你正打算把一个 webview 原型改造成独立桌面应用或者刚开始用 Electron Vue 3 做工具类软件这篇应该能帮你少走很多弯路。1. 为什么把打字游戏放进 VSCode又拆出来变成独立应用1.1 VSCode 扩展里的“临时舞台”最开始做这个打字游戏动机非常简单我每天有大量时间待在 VSCode 里想利用碎片时间练练英文打字速度又不想切到浏览器。VSCode 扩展刚好提供了一个不错的舞台。用WebviewPanel注册一个自定义面板里面塞一套网页界面打字游戏就能跑在编辑器内部。这个方案的启动成本很低不需要自己搭窗口、不需要处理安装包扩展打包成.vsix就能装。但“低启动成本”的另一面是“低能力上限”。VSCode webview 本质上是一个被编辑器环境托管 iframe跨域、剪贴板、文件系统、系统级快捷键这些能力都受限。我想做几件事越做越别扭想让游戏支持自定义文章库直接从本地 Markdown 文件导入文本但 webview 不能随便读文件想记录历史打字成绩并生成一个周维度趋势图数据只能塞到context.globalState里本质是一个小 JSON不太适合做结构化数据想按一个全局快捷键不管当前焦点在哪都能暂停/开始游戏VSCode 扩展里实现很绕想把窗口单独拉出来全屏用沉浸式布局消灭编辑器的信息干扰webview 做不到。这些需求单独看都不致命但堆在一起就说明一个问题项目形态需要从“编辑器里的玩具”变成“独立桌面工具”。1.2 从扩展到独立应用的三个变化严格说这次改造不是重写而是“移植 换壳”。我给自己定下三个方向第一界面尽量复用。游戏界面在 VSCode webview 里已经写了一版 Vue 3 组件这套东西可以原样搬进 Electron 的渲染进程不需要推倒重来。第二逻辑层必须独立。之前游戏逻辑和 webview 通信代码揉在一起这次先抽出TypingEngine类让它不依赖 VSCode API、也不依赖 electron API纯粹用 TypeScript 写判定逻辑。第三平台能力统一收口。所有和桌面系统相关的能力例如读取文件、保存记录、全局快捷键都通过 preload 暴露给渲染进程UI 层不关心背后的实现是 VSCode API 还是 Electron IPC。最终落地之后我体会到一句很实在的话架构好坏不看你用了多高级的框架而看你换了个运行环境后要改多少行代码。这次替换平台层时我大概只改了几个 adapter 文件游戏引擎和 Vue 组件都没动。2. 架构改造把 WebView 时代的分层平滑搬到 Electron 里2.1 VSCode WebView 到 Electron BrowserWindow 的映射VSCode webview 和 Electron BrowserWindow 在表面上很像都是“一个网页容器”但机制不同webview 里跑的是编辑器进程管理下的特殊页面通信要依赖postMessage和onDidReceiveMessageElectron 里则是标准 Chromium 页面但出于安全默认不开nodeIntegration所以也需要一条主进程和渲染进程之间的桥。两者角色的对应关系如下功能VSCode 扩展Electron 应用后台逻辑进程extension hostElectron main process界面容器WebviewPanelBrowserWindow渲染层webview 内页面渲染进程Vue 3通信桥acquireVsCodeApi().postMessagepreload contextBridge数据持久化context.globalState / MementouserData 目录下的 JSON / SQLite发布形态.vsix 扩展包安装包NSIS / AppImage / deb / dmg这个表是我做架构规划时的核心索引。每个格子都代表一个“平台适配点”。我的做法是写一个PlatformBridge接口VSCode 版本和 Electron 版本各实现一套。接口长得像这样interface PlatformBridge { saveRecord(record: TypingRecord): Promisevoid loadRecords(): PromiseTypingRecord[] readTextFile(): Promisestring | null onResetGame(callback: () void): void }UI 层只依赖这个接口不直接触碰ipcRenderer也不直接触碰 VSCode API。这样最大的好处是我可以在浏览器里开一个 dev server用 Mock 的 bridge 开发界面到 Electron 里运行时再把真实现注入进去。2.2 Vue 3 组合式 API 怎么组织打字游戏的状态界面端我用了 Vue 3 的组合式 API。为什么不选选项式因为打字游戏的状态变化非常高频按键命中、错误高亮、实时速度、进度条这些状态之间互相影响用setupref/reactive/computed组织起来更直观逻辑可以按“特征”聚合而不是被迫按data、methods硬分类。比较典型的做法是把游戏状态拆成几个独立模块// useTypingGame.ts export function useTypingGame() { const engine new TypingEngine() const progress ref(0) const currentChar ref() const status refidle | running | finished(idle) const stat reactive({ cpm: 0, accuracy: 1, time: 0, }) function onKeydown(event: KeyboardEvent) { if (status.value ! running) return const result engine.type(event.key) updateUI(result) } return { progress, currentChar, status, stat, onKeydown } }这里有个设计细节engine是普通 TypeScript 对象不是reactive。按键事件每次触发都会改变它的内部状态如果把它直接丢进 Vue 响应式系统每次按键都会触发多次依赖收集和更新白白消耗性能。正确姿势是让引擎保持“朴素的类”只在需要展示时才把结果写入ref/reactive。这一点对打字、音乐播放器、画板这类高频输入场景尤其重要。2.3 游戏引擎与 UI 解耦避免“页面一卡就去背锅”我见过很多小游戏项目最终卡顿都不是页面渲染引起的而是把状态判定的计算也塞进了渲染线程。打字游戏的判定逻辑本身不复杂但如果每次按键都同步做大量数组操作再更新多个图表帧时间就会明显变长。所以这次我把判定逻辑放到了独立的TypingEngine类里和 Vue 组件完全隔离。组件不需要知道引擎内部怎么处理退格、怎么统计正确率它只调用type()、reset()、deleteLast()然后读取返回值。组件可以做记忆化渲染例如只有当前字符变化时才更新整行高亮而不是每次按键都重建整个文章面板。潜在收益可以看这个对比没有解耦按键 → 修改 Vue data → 触发整行 diff → 触发子组件重渲染解耦之后按键 → 引擎修改内部状态 → 组件按需读取 → 最小粒度更新。实际体感是在很长一篇文章上打字光标移动不会出现肉眼可见的延迟。这也是我强烈建议把所有“规则型逻辑”都放在框架外管理的原因。3. 打字游戏核心模块的效率实现3.1 输入判定算法退格、重输、错误笔数怎么算打字游戏最核心的算法是输入判定。需求看起来很简单给一段英文文章用户照着打打错的字符标记出来最后统计正确率和速度。但细节藏猫腻退格、重输、多打字符、中英文输入法干扰全都是坑。我用的判定方式比较朴素但实测效果稳定以“期望文本”和“当前已输入文本”做逐字符比较用Math.max(0, 期望长度 - 当前输入长度)控制比较范围。正确率用“正确匹配数 / 总敲击数”计算这样能避免退格造成的虚高数据。核心逻辑如下export class TypingEngine { private target: string[] private input: string[] [] private totalTyped 0 private startTime 0 constructor(text: string) { this.target text.split() } start() { this.startTime performance.now() } type(key: string) { this.totalTyped this.input.push(key) return this.snapshot() } deleteLast() { this.input.pop() return this.snapshot() } private snapshot() { let matched 0 const len Math.min(this.target.length, this.input.length) for (let i 0; i len; i) { if (this.target[i] this.input[i]) matched } const elapsed (performance.now() - this.startTime) / 1000 const minutes Math.max(elapsed / 60, 0.001) const cpm Math.round(matched / minutes) const rawCpm Math.round(this.totalTyped / minutes) const accuracy this.totalTyped 0 ? 1 : matched / this.totalTyped return { matched, total: this.target.length, progress: this.target.length 0 ? 0 : matched / this.target.length, cpm, rawCpm, accuracy, elapsed, } } }这里有一个容易忽略的点deleteLast不减少totalTyped。因为打错后用户必须按退格退格本身也是一个敲击动作。把退格算进去得到的是“毛速 rawCpm”只统计正确到达位置的是“净速 cpm”。两个指标都展示用户不会自我感觉过于良好。3.2 可视化键盘与按键回馈网页端打字游戏通常只显示文本但桌面端用户对“沉浸感”期待更高。我加了一个实时键盘可视化面板按下哪个键屏幕上的键盘对应按键就亮起来命中是绿色打错是红色。实现时有一个坑尽量监听KeyboardEvent.code不要监听key。因为key会受到输入法、大小写、系统布局影响而code代表物理按键。这样即便用户切成俄语键盘布局可视化键盘也能正确反映手指位置。一个简易的键盘行结构const ROWS [ [Backquote, Digit1, Digit2, /* ... */], [KeyQ, KeyW, KeyE, /* ... */], // ... ]按下时用event.code查表找到对应按键的坐标集合设置高亮状态。这里要用keydownkeyup两个事件避免按住的按键一直亮着导致视觉混乱。另外我加了一个隐藏的输入框页面全局点击后自动focus()键盘事件才能稳定捕获。3.3 练习记录的本地持久化数据持久化这部分Electron 比 VSCode webview 自由得多。我把记录写成 JSON 文件放到app.getPath(userData)目录下按天聚合成一条记录。每次游戏结束调用一次保存用ipcMain.handle和ipcRenderer.invoke完成。写文件时要注意两点不能每打一个字符就写一次文件。打字游戏一局可能有几千次敲击高频写盘不仅浪费 SSD还容易让主进程卡住。我在渲染进程侧做了“结束时统一提交”加上主进程侧 300ms 的写入防抖避免连续多次结束游戏时挤在一起。不能直接用fs.writeFileSync在渲染进程里写除非你开了nodeIntegration。安全上建议保持contextIsolation: true通过 preload 暴露白名单 API// preload.ts import { contextBridge, ipcRenderer } from electron contextBridge.exposeInMainWorld(typingAPI, { saveRecord: (record: unknown) ipcRenderer.invoke(typing:save-record, record), loadRecords: () ipcRenderer.invoke(typing:load-records), })渲染进程里调用window.typingAPI.saveRecord(...)不直接感知 Node 环境。这种边界清晰的设计就是标题里说的“独立应用架构改造”最值钱的部分。3.4 计时驱动用 requestAnimationFrame 而不是 setInterval打字游戏要实时显示已经用时、CPM、正确率很多人第一反应是setInterval每秒更新一次。但在桌面应用里setInterval可能会因为主线程阻塞而产生漂移而且定时器回调如果做的工作多了会有明显的卡顿感。我改用requestAnimationFrame驱动统计更新但做了一层节流每 100ms 才把引擎的 snapshot 写到 Vue 响应式变量里其余帧直接跳过。这样既保证了视觉平滑又不会让响应式系统被高频频繁触发。let lastTick 0 function loop(ts: number) { if (ts - lastTick 100) { const s engine.snapshot() stat.cpm s.cpm stat.accuracy s.accuracy stat.time s.elapsed lastTick ts } if (status.value running) { requestAnimationFrame(loop) } }这套做法好在哪里游戏不运行的时候循环停止不浪费资源运行时最多每 100ms 更新一次人眼感知不到延迟CPU 占用却低很多。4. Electron Vue 3 工程化搭建与打包4.1 Vite Electron electron-builder 的项目骨架如果你现在从零搭一个 Electron Vue 3 项目建议直接用 Vite 作为渲染进程构建工具再配合 electron-builder 做打包。我的项目结构大致如下typing-game/ ├── src/ │ ├── main/ │ │ └── index.ts # Electron 主进程 │ ├── preload/ │ │ └── index.ts # preload 脚本 │ └── renderer/ │ ├── index.html │ └── src/ │ ├── App.vue │ ├── engine/ │ │ └── TypingEngine.ts │ └── components/ ├── package.json ├── electron-builder.yml └── vite.config.ts主进程和渲染进程如果是两个构建目标最简单的方式是让 Vite 分别处理。开发时渲染进程跑 dev server主进程加载process.env.VITE_DEV_SERVER_URL生产环境渲染进程构建到dist目录主进程加载dist/index.html。关键代码import { app, BrowserWindow } from electron import path from node:path function createWindow() { const win new BrowserWindow({ width: 1000, height: 700, webPreferences: { preload: path.join(__dirname, ../preload/index.js), contextIsolation: true, nodeIntegration: false, }, }) if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(process.env.VITE_DEV_SERVER_URL) } else { win.loadFile(path.join(__dirname, ../renderer/index.html)) } } app.whenReady().then(() { createWindow() app.on(activate, () { if (BrowserWindow.getAllWindows().length 0) createWindow() }) })这里有个新手常见的坑__dirname在生产构建后的路径会发生变化所以 preload 路径和loadFile路径最好通过app.getAppPath()拼接而不是写死相对路径。4.2 preload、contextIsolation 与 IPC 调用边界Electron 有一个历史包袱早期为了方便很多人直接开nodeIntegration: true然后在页面里用require(fs)。这个写法在内部工具里能用但安全隐患极大一旦页面被注入外部脚本整个系统都可能暴露给你不可控的代码。我的选择是用安全默认值contextIsolation: true、nodeIntegration: false、sandbox: true。渲染进程只能通过window.typingAPI访问主进程暴露的少数方法。这个改动的代价是需要多写一层 preload但对独立应用来说安全边界是产品上线前必须补齐的。主进程侧用ipcMain.handle注册对应的处理函数处理完后返回结果。如果业务逻辑复杂建议把 IPC handler 拆成多个文件按领域聚合不要一个文件写几千行。我踩过一次教训把所有 handler 写在 main.ts 里后期改一个功能得翻几百行才能定位重构成本特别高。4.3 electron-builder 打包与 Linux 分发格式打包配置我写在electron-builder.yml里最简化版本长这样appId: com.example.typinggame productName: TypingGame directories: output: release files: - dist/** - dist-electron/** win: target: - nsis linux: target: - AppImage - deb mac: target: - dmg这里说一个分发层面的经验Windows 下 NSIS 默认生成的是安装引导程序如果用户机器没有管理员权限推荐额外生成一个portable版本解压即用。Linux 下常见桌面环境对 AppImage 和 deb 的支持比较好建议打包时都带一份。如果你面对的是需要离线安装、不开源的内部办公环境deb 或 rpm 可能比 AppImage 更好分发因为不依赖 FUSE 环境。打包后体积是另一个容易忽视的问题。Electron 自带 Chromium体积天然就在 80MB 以上。想减小体积重点不是压缩资源而是确认files配置里没有把整个node_modules打进去。我只打包构建产物生产依赖尽量少。另外electron-builder 默认会生成很多语言包如果你的应用只提供中英文可以用electronLanguages配置裁剪。4.4 新项目最容易踩的 5 个配置坑我整理了自己在搭建阶段出过的几个问题值得直接抄进笔记路由模式。Vue Router 如果开了 history 模式打包后用file://加载页面会白屏。桌面应用建议用createWebHashHistory这是最省事的方案。资源路径。Vite 构建的默认 base 是/放到 file 协议下需要改成相对路径base: ./否则图片、字体全部 404。开发者工具和菜单。生产环境记得Menu.setApplicationMenu(null)不然默认菜单里会有“检查元素”“重新加载”看起来很不专业。Electron 缓存。改完主进程代码后如果界面还显示旧内容先确认是不是 dev server 缓存必要时重启 electron。杀毒软件误报。未签名的 Electron 应用在 Windows 上经常被 SmartScreen 拦截这个没办法靠配置彻底解决最好提前准备代码签名证书。没有证书前至少要把appId和productName设置成正式的不要用默认electron名字。5. 实操中的特殊场景与自动化测试5.1 全局快捷键和系统冲突因为我把游戏当作独立的“随时可以唤出”的工具所以加了全局快捷键后台无论焦点在哪按CommandOrControlShiftT都能暂停或继续游戏。Electron 的globalShortcut接口很简洁import { globalShortcut } from electron app.whenReady().then(() { const ok globalShortcut.register(CommandOrControlShiftT, () { win.webContents.send(typing:toggle) }) if (!ok) { console.log(全局快捷键注册失败可能被系统或其他应用占用) } }) app.on(will-quit, () { globalShortcut.unregisterAll() })这里有两个教训。第一注册失败不代表代码写错而是可能与系统已有快捷键冲突例如截图工具、输入法切换可能已经占用了组合键。所以要检查返回值在 UI 里给用户提示而不是无声失败。第二应用退出前必须unregisterAll()否则快捷键在应用退出后仍可能残留影响其他程序。游戏界面里弹出一个“按快捷键暂停”的提示我选择在渲染进程侧监听typing:toggle事件不强迫用户找窗口来点击按钮。这类细节决定了桌面应用和网页应用的体验差距。5.2 用 Playwright 连接 Electron 做回归测试打字游戏的逻辑虽然简单但改 UI 的时候很容易把判定逻辑搞坏。我引入 Playwright 的 Electron 支持做端到端回归。其实不用额外起 Chromium直接用 Playwright 连接 Electron 应用本身import { _electron as electron } from playwright const app await electron.launch({ args: [dist-electron/main.js] }) const page await app.firstWindow() await page.waitForSelector(#app) // 模拟一次按键输入 await page.keyboard.type(Hello)这种测试的价值是能模拟真实按键事件、验证 Vue 组件挂载、检查主进程和渲染进程之间的 IPC 是否正常。写个简单的冒烟测试脚本每次改版前跑一遍比我手工点半天强得多。特别注意的一点Playwright 连接 Electron 需要应用先进入可调试状态生产构建里如果关了远程调试端口测试就用不了。我一般单独保留一个E2E1环境变量来控制启动参数。5.3 性能排查为什么打字输入开始掉帧这个项目踩过最典型的性能坑是在输入一段时间后发现快速打字时页面明显掉帧。排查路径是这样的先用 Electron 自带的性能面板记录一段输入操作发现Scripting时间很高但网络和渲染很平稳。对 Vue 组件做响应式依赖分析后发现每次按键都会更新一个包含整个文章段落的大响应式对象导致所有字符组件都重新比较。优化方案是改成“只更新已输入长度和当前错误索引集合”文章内容用非响应式变量存储组件通过v-for下一层子组件做 memo。另外如果游戏里开了音效每次按键都创建新的 Web Audio 节点也会造成 GC 压力。简单音效可以复用同一个AudioContext和 oscillator 节点只在触发时修改频率不要每按一次键就new一个节点。6. 遇到问题怎么排查速查表很多问题不是理论不清楚是排查路径浪费了太多时间。我整理了一张常用的问题速查表遇到现象直接对表查原因现象可能原因常规解法打包后打开白屏Vite base 路径不对 / 路由用了 history 模式设置base: ./路由改成createWebHashHistorywindow.typingAPI为 undefinedpreload 路径不对 / 构建后 preload 没输出检查主进程中的 preload 绝对路径确认构建产物存在IPC 调用没反应ipcMain.handle和ipcRenderer.invoke通道名不一致日志打印通道名保持主进程和 preload 名称一致全局快捷键无响应被系统或其他应用占用检查register返回值换一组组合键打字延迟但 UI 不卡响应式对象范围太大把高频状态拆细子层组件做 memo安装包体积异常大node_modules被打进去了检查 electron-builderfiles配置和依赖安装范围dev 模式正常打包后菜单还在生产模式没有禁用菜单在ready后按环境变量调用Menu.setApplicationMenu(null)这张表没法覆盖所有问题但它提供了一个排查思路先区分是“渲染层问题”还是“主进程问题”。白屏、绑定不上 bridge通常是渲染层和构建配置的问题快捷键、文件、窗口生命周期通常是主进程的问题。分开看效率高很多。这次重构给我最大的收获不是学会了 Electron而是更清楚了“平台壳”和“业务逻辑”之间应该怎么划界。原来在 VSCode 扩展里写下的游戏引擎搬到 Electron 之后一行没改真正要改的、要调试的全是那些和窗口、进程、文件系统相关的适配代码。如果你也正打算把一个原型重构成桌面应用我建议不管最后选什么框架先把业务逻辑从平台代码里拆出来。拆完之后你会发现换壳这件事远比你想象的简单。
返回列表