做 VSCode 打字扩展做到第三版时,我萌生了一个想法:把它抽出来,做成一个真正的桌面打字游戏应用。VSCode 里的 Webview 扩展虽然方便,但受限于编辑器窗口和扩展 API 的边界,做全屏专注模式、自定义快捷键、系统级菜单这些事总觉得隔了一层。于是我用 Electron + Vue 3 做了一次完整的架构改造,把原来挂在编辑器里的扩展逻辑,重构成一个独立分发的桌面应用。这篇文章就是这次改造的完整复盘,包含技术选型、架构分层、核心引擎封装、打包分发和踩坑记录,适合正在做 Electron 应用、或者想把 VSCode 扩展升级成独立产品的开发者参考。
1. 改造动机:为什么把 VSCode 扩展拆成独立应用
1.1 扩展形态的三个痛点
很多人问过我:VSCode 扩展用 Webview 就能跑 HTML + JS,打字游戏这种形态完全能在里面实现,为什么还要折腾成 Electron?我实际做了大半年扩展后,感受最深的是三个限制。
第一个痛点是性能隔离。VSCode 扩展的 Webview 运行在编辑器渲染进程中,虽然有独立的 webview 标签页,但和编辑器的 DOM、插件消息、主题切换等机制共享进程资源。打字游戏要频繁处理键盘事件、实时渲染文本、维护波形和统计,在高分屏或者大文件打开的情况下,明显能感到按键响应延迟。有一次用户反馈说某些场景下按键有 30ms 左右的延迟,虽然不太起眼,但对打字游戏这种毫秒级体验敏感的应用,这就是硬伤。
第二个痛点是交互边界。扩展里想做一个全屏无干扰模式,只能通过 Webview 全屏 + 隐藏编辑器 UI,效果勉强但总有瑕疵,窗口拖拽、标题栏控制、多显示器支持都受 VSCode 窗口体系约束。想监听快捷键,还得注册 keybinding 并处理与编辑器原有快捷键的冲突,体验很难做好。
第三个痛点是分发门槛。扩展要通过 VSCode 插件市场审核,对游戏类应用来说,更新节奏和渠道控制都不够自由。而且用户必须安装 VSCode 才能用你的应用,这个前置条件本身就过滤掉了很多只是想练打字的人。
1.2 独立应用的核心收益
改成独立 Electron 应用之后,这几个痛点基本都解决了。渲染进程只跑游戏本身,不用跟编辑器抢资源,启动后内存占用反而比 VSCode + 扩展更低。窗口完全自控,我可以自由做无边框窗口、开机自启、全局快捷键、系统托盘,这些都是扩展形态很难做到的。
分发逻辑也彻底变了。Electron 应用可以打包成 exe、dmg、deb、AppImage,用户下载双击即可运行,不需要任何前置环境。而且我可以自己控制更新节奏,不需要等插件市场审核。像卡顿优化、新关卡、词库更新这些功能,都可以及时推到用户手里。
还有一点容易被忽略:独立应用让用户觉得这是一个“产品”,而不是一个编辑器里的“小玩具”。这对鼓励持续使用、建立用户习惯是有实际帮助的,尤其打字练习这种需要长期坚持的场景,产品感很重要。
1.3 一个判断原则:什么时候该拆、什么时候不该拆
如果你也在纠结要不要把 VSCode 扩展拆成独立应用,我建议先做一次成本收益判断。扩展形态适合工具属性强、和编辑器工作流强绑定的场景,比如格式化、代码片段、文档预览、linter 提示。这些应用离了编辑器就没意义,拆出去反而是倒退。
但如果你做的东西本质上是个独立产品,编辑器只是个临时的运行壳,比如游戏、教程工具、本地知识库、写作软件,那拆出来只是时间问题。判断标准很简单:用户能不能在不打开编辑器的情况下理解并使用你的功能?如果能,就应该考虑拆了。当时我列了个表,把扩展能做的、Electron 能做的都写下来,发现键盘事件控制、窗口设计、多渠道分发这三项扩展是明显跟不上的,所以拆的决定很明确。
2. 整体架构设计:Electron + Vue 3 的分层逻辑
2.1 为什么是 Electron 而不是 Tauri 或原生
技术选型的时候,我认真对比过 Tauri,也考虑过用 Rust + egui 写原生,但最后还是选了 Electron。原因倒不是情怀,而是结合项目现状做的选择。
原来的扩展就是 HTML + TypeScript + Vue,整个打字游戏的 UI、动画、词库渲染逻辑都已经在 Webview 里跑通了。拆成 Electron 后,渲染进程可以直接复用这套 Vue 组件和样式,工作量大幅压缩。如果换 Tauri,虽然包体积更小、内存更优,但前端嵌入方案是 WebView,在国产 Linux 系统上容易出现 WebView 环境不一致的问题,而且 Rust 侧的插件生态我还不熟,时间成本不可控。Electron 内置 Chromium,不管什么系统,渲染行为是确定的,这对工具类应用很重要——我不希望同一套 Vue 代码在某台机器上布局就变了。
另外,Electron 的成熟生态也是加分项。electron-builder 打安装包、electron-updater 做增量更新、主进程 API 查文档就能用,这些都是在社区里被反复验证过的方案。省下来的时间可以专注于打字游戏本身。
2.2 主进程 / 渲染进程 / 预加载脚本如何分工
这次架构改造的核心,是把原来 VSCode 扩展里混合在一起的逻辑拆成三层。
主进程负责系统级能力:创建窗口、自定义菜单、监听全局快捷键、读写用户数据文件、获取系统语言、处理单实例锁。渲染进程只做 UI 渲染和打字游戏状态机,不直接碰操作系统 API。预加载脚本 contextBridge 暴露一套白名单 API,渲染进程通过window.api调用主进程能力,本质上建立起了一条清晰的 IPC 通道。
这里最关键的是隔离原则:渲染进程永远不应该直接使用 Node.js API。Electron 出于安全原因默认开启了 contextIsolation,意思就是渲染进程拿不到 Node 的require、process等能力。很多人图省事会在渲染进程直接require('electron')或者nodeIntegration: true,这等于把系统权限暴露给了前端代码,一旦某个第三方依赖有漏洞,攻击面就是整个用户系统。我的做法是严格使用contextBridge.exposeInMainWorld,只把需要的方法暴露出来,比如window.api.readRecords()、window.api.saveRecord()这些。
2.3 复用还是重写:从 VSCode Webview 迁移的取舍
拆架构时最纠结的问题是:UI 层能不能直接复制?实际上,VSCode Webview 和 Electron 渲染进程虽然都跑 HTML + JS,但 API 差异不小。
VSCode Webview 里常用的acquireVsCodeApi().postMessage()这套消息机制,到 Electron 这边要换成ipcRenderer.invoke()或者ipcRenderer.send()。获取数据的方式也不同,VSCode 扩展用context.globalState存用户数据,Electron 得自己处理用户目录下的 JSON 文件。我在迁移时做了个很实际的决策:UI 组件和样式 100% 复用,所有数据访问与系统调用全部重写。
具体的做法是给渲染进程定义一个接口层。原来组件里直接调用vscode.postMessage()的地方,全部改成调用window.api.getLessonList()、window.api.saveBestRecords()这类业务方法。这样以后哪怕再换到 Tauri,只需要替换window.api的实现,UI 完全不用动。这种面向接口而非面向 API 的写法,是这次架构改造里我做的最正确的决定之一。
2.4 项目目录结构
改造后的项目结构长这样,清晰隔离了主进程、预加载脚本和渲染进程。
typing-game/ ├── electron/ │ ├── main.ts # 主进程入口 │ ├── preload.ts # 预加载脚本,暴露 window.api │ ├── menu.ts # 自定义菜单 │ ├── storage.ts # 用户数据读写 │ └── locale.ts # 系统语言检测 ├── src/ │ ├── components/ # Vue 组件 │ ├── views/ # 页面级组件 │ ├── composables/ │ │ ├── useTypeEngine.ts # 打字引擎 │ │ ├── useStats.ts # 统计逻辑 │ │ └── useAudio.ts # 音效管理 │ ├── api/ # 渲染进程调主进程的统一入口 │ └── App.vue ├── packages/ # 打包配置 └── electron-builder.yml这样分层之后,前端同学可以从src目录入手,不需要理解 Electron 细节;负责打包和系统适配的人只改electron目录。职责边界非常清楚。
3. 打字游戏核心引擎实操:从 Vue 组件逻辑到可复用引擎
3.1 useTypeEngine:用组合式 API 封装打字引擎
原扩展里所有打字逻辑都写在TypingPanel.vue组件内部,状态和 UI 混在一起,改一个功能容易碰坏另一块。这次改造我选了 Vue 3 的组合式 API,把打字引擎彻底抽成了一个独立的useTypeEngine组合函数。
先说为什么用组合式 API 而不是选项式。打字游戏的状态非常多:当前文本、当前索引、错误次数、开始时间、暂停状态、完成时间、速度波动、准确率。这些状态之间还有复杂的联动关系,选项式 API 中 data、computed、methods 分开写,状态多起来之后维护成本极高。组合式 API 允许我把“打字引擎”这个完整逻辑域的所有数据和方法封装到一个函数里,把“统计”“词库切换”“音效”分别拆到独立组合函数,再按需组合。这其实就是模块化思维在组件内的延伸。
useTypeEngine的核心实现大概是这样的:
export function useTypeEngine() { const text = ref('') const index = ref(0) const errorCount = ref(0) const status = ref<'idle' | 'running' | 'paused' | 'finished'>('idle') let startTime = 0 let endTime = 0 function start(newText: string) { text.value = newText index.value = 0 errorCount.value = 0 status.value = 'running' startTime = performance.now() } function handleKeydown(e: KeyboardEvent) { // 跳过修饰键和组合键 if (e.ctrlKey || e.metaKey || e.altKey) return // 输入法组合期间不判定 if (e.isComposing || e.keyCode === 229) return if (status.value !== 'running') return const expected = text.value[index.value] const typed = e.key if (typed === expected) { index.value++ if (index.value >= text.value.length) { status.value = 'finished' endTime = performance.now() } } else { errorCount.value++ } } function speed() { const minutes = (endTime - startTime) / 60000 return minutes > 0 ? Math.round(index.value / 60 / minutes) : 0 } return { text, index, errorCount, status, start, handleKeydown, speed } }e.isComposing这个判断特别重要,中文输入法在按下字母键时,会先进入 composition 状态准备组词,这时候keydown事件会被触发但keyCode是 229,如果不跳过,中文输入过程中按的字母会被误判为打字输入,统计直接爆炸。这个坑我后面会详细说。
3.2 打字判定的细节:大小写、修饰键和输入法干扰
打字游戏的核心判定逻辑坑非常多,这里展开讲三个我花了很久才理顺的点。
第一个是大小写问题。如果文本是小写字母,用户按 Shift 键会输入大写,而e.key返回的是实际输入的字符。所以单纯拿e.key === expected做判断,大写状态全部会判错。我的处理方式是统一用e.key.toLowerCase()和text[index].toLowerCase()比较,视觉上只检查用户是否按对了字符,不检查 Shift 是否误开。当然游戏如果设计成必须区分大小写模式,那就要单独处理,但在默认的英文打字练习场景,忽略大小写是更友好的做法。
第二个是修饰键和快捷键。按下 Shift、Ctrl、Alt、Meta 等修饰键时,e.key分别是'Shift'、'Control'这些,如果不跳过,游戏会把这些当成错误输入,错误率直接飘红。我加了一个判断:if (['Shift', 'Control', 'Alt', 'Meta'].includes(e.key)) return。还有一个隐藏坑——当用户按了 Tab 或方向键,它们也会触发keydown,但这些不应该是打字输入,也要过滤掉。
第三个是输入法干扰。这个问题在无边框窗口和全局模式下尤其明显。如果系统默认输入法是中文,用户按字母键时,Electron 窗口会响应用户的输入法状态,导致 keydown 事件进入 composition 流程。最彻底的方案是给窗口设置禁用输入法:
mainWindow.webContents.setIgnoreMenuShortcuts(true)但更直接的办法是监听组合事件。我最终做的是在keydown里判断e.isComposing,同时在compositionstart时把当前状态标记为“输入法激活”,直到compositionend才恢复接收按键。双保险之后,中文输入法的干扰就彻底消失了。
3.3 统计逻辑:速度、准确率与完成度
打字游戏的统计指标有三个:速度(WPM/CPM)、准确率、完成度。这三个指标看起来简单,但计算口径稍不注意就会自相矛盾。
速度的通用做法是记录开始时间,结束时用字符数 / 用时(秒) * 60得到 CPM,再除以 5 得到 WPM。这里有个我自己定的小规则:即使打错了字符,也算打过的字符,所以速度统计依据的是“击键通过的位置”而不是“正确字符数”,否则准确率和速度会互相干扰,用户会看到速度低且准确率也低,体验很挫败。更合理的口径是速度看你出手有多快,准确率看你判断有多准,两个维度独立统计。
用 TypeScript 实现统计函数:
export function calcStats(index: number, errorCount: number, elapsedMs: number) { const minutes = elapsedMs / 60000 const cpm = minutes > 0 ? Math.round(index / minutes) : 0 const wpm = Math.round(cpm / 5) const totalTyped = index + errorCount const accuracy = totalTyped > 0 ? Math.round((index / totalTyped) * 100) : 100 return { cpm, wpm, accuracy } }这里要特别强调elapsedMs的采集方式:一定用performance.now(),不要用Date.now()。因为performance.now()是相对页面加载零点的高精度时间戳,不受系统时间调整影响,而且精度到微秒级。曾经一次系统自动校时导致Date.now()往回跳,用户的每局成绩全部变成了负数,查了半天才定位到这个问题。
完成度更简单:index / text.length。但 UI 上我同时展示了一个进度条和一个百分比数字,进度条的动画用 CSS transition,避免 setInterval 频繁刷新 DOM 导致掉帧。
4. Electron 主进程与系统集成改造
4.1 自定义菜单替换默认菜单
Electron 默认的应用菜单是一套通用模板,对打字游戏来说完全没意义,用户右键还能看到“重新加载”“开发者工具”这种选项,既不美观也不安全。我自定义了菜单,只保留游戏需要的操作。
const template: MenuItemConstructorOptions[] = [ { label: '游戏', submenu: [ { label: '重新开始', accelerator: 'CmdOrCtrl+R', click: () => mainWindow?.webContents.send('game:restart') }, { label: '暂停 / 继续', accelerator: 'Space', click: () => mainWindow?.webContents.send('game:toggle-pause') }, { type: 'separator' }, { label: '退出', role: 'quit' } ] }, { label: '视图', submenu: [ { label: '切换深色模式', click: () => mainWindow?.webContents.send('theme:toggle') }, { type: 'separator' }, { label: '全屏', role: 'togglefullscreen' } ] }, { label: '帮助', submenu: [ { label: '项目主页', click: () => shell.openExternal('https://example.com') } ] } ] Menu.setApplicationMenu(Menu.buildFromTemplate(template))自定义菜单的作用不只是美学,它能克制 Electron 默认快捷键带来的副作用。比如默认的CmdOrCtrl+R是 reload 窗口,对打字到一半的用户来说是毁灭性打击,所以我重新定义了它。注意这里我用了webContents.send给渲染进程发消息,而不是直接用 reload,这样游戏可以自己决定重开一局的逻辑,而不是粗暴刷新页面。
4.2 获取系统语言与国际化方案
说到菜单,就绕不开多语言。原扩展只支持英文,拆成独立应用后,我觉得至少要把中英文切换做了,这时候就需要读系统语言来决定初始语言。
Electron 主进程里获取系统语言有两种做法:
// 推荐:应用当前语言,受 app 命令行设置影响 const locale = app.getLocale() // Electron 24+:获取系统区域设置,更接近 OS 层 const systemLocale = app.getSystemLocale()app.getLocale()会返回zh-CN、en-US这种格式的字符串。但我发现一个细节:macOS 和 Windows 返回的格式不完全一致,Windows 上有时是zh-CN,macOS 上可能是zh-Hans。所以解析的时候不能直接startsWith('zh'),我选择做了个小映射表,兼容常见变体。这里要注意的是菜单构建时要传当前语言,然后通过Menu.setApplicationMenu重新设置一遍。
更完整的方案是引入 i18n 库,把渲染进程的文案统一管理。我用 Vue 的useI18n()配合主进程传过来的 locale,切换语言时通过 IPC 同步窗口标题和菜单。
4.3 窗口控制与外部链接处理
独立应用有一个逃不掉的问题:渲染进程里有很多链接需要打开。比如“帮助”菜单里的项目主页,或者游戏内点击评分记录里的 GitHub 链接。如果直接让渲染进程window.open(url),默认会在 Electron 窗口里开一个新窗口,而不是用户的系统浏览器,体验很怪。
正确的做法是用setWindowOpenHandler拦截所有新窗口请求,把外链交给系统默认浏览器,同时阻止 Electron 内部打开:
mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('https://') || url.startsWith('mailto:')) { shell.openExternal(url) } return { action: 'deny' } })这里有个安全细节:一定要校验 url 协议,只允许 https 或 mailto,禁止 file:// 协议,避免恶意链接读取本地文件。另外,渲染进程里如果有些内嵌窗格需要保留在应用内部,比如某个基于 WebView 的预览页面,可以单独用一个BrowserView配合白名单协议管理,这个来自热搜里有人问的 “electron 壳子内的页面打开 url” 的问题——本质就是区分哪些链接交给内部展示、哪些交给系统浏览器,不要混在一起。
4.4 数据持久化:从扩展存储迁移到本地文件
VSCode 扩展时代用context.globalState存用户设置和最高纪录,迁移到 Electron 后需要自己的存储方案。我的选择是:简单数据用 JSON 文件,复杂数据不用数据库,因为写字游戏的数据量并不大,记录量到几万条都很小。引入数据库反而要处理原生模块编译问题,纯 JS 的存储库在 Electron 里不香。
我的实现是写一个storage.ts,数据文件放app.getPath('userData')目录下的records.json。
import { app } from 'electron' import { join } from 'path' import { readFileSync, writeFileSync } from 'fs' const dataFile = join(app.getPath('userData'), 'records.json') export function readRecords(): Record[] { try { return JSON.parse(readFileSync(dataFile, 'utf-8')) } catch { return [] } } export function saveRecord(record: Record) { const records = readRecords() records.push(record) // 只保留最近 1000 条,防止文件无限膨胀 const trimmed = records.slice(-1000) writeFileSync(dataFile, JSON.stringify(trimmed), 'utf-8') }写入要特别注意写文件时机,最好用防抖延迟落盘,避免打字结束时频繁写盘。我在游戏结束时批量写入一场比赛的所有分段记录,而不是每打一个字符存一次,这个性能差距在机械硬盘上非常明显。
5. 打包与跨平台分发:从“我在我电脑上能跑”到“谁都能装”
5.1 electron-builder 基础配置与多平台目标
打包是我花时间第二多的地方,坑密度极高。我用的是 electron-builder,配置文件electron-builder.yml核心段落如下:
appId: com.example.typinggame productName: TypingGame directories: output: release files: - dist/**/* - electron/** - package.json asar: true win: target: - nsis icon: build/icon.ico nsis: oneClick: false allowToChangeInstallationDirectory: true mac: target: - dmg category: public.app-category.games linux: target: - AppImage - deb category: Game这个配置本身不复杂,但我踩了一个大坑:electron/**目录必须确保打包进去之后,主进程入口路径正确。如果 package.json 里的main指向electron/main.js,那files里就要包含electron目录。曾经有一次我把electron目录改成dist-electron,忘了同步 package.json 的main字段,结果所有平台打包出来的应用双击就闪退,控制台报错找不到模块,排查花了一晚上。
打包后的产物包含一个win-unpacked目录和一个 NSIS 安装包。Windows 上还碰到了一个很现实的问题:没有代码签名时,SmartScreen 会弹“Windows 已保护你的电脑”。对于国内个人开发者来说,买 EV 证书太贵,普通 OV 证书也开销不小。短期的过渡方案是引导用户在安装时点“更多信息 → 仍要运行”,但长期看如果需要面向大众分发,代码签名终究绕不过去,这是 Windows 生态的硬规则。
5.2 国产系统(银河麒麟等)的分发注意点
网上很多人在搜“electron 国产系统分发”“银河麒麟 electron 版本”,这确实值得单独聊聊。国产系统上 Electron 应用主要遇到两个问题:高版本 Electron 依赖的 glibc 版本可能高于系统自带;以及缺系统依赖库。
我实际在银河麒麟 V10 上测过,Electron 26 默认打包的 deb 包,安装可以成功,但运行时提示缺少 libnss3、libatk-bridge2.0 等动态库。解决办法有两种:一种是把这些依赖打进 deb 包的Depends里,让系统自动安装;另一种是直接用 AppImage,它自带大部分依赖,兼容性更好。但 AppImage 也有问题,部分国产系统默认没有libfuse2,AppImage 跑不起来。所以我的建议是发布 deb 包走软件包管理器安装,同时提供 AppImage 作为替代,并把依赖说明写清楚。
另外国产系统上的窗口边框渲染、字体渲染与 Windows/macOS 差异较大,尤其是高分屏缩放,需要在主进程主动调用app.commandLine.appendSwitch('high-dpi-support', '1'),否则界面会发虚或控件巨大。
5.3 包体优化与常见打包报错
Electron 应用包体大是众所周知的痛点,动辄 80MB+。打字游戏这种工具类小应用,用户很难接受下载一个超大安装包。我做了三件事压缩体积。
第一是开启 asar 归档,把所有业务代码打进一个 asar 包,文件数量少了,加载速度也快一些。第二是裁剪依赖。打包时 electron-builder 会把node_modules里 production 依赖都打进去,但如果某些依赖只是开发用的,比如 typescript、vite,就应该确保它们在 devDependencies 里,electron-builder 不会打包开发依赖。第三是关闭不必要的自动更新模块——electron-updater 如果不配置镜像源,国内网络下每次启动都会静默请求 GitHub release,既慢又笨,纯单机游戏暂时不需要它。
常见的打包报错里,最经典的是“After pack: cannot find module 'electron-updater'”或“can't resolve 'fs' in renderer process”。这两个本质上都是因为把 Node 核心模块或主进程依赖错误地引入到了渲染进程。我的排查心得是:渲染进程代码里严禁 importfs、path、os等 Node 模块;如果第三方库依赖 Node 模块,就不要在渲染进程使用它,而是通过 IPC 放到主进程调用。这条铁律贯彻好,一半的打包报错都不会出现。
6. 上线后的踩坑记录与优化方向
6.1 高价值踩坑实录速查表
改造上线后收集了不少用户反馈,也踩了不少坑,我把最有价值的几条整理成速查表,方便后来人直接查。
| 现象 | 根因 | 解决办法 |
|---|---|---|
| 中文输入法下按字母键被误判为打字 | keydown 进入 composition 状态,keyCode = 229 | 判断 e.isComposing / keyCode === 229,配合 compositionstart/end |
| Ctrl+R 或 Space 被系统菜单占用,游戏逻辑收不到按键 | Electron 默认应用菜单抢占了快捷键 | 自定义 Menu 模板,或监听 before-input-event 拦截 |
| 打包后在非开发机启动白屏,控制台报 file:// 资源 404 | 渲染进程入口路径写死相对路径,打包后目录结构不同 | 一律用path.join(__dirname, '../dist/index.html')定位入口文件 |
| Linux 下 deb 安装运行提示缺 .so 文件 | electron 依赖系统库未声明 | 在 deb 的 Depends 字段声明 libnss3、libatk 等依赖,或改用 AppImage |
| 游戏跑久了内存缓慢上涨 | WebContents 里积累了大量的 IPC 监听器、事件回调没有 removeListener | 组件卸载时清空监听,IPC 回调用off()注销 |
| 高 DPI 缩放下界面文字模糊 | 未开启 high-dpi-support,或未处理 devicePixelRatio | 主进程开启高分屏支持,CSS 中使用 rem/vw 适配 |
6.2 性能优化:从 60 FPS 到更顺滑
打字游戏的渲染层优化有自己的一套逻辑。早期版本每个按键更新一个 Vue 响应式数组,通过 v-for 渲染几十个字符,实际上 60 帧完全没问题。但在长篇文章模式下,单次渲染的 DOM 节点会超过 2000 个,Vue 的响应式更新就会开始掉帧。
我的优化思路是分片渲染,只渲染当前视口附近的内容,用一个固定高度的容器加虚拟滚动。具体实现不复杂:维护一个visibleRange,根据滚动位置算出该显示哪 50 个字符节点,其他的字符统计逻辑在 JS 里计算,不渲染成 DOM。这样长文本模式下 DOM 节点始终稳定在 100 个以内,FPS 稳定在 144 Hz 显示器上也不掉帧。
另一个性能点是打字时的音效。如果每次按键都同步创建一个Audio对象并play(),高频率下会有延迟。我改成预加载 3~5 个短音效 buffer,在AudioContext里用一个播放队列轮询选择空闲的 buffer 来播放,这样按键音不再卡顿,也不会因为连打触顶限制而漏音。
6.3 后续扩展方向:路由、远程内容与数据同步
现在的版本是纯本地的单窗口应用,没有使用 Vue Router。因为游戏就几个页面:主菜单、游戏页、统计页、设置页,用组件切换就够了。但如果后面要加入选关系统、多模式教学、用户系统,建议引入 Vue Router,但要注意 Hash 模式,因为 Electron 生产环境使用 file:// 协议加载入口文件,History 模式在深链刷新时会 404。这是很多从 Web 转向 Electron 的开发者会踩的坑。
另外热搜里有人问“vue 播放 m3u8”,这个在打字游戏里暂时用不上,但如果是做带背景音的教学游戏,可以考虑在渲染进程里用 hls.js 播放音视频流。数据的未来方向是云同步,把个人最佳成绩、词库进度同步到自己的服务器——参考 springboot + vue 前后端分离的思路,客户端只负责数据上报和展示,服务端管账号和成绩排行。这是独立应用走向产品化很自然的一步,但要考虑隐私设计,尽量匿名化上报。
如果你也想把一个 VSCode 扩展拆成独立应用,我的建议是先花半天时间把所有用到 VSCode API 的地方列出来,逐个对照 Electron 的能力找替代方案,再动手写代码。架构改造的价值不在于“换了壳”,而在于你把逻辑重新梳理清楚,让它脱离宿主环境也能独立生存。整个改造过程会遇到的坑很多,但每解决一个,你对 Electron 的理解就更深一层——尤其是主进程和渲染进程的边界感、IPC 消息的设计、打包分发这些和扩展开发完全不同的维度。这套改造跑通之后,下一款应用的上手速度会快非常多。