从 VSCode 扩展到 Electron 独立应用:打字游戏架构改造复盘
在 VSCode 里做打字练习插件做到第三版的时候我越来越觉得不对劲菜单栏是 VSCode 的快捷键是 VSCode 的甚至字体渲染都带着编辑器那股味道。用户想练打字却要忍受编辑器自带的各种输入法冲突还有那个永远无法完全隐藏的 Activity Bar。于是我下决心把这个打字游戏从 VSCode 扩展里剥离出来改成独立桌面应用。这个项目在选型上几乎没什么悬念—— Electron 配 Vue 3把原来扩展里那套 Webview 页面搬出来重写。整个过程比预想的复杂踩了不少坑也收获了不少经验。这篇内容就是这次架构改造的完整复盘为什么非要从 VSCode 扩展迁移到 Electron、工程骨架怎么搭、VSCode 扩展的消息机制怎么改造成 Electron 的 IPC、核心打字逻辑有哪些坑、打包分发怎么处理。适合正在做 VSCode 扩展想转独立应用、或者想用 Electron 做小工具但不知道从哪下手的开发者参考。1. 从 VSCode 扩展到独立应用先想清楚为什么要改1.1 VSCode 扩展的先天限制VSCode 扩展本质上运行在一个受限的沙箱环境里。你写界面只能用 Webview而 Webview 加载的是本地 HTML 页面和扩展宿主进程之间只能通过 postMessage 通信。听起来很灵活但真正做起来处处受限。先说 Webview 本身。它是 VSCode 基于 iframe 做的一层包装默认情况下有很多网站权限是被限制的比如不能直接访问剪贴板的部分接口、不能控制窗口大小、不能创建系统托盘。更难受的是VSCode 有一套自己的 UI 体系和快捷键系统。你想做一个独立的打字游戏界面希望用户的注意力完全集中在游戏内容上但 VSCode 永远在周围显示编辑器相关的按钮和状态栏。其次是 JavaScript 运行环境。虽然 VSCode 扩展可以在宿主进程里跑 Node.js但你的业务逻辑被拆成三块宿主进程、Webview、还有 Webview 里的沙箱脚本。数据通信必须层层转发代码结构很容易变得混乱。我最初的版本就是这样游戏状态机在 Webview 的 JavaScript 里跑计分逻辑在宿主进程里跑设置存在 VSCode 的全局配置里。这种架构做成 demo 没问题但一旦游戏功能增多比如加音效、加打字统计图表、加自定义词库上传代码的维护成本会迅速上升。1.2 独立应用能解决什么换成 Electron 之后最明显的变化是游戏可以跑在独立的窗口里。我们可以完全控制窗口的样式和行为无边框窗口、自定义标题栏、全局快捷键、系统托盘。打字游戏需要的是沉浸感和即时响应独立窗口意味着没有编辑器的各种干扰。另一个核心优势是权限。Electron 的渲染进程虽然是 Chromium但主进程拥有完整的 Node.js 能力。读写文件、访问系统剪贴板、监听全局键盘事件、弹出系统通知这些在 Electron 里都变成了常规操作。比如我想做一个用户自定义词库功能原来在 VSCode 扩展里要把词库文件放到工作区里还得提示用户用命令面板触发导入。在 Electron 里直接弹一个文件选择框然后把词库文件复制到用户数据目录整个过程简单得多。性能方面也有收益。VSCode Webview 会受限于编辑器自身的渲染调度快速打字时偶尔会出现卡顿。独立窗口的 Chromium 渲染引擎跑起来干净利落渲染性能完全可控。我做了一个简单的压力测试在 Webview 里连续输入大幅字符时界面帧率会掉到 30fps 以下换成 Electron 独立窗口后基本稳定在 60fps。1.3 改造的边界与风险当然迁移不是没有代价。VSCode 扩展的优势在于跨平台一致性你在 Windows 上写好的扩展换到 macOS 上依然能跑VSCode 帮你处理了大部分平台差异。Electron 也支持跨平台但打包、签名、系统权限这些东西需要自己搭一套流程。另一个需要考虑的是用户获取渠道。VSCode 扩展可以在 VSCode 插件市场一键安装用户几乎是无感知的。独立应用却需要用户下载安装包这对分发是一个新的挑战。基于这些考虑改造之前必须想清楚目标是把 VSCode 扩展的整体功能完整搬迁还是提炼出核心场景做深度优化我的选择是后者。原扩展里的命令面板、设置项这些只在编辑器环境里才有意义的东西直接砍掉保留打字训练相关的核心功能把精力集中在体验优化上。2. 架构设计先行Electron Vue 3 的整体骨架2.1 Electron 和 Vue 3 怎么分工Electron 应用分为主进程、预加载脚本和渲染进程。主进程负责创建窗口、管理应用生命周期、处理系统事件渲染进程负责页面渲染跑的就是我们熟悉的 Web 技术栈预加载脚本是连接主进程和渲染进程的桥梁。Vue 3 负责的是渲染进程这一层。整个游戏界面、状态管理、组件交互统统由 Vue 完成。这里要特别注意一点 Vue 的响应式系统在主进程里是不能直接用的主进程里跑的是纯 Node.js 环境。所以数据的流向必须设计清楚游戏界面的所有 UI 状态放在 Vue 的 store 里需要访问系统资源时通过预加载脚本暴露的 API 调用主进程主进程处理完数据再通过 IPC 把结果发回给渲染进程这种单向数据流的模式可以最大程度避免状态混乱。2.2 工程初始化选择 electron-vite搭建 Electron Vue 3 工程项目我用的是 electron-vite。它基于 Vite天然支持 Vue 3 的快速热更新同时内置了对主进程、预加载脚本、渲染进程三部分的构建支持。初始化方式很简单npm create quick-start/electronlatest typing-game -- --template vue这个模板会生成一个带主进程、预加载脚本、渲染进程的完整工程。目录结构如下typing-game/ ├── src/ │ ├── main/ # 主进程 │ │ └── index.ts │ ├── preload/ # 预加载脚本 │ │ └── index.ts │ └── renderer/ # 渲染进程 │ ├── src/ │ │ ├── components/ │ │ ├── App.vue │ │ └── main.ts │ └── index.html ├── electron-builder.yml # 打包配置 ├── electron.vite.config.ts # electron-vite 配置 └── package.json为什么选 electron-vite 而不是官方的 Electron Forge 或者自己写构建脚本主要原因还是开发体验。Vite 的热更新速度在编辑器场景下感受特别明显它把主进程、预加载脚本和渲染进程分开构建修改主进程代码会自动重启 Electron 应用修改渲染进程则走 Vite 的 HMR 通道不会丢失页面状态。开发打字游戏这种需要频繁调整 UI 的项目这一点非常关键。2.3 主进程配置与窗口管理主进程最关键的是创建 BrowserWindow。这里我用了自定义无边框窗口配合渐进式渲染的游戏背景色function createWindow(): void { const mainWindow new BrowserWindow({ width: 1200, height: 800, frame: false, backgroundColor: #0f172a, webPreferences: { preload: join(__dirname, ../preload/index.js), contextIsolation: true, nodeIntegration: false } }) if (is.dev process.env[ELECTRON_RENDERER_URL]) { mainWindow.loadURL(process.env[ELECTRON_RENDERER_URL]) } else { mainWindow.loadFile(join(__dirname, ../renderer/index.html)) } }两个地方需要特别注意。一个是frame: false也就是去掉系统默认边框。这会让窗口看起来像游戏应用但相应的最小化、最大化、关闭按钮都要自己实现。我是在 Vue 组件里做了三个按钮通过 IPC 调用主进程的窗口控制接口来完成操作。另一个是contextIsolation: true和nodeIntegration: false。这是安全基线。渲染进程里的页面代码不能直接访问 Node.js 的 API所有系统能力都通过 preload 脚本暴露的有限接口来调用。打字游戏会读取用户本地的词库文件这个能力如果被页面任意代码拿到就等于给了任何 XSS 攻击打开本地文件的钥匙所以必须用 contextBridge 做一层隔离。3. 数据流重构从 VSCode 的 postMessage 到 Electron IPC3.1 VSCode 扩展的通信方式复习写 VSCode 扩展的时候Webview 和扩展宿主进程的通信是通过 postMessage。大致结构是这样的// Webview 里的代码 const vscode acquireVsCodeApi() vscode.postMessage({ type: saveScore, score: 100 }) // 扩展宿主进程里 webview.onDidReceiveMessage((message) { if (message.type saveScore) { // 存储数据 } })这套机制写起来不复杂但类型系统是薄弱的。消息靠字符串 type 区分没有编译期类型检查改一个字段名可能藏很久才发现。而且因为 VSCode 扩展里开发者不能开多个窗口随意玩调试起来比较麻烦只能一遍遍地用开发者工具看 message 内容。3.2 Electron 的 IPC 双通道设计Electron 的 IPC 机制更正规。渲染进程通过ipcRenderer.invoke调用主进程暴露的方法主进程通过ipcMain.handle注册处理函数。这套机制天然支持 Promise可以直接返回异步结果消息类型也有 TypeScript 可以约束。我在项目里把 IPC 接口统一放在src/preload/index.ts里import { contextBridge, ipcRenderer } from electron const api { // 读取词库 readWordList: () ipcRenderer.invoke(read-word-list), // 保存打字记录 saveRecord: (record: TypingRecord) ipcRenderer.invoke(save-record, record), // 读取历史最佳成绩 getBestScore: () ipcRenderer.invoke(get-best-score), // 窗口控制 minimize: () ipcRenderer.send(window-minimize), maximize: () ipcRenderer.send(window-maximize), close: () ipcRenderer.send(window-close) } contextBridge.exposeInMainWorld(api, api)然后在主进程里对应实现ipcMain.handle(read-word-list, async () { return await wordListService.read() }) ipcMain.handle(save-record, async (_event, record) { return await recordService.save(record) }) ipcMain.handle(get-best-score, async () { return await recordService.getBest() }) ipcMain.on(window-minimize, () mainWindow.minimize()) ipcMain.on(window-maximize, () { if (mainWindow.isMaximized()) { mainWindow.unmaximize() } else { mainWindow.maximize() } }) ipcMain.on(window-close, () mainWindow.close())这里有个细节ipcRenderer.invoke和ipcMain.handle是成对出现的一个负责请求一个负责响应。而ipcRenderer.send和ipcMain.on是单向的不需要返回值。窗口控制这种仅发射命令的场景用 send 就够了不需要等待返回值。3.3 词库与成绩数据怎么落盘在 VSCode 扩展里持久化数据最简单的方案是vscode.workspace.getConfiguration()和updateConfiguration()。但独立应用没有配置中心必须自己管文件。我用的是electron-store它本质上是把 JSON 数据写到用户目录下的一个文件里。打字游戏需要持久化的数据主要有两类用户自定义词库和打字成绩记录。词库可能很大不适合往配置 JSON 文件里塞我单独做了一个data/wordlist.json。成绩记录则走 electron-store存储历史最佳成绩、总训练时长、平均正确率这些轻量数据。需要注意electron-store的版本兼容问题。它的 v8 版本要求 Electron 20 以上如果你的 Electron 版本比较老最好用 v7 或者干脆手动读写 JSON 文件避免不必要的依赖冲突。4. 核心业务重构打字游戏逻辑从 Webview 搬到 Vue 组件4.1 打字核心状态设计打字游戏的核心逻辑和 Vue 组件深度绑定。我在原来的 VSCode 扩展里用的是纯 JavaScript 加 DOM 操作重写成 Vue 3 之后所有游戏状态全部用ref和computed管理。核心状态包括当前目标词列表当前输入内容当前匹配到的字符索引本次运行的正确数、错误数、击键次数剩余时间或剩余词数这里最核心的是currentIndex也就是当前需要输入的字符在目标词中的位置。每次键盘输入要么匹配通过、currentIndex 加 1要么匹配失败、错误计数加 1。整个逻辑写在 Vue 的事件处理函数里不依赖任何外部库。const currentIndex ref(0) const errorCount ref(0) const typedCount ref(0) function handleKeydown(event: KeyboardEvent) { if (gamePhase.value ! playing) return const expectedChar currentWord.value[currentIndex.value] const actualChar event.key if (actualChar.length ! 1) return typedCount.value if (actualChar expectedChar) { currentIndex.value if (currentIndex.value currentWord.value.length) { // 当前词完成切换到下一个词 nextWord() } } else { errorCount.value } }这段代码看起来简单但实际有一个坑中文输入法。当用户使用微软拼音或者搜狗输入法时键盘事件中event.key的值是Process中英文混合模式下非常容易误判。所以必须在打字游戏聚焦状态下强制切换到英文模式或者在收到Process事件时直接忽略。我用一个笨办法解决了游戏启动时给用户一个明显的提示“请切换到英文输入法”同时在代码里检测event.isComposing为 true 时直接 return不参与游戏逻辑。4.2 词库加载与游戏会话管理词库文件的数据结构决定了解耦方式。我设计了一个包含词、词拼音、词频、长度分级的结构。Vue 组件从 preload 的 API 里获取词库数据后按照当前难度筛选出合适的词列表。async function startSession(difficulty: easy | medium | hard) { const wordList await window.api.readWordList() const filtered wordList .filter((item) item.difficulty difficulty) .sort(() Math.random() - 0.5) .slice(0, 50) gameWords.value filtered currentIndex.value 0 errorCount.value 0 gamePhase.value playing sessionStartTime.value Date.now() }这里用了Date.now()记录会话开始时间结束的时候计算总耗时和打字速度统计出 WPMWords Per Minute和正确率const totalTimeSeconds (Date.now() - sessionStartTime.value) / 1000 const wpm Math.round((gameWords.value.length / 5) / (totalTimeSeconds / 60)) const accuracy Math.round((1 - errorCount.value / typedCount.value) * 100)说句题外话WPM 的计算公式里除以 5 是行业惯例表示一个英文单词平均 5 个字符但中文打字测试不太适用。这个游戏支持中英文混合词库如果纯中文训练WPM 会显得特别高。我后来在统计里做了区分中文模式用 CPMCharacters Per Minute英文模式用 WPM。4.3 微交互和动画的 Vue 实现打字游戏的体验高低很大程度上取决于反馈是否即时。每个字符输入成功后加一个轻微的变色反馈错误输入时出现一个短暂的抖动动画。这些都是用 Vue 的过渡系统实现的。template div classchar :class{ correct: charState correct, error: charState error } :key${word}-${index} {{ char }} /div /template为了给每个字符单独设置状态我在词列表组件里把单词拆成字符数组每个字符组件绑定一个响应式状态。这里 Vue 的 key 设计很关键如果 key 用的是字符本身比如同一个单词里多个相同的字母界面渲染会出现状态错乱。我的做法是用词的索引加上字符在词内的索引来拼 key保证唯一性。动画方面用 CSS animation 就够不需要引入额外的动画库。错误抖动用transform: translateX配合 keyframes 实现正确反馈用transition: background-color 0.1s ease。Electron 的 Chromium 版本比较高这些基础 CSS 能力完全够用。5. Electron 特有的设置与全局能力从 VSCode 配置到主进程服务5.1 全局快捷键与系统托盘VSCode 扩展里做全局快捷键必须依赖 VSCode 的 keybinding 系统用户要在编辑器里自定义按键才能生效。Electron 则有globalShortcut模块可以直接注册系统级快捷键。我给打字游戏加了一个“全局呼出/隐藏游戏窗口”的快捷键默认AltShiftTimport { globalShortcut } from electron app.whenReady().then(() { globalShortcut.register(AltShiftT, () { if (mainWindow.isVisible()) { mainWindow.hide() } else { mainWindow.show() mainWindow.focus() } }) })这里要注意globalShortcut注册的快捷键是系统级的即使应用没激活也能被触发。但这也意味着如果和操作系统里其他应用的快捷键冲突注册会静默失败。我的做法是在状态栏加了一个“快捷键注册状态”的提示如果注册失败还能从托盘菜单呼出窗口。系统托盘对打字游戏的意义在于它可以常驻后台。关闭窗口时默认是隐藏到托盘而不是退出程序用户随时按快捷键唤出游戏开始训练这个体验比 VSCode 扩展要顺滑得多。5.2 自定义菜单与右键菜单去掉系统默认菜单栏之后很多用户习惯的操作需要自己补上。我在主进程里做了一个简单的右键菜单包含复制、粘贴、切换窗口大小、退出应用。这个菜单的实现也不复杂import { Menu, BrowserWindow } from electron function createContextMenu(win: BrowserWindow) { const menu Menu.buildFromTemplate([ { label: 复制, role: copy }, { label: 粘贴, role: paste }, { type: separator }, { label: 最小化, click: () win.minimize() }, { label: 关闭窗口, click: () win.hide() }, { label: 退出应用, click: () app.quit() } ]) win.webContents.on(context-menu, (event) { event.preventDefault() menu.popup({ window: win }) }) }5.3 系统通知与训练提醒做了这个应用之后我想加一个“久坐提醒”的功能训练满 30 分钟弹个系统通知让用户休息。Electron 的Notification模块可以跨平台发送系统通知。实现起来只需要一行import { Notification } from electron if (Notification.isSupported()) { new Notification({ title: 休息一下, body: 你已经连续打字 30 分钟了起来活动一下吧, silent: false }).show() }这个功能如果是 VSCode 扩展来做会比较别扭因为 VSCode 自身的通知中心会拦截一部分通知而且扩展没法保证应用在后台时通知能正常弹出。Electron 则直接把通知推到操作系统层面即使游戏窗口隐藏到托盘也能正常提醒。6. 打包分发从开发机到用户电脑的最后一公里6.1 electron-builder 配置项目打包我用的是 electron-builder配合 electron-vite 非常顺滑。核心配置在electron-builder.ymlappId: com.typinggame.app productName: TypingGame directories: buildResources: build output: release files: - !**/.vscode/* - !src/* - !electron.vite.config.{js,ts,mjs,cjs} - !{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md} - !{.env,.env.*,.npmrc,pnpm-lock.yaml} asar: true win: target: - nsis executableName: TypingGame nsis: oneClick: false allowToChangeInstallationDirectory: true createDesktopShortcut: true createStartMenuShortcut: true mac: target: [dmg, zip] linux: target: [AppImage, deb]这里几个配置细节值得展开说。asar: true是把应用代码打成一个 asar 包减少文件数量防止用户随意改源码。但如果主进程里有动态读取本地文件的需求比如读取用户词库文件需要确保路径是在app.getPath(userData)下而不是在 asar 包内部。nsis.oneClick: false表示生成一个带向导的安装程序用户可以选择安装目录。对桌面应用来说用户对安装位置有控制权更友好。6.2 资源文件与图标Electron 打包最容易翻车的地方是资源文件路径。开发模式下渲染进程加载的是 Vite 开发服务器打包后渲染进程加载的是file://协议下的本地文件。这个时候使用绝对路径/assets/xxx.png会失效因为文件在磁盘上的路径和网页的绝对路径不是一回事。electron-vite 的模板会自动处理这个问题但如果你用了额外的静态文件比如游戏里内置的音效、背景图要记得把它们放到src/renderer/src/assets下通过 Vite 的 import 方式引入让 Vite 处理文件路径。不要直接在 CSS 里写url(/images/bg.png)这样打包后会找不到。图标方面Windows 需要.ico文件macOS 需要.icnsLinux 需要.png。electron-builder 的 buildResources 目录下放一个icon.ico和icon.png就够它会自动适配各个平台。6.3 自动更新的坑自动更新我最初用的是 electron-updater但踩了一次大坑Windows 下如果安装了 NSIS 版本的应用自动下载更新包后调用quitAndInstall()时偶尔会失败表现为旧版本退出但没有安装新版本。排查下来是因为安装包被系统杀毒软件拦截了或者安装路径权限不足。这个问题的规避方案是不设置安装目录为 Program Files而是默认装到用户目录下。同时更新流程要加一个版本确认逻辑调用quitAndInstall()之前先确认更新包下载完整安装后读取应用版本号校验是否更新成功。如果你的目标用户是普通消费者自动更新其实优先级不高可以放到后面再做。打一个完整的新版本安装包发到官网让用户重新下载也是可以接受的方案。7. 踩坑实录从 VSCode 到 Electron 过程中最深的几个坑7.1 开发环境 HMR 失效刚开始搭脚手架时遇到一个很离谱的问题在 Vue 组件里改代码页面不会热更新。翻来覆去查了一下午最后发现是 electron-vite 的环境变量问题。模板里有一段代码if (is.dev process.env[ELECTRON_RENDERER_URL]) { mainWindow.loadURL(process.env[ELECTRON_RENDERER_URL]) } else { mainWindow.loadFile(join(__dirname, ../renderer/index.html)) }如果主进程在窗口创建后才设置环境变量或者ELECTRON_RENDERER_URL没有正确传入就会走 loadFile 分支加载的是打包后的旧文件自然没有 HMR。后来我把这段环境逻辑直接取出到 electron.vite.config 里确保开发模式下必定注入正确的 URL问题才消失。7.2 窗口无边框导致拖拽失效用了frame: false之后整个窗口失去了默认的拖拽区域。如果用户想拖动窗口必须在页面里手动设置-webkit-app-region: drag。我最初只在顶部标题栏设了拖拽区域结果写的按钮全点不了因为拖拽区域会拦截所有鼠标事件。正确处理方式是把标题栏中间部分设为可拖拽区域两侧和按钮区域保留常规交互。CSS 里用-webkit-app-region: no-drag把按钮部分排除掉。7.3 打字过程中页面频繁 GC 导致卡顿Vue 组件在打字过程中会频繁更新字符组件的 class 绑定。一开始我用了比较粗的响应式设计整个词列表绑定在一个大对象的computed上每次击键都会重新计算整个词列表的渲染。结果就是每输入一个字浏览器就要重新 Diff 整个单词列表长时间打字后内存占用飙升出现肉眼可见的卡顿。优化策略有两个一是把单词列表拆成只读的静态 props不再参与响应式更新二是用v-once标记不需要更新的字符节点仅对当前输入的字符使用独立的响应式变量。这样改完之后打字过程中的性能开销基本只剩键盘事件本身的处理了帧率稳定。7.4 打包后白屏这是 Electron 新手最容易遇到的问题。打包之后打开应用是白屏但开发模式下一切正常。通常的原因是渲染进程加载的是file://协议的本地文件而里面用到了http://或者/src/...等不匹配的资源路径。我的排查方法是打开应用后按CtrlShiftI调出开发者工具看 Console 里的具体报错。如果是因为加载路径问题会明确提示Not allowed to load local resource:。解决方式是确保所有资源都通过相对路径或者 Vite 的base配置设为./后打包。8. 改造完之后的感受与建议这次从 VSCode 扩展到 Electron 独立应用的迁移耗时差不多两周多核心代码量不算多但架构层面的调整才是最花时间的。最直观的好处是产品和代码都更干净了。VSCode 扩展的逻辑被强行塞进编辑器的插件机制里很多代码都是为了适配插件 API 而写的样板。独立应用之后游戏逻辑就是纯粹的 TypeScript 加 Vue 组件数据结构、模块边界都非常清晰。对还在犹豫要不要从 VSCode 扩展迁移到 Electron 的开发者我的建议是如果你的工具需要独立窗口、全局快捷键、系统托盘这些系统级能力或者你的用户根本不想打开 VSCode 来使用你的功能那就果断迁移。Electron 的开发模式已经很成熟electron-vite 把工程脚手架、热更新、打包流程全部打通了迁移成本并没有想象中那么高。但如果你只是想在 VSCode 里加一个辅助功能用户本来就开着编辑器做事那留在扩展生态里反而更合适没必要承受独立安装包的分发和维护成本。这个打字游戏后续我打算继续扩展的方向包括账号系统与云端成绩同步、多人实时打字比赛、更精细的训练数据分析。独立应用之后做这些扩展的想象空间比 VSCode 扩展大得多。如果你也在做类似的改造建议先从最小闭环跑通搭好 Electron 工程让现有功能完整跑起来再逐步叠加系统级能力不要一上来就追求大而全。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →