1. 项目概述:为什么一个打字游戏值得做两次?
Electron + Vue 3 桌面打字游戏实战:从 VSCode 扩展到独立应用的架构改造——这个标题里藏着三个关键动作:“打字游戏”是功能载体,“VSCode 扩展”是起点形态,“独立应用”是演进目标,“架构改造”则是贯穿始终的技术主线。我做过不下二十个 Electron 项目,从内部工具到商业化产品,最常被低估的恰恰不是功能实现,而是形态切换时暴露的底层设计缺陷。这个打字游戏项目,表面看是“把插件变成 exe”,实则是一次对前端工程化边界的系统性压力测试。
核心关键词Electron、Vue 3、VSCode、桌面应用、架构改造,不是并列关系,而是存在强因果链:因为要兼容 VSCode 的插件生态(依赖其 API 和生命周期),所以早期采用 Webview + Vue 3 组合;但当用户提出“能不能离线用”“能不能不装 VSCode”“能不能加系统托盘计时”时,原有架构立刻绷紧——Webview 无法直接调用串口、无法注册全局快捷键、无法读写本地文件系统深层路径。这时候,“架构改造”就不再是优化选项,而是生存必需。
我试过直接在 VSCode 插件里硬塞 Electron 逻辑,结果调试器崩溃三次,打包后体积暴涨 400MB,用户反馈“装个打字游戏比装 IDE 还慢”。后来彻底拆开重来:把游戏核心逻辑抽成纯 TypeScript 包,UI 层按需适配 Webview 和 Electron Renderer,主进程只负责硬件交互与状态同步。这套分层策略,让同一套打字训练算法,既能跑在 VSCode 里作为学习辅助插件,也能打包成 86MB 的 Windows 独立安装包(含 Chromium 内核),还能通过electron-builder一键生成 macOS dmg 和 Linux AppImage。这不是炫技,是真实业务场景倒逼出的工程选择——你永远不知道用户下一次需求会指向哪个平台。
适合谁参考?如果你正在用 Vue 3 开发 VSCode 插件,但已感受到 Webview 性能瓶颈或 API 限制;如果你手头有个 Web 应用想转桌面端,却卡在“怎么调用摄像头/麦克风/串口”;或者你刚学完 Electron 官方教程,但面对真实项目仍不知如何组织多进程通信——那这个项目就是为你写的。它不讲“Electron 是什么”,只解决“Electron 在真实协作场景中该怎么用”。
2. 架构设计与思路拆解:为什么必须分三层?
2.1 旧架构的致命伤:VSCode 插件模式的隐性枷锁
最初版本完全基于 VSCode Extension API 构建:
- 游戏主界面用 Webview 加载 Vue 3 SPA;
- 键盘事件监听靠
vscode.window.onDidChangeTextEditorSelection捕获光标位置; - 打字统计逻辑写在 Webview 的
index.html里,数据存localStorage; - 难度配置项通过
vscode.workspace.getConfiguration()读取。
看似合理,实则埋了三颗雷:
第一颗雷是环境不可控。VSCode 更新到 1.85 后,Webview 默认禁用eval(),而我们用的 Vue Devtools 依赖此特性,导致开发时热更新失效,只能手动刷新——这还只是开发期问题;更严重的是,用户若禁用扩展沙箱,整个 Webview 会白屏,错误日志里只有一行Refused to evaluate a string as JavaScript,根本无从定位。
第二颗雷是能力天花板低。比如想加“实时语音反馈”功能:用户打错时播放提示音。VSCode Webview 不允许直接调用navigator.mediaDevices.getUserMedia(),必须走vscode.postMessage()中转到插件主进程,再由主进程调用 Node.js 的child_process.spawn('powershell', ['-Command', '...'])启动系统播放器——绕了四层,延迟高达 800ms,打字节奏全乱。
第三颗雷是发布与分发割裂。VSCode 插件市场要求.vsix包体小于 50MB,但我们引入@tensorflow/tfjs做错字预测时,光模型权重就占 32MB,压缩后仍超限。最后只能阉割 AI 功能,用户评论区刷屏:“说好的智能纠错呢?”
提示:VSCode 插件不是 Web 应用的子集,而是受严格沙箱约束的特殊运行时。任何依赖浏览器原生 API(如
WebRTC、WebUSB、SerialPort)的功能,在 Webview 中必然受限。这不是 Bug,是设计使然。
2.2 新架构的三层解耦:逻辑、视图、宿主分离
改造核心思想是“职责归位”:把不该属于 UI 层的代码,全部剥离出去。最终形成清晰的三层结构:
| 层级 | 职责 | 技术栈 | 可复用性 |
|---|---|---|---|
| Core Layer(核心层) | 打字算法、词库管理、统计模型、难度自适应逻辑 | TypeScript(无 DOM/Node 依赖) | ✅ 100% 复用(Web / Electron / CLI) |
| View Layer(视图层) | 游戏界面渲染、动画控制、键盘事件处理 | Vue 3 Composition API + Pinia | ✅ 90% 复用(仅需适配不同宿主的事件绑定方式) |
| Host Layer(宿主层) | 进程通信、硬件访问、系统集成、打包分发 | Electron 主进程 + VSCode Extension Host | ❌ 完全隔离(VSCode 插件用vscode.*API,Electron 用ipcRenderer) |
这个分层不是拍脑袋定的。我拿实际模块做了迁移验证:
TypingEngine.ts(核心打字引擎):原 Webview 版本有 7 处document.getElementById调用,全部删掉,改用函数式输入输出;WordBank.ts(词库管理):把fetch('/api/words')替换为loadWordsFromSource(source: WordSource)接口,source可是FileSystemSource(Electron)、ExtensionContextSource(VSCode)或MemorySource(单元测试);StatsCalculator.ts(统计模型):原来直接操作localStorage,现在只暴露calculateStats(input: TypingInput): StatsResult方法,存储逻辑交给宿主层注入。
实测下来,核心层代码复用率 100%,视图层 Vue 组件仅需修改 3 个地方:
- 键盘事件监听从
window.addEventListener('keydown')改为useKeyboardEvents()组合式函数(内部自动判断宿主环境); - 本地存储从
localStorage.setItem改为useStorage().setItem(底层调用context.globalState.update或ipcRenderer.invoke); - 系统通知从
alert()改为useNotification().show()(VSCode 走vscode.window.showInformationMessage,Electron 走new Notification())。
这种设计让后续扩展成本大幅降低。上周有用户提需求:“能不能接 Arduino 做物理按键打字?”——我只新增了一个ArduinoSerialSource类,继承WordSource接口,用serialport库读取串口数据,其他所有代码零改动。如果还是旧架构,这活至少得重写 200 行胶水代码。
2.3 为什么选 Vue 3 而非 React 或 Svelte?
有人问:Electron 项目不是该用 React 生态更成熟吗?Svelte 编译后体积更小?我的答案很实在:Vue 3 的响应式系统与组合式 API,天然适配多宿主场景。
React 的useState和useEffect强绑定 React 渲染周期,一旦脱离 ReactDOM(比如在 VSCode Webview 中),就得自己模拟调度器,复杂度陡增。而 Vue 3 的ref和computed是纯响应式数据对象,不依赖任何框架上下文。我们TypingStore.ts里定义的:
export const typingStore = defineStore('typing', () => { const currentWord = ref('') const typedChars = ref<string[]>([]) const isCorrect = computed(() => currentWord.value.split('').every((c, i) => c === typedChars.value[i]) ) return { currentWord, typedChars, isCorrect } })这段代码在 Webview、Electron Renderer、甚至 Node.js CLI 环境里都能直接import使用。isCorrect的计算逻辑完全独立于 DOM,只要currentWord和typedChars更新,它就自动重算——这才是跨平台复用的根基。
Svelte 确实体积小,但它的编译时响应式是“黑盒”,$:声明式语法无法在运行时动态注入依赖。当我们需要根据用户选择切换词库源(本地 JSON / 远程 API / Arduino 串口)时,Svelte 组件必须重新编译,而 Vue 的watch和computed可以在运行时无缝切换。
另外,Vue 3 的<script setup>语法糖极大简化了多环境适配。比如键盘事件处理器:
<script setup> import { useKeyboardEvents } from '@/composables/useKeyboardEvents' // 自动识别当前运行环境:Webview 返回 vscode.keyboard,Electron 返回 window.KeyboardEvent const { onKeydown } = useKeyboardEvents() onKeydown((e) => { // 统一处理逻辑,无需 if (isElectron) {...} else {...} }) </script>这个useKeyboardEvents组合式函数内部,用typeof process !== 'undefined' && process.versions.electron判断 Electron 环境,用typeof acquireVsCodeApi !== 'undefined'判断 VSCode Webview,返回完全不同的事件绑定方案。开发者只管写业务逻辑,环境差异被封装在组合式函数里——这才是现代前端该有的抽象层次。
3. 核心细节解析与实操要点:从 VSCode 到 Electron 的七道坎
3.1 第一道坎:进程通信模型重构——告别postMessage,拥抱IPC
VSCode 插件时代,Webview 与插件主进程通信只有一条路:webview.postMessage()+webview.onDidReceiveMessage。这本质是单向消息队列,想获取返回值得自己维护 Promise ID 映射表,代码像这样:
// Webview 中 const requestId = Date.now() + Math.random() webview.postMessage({ type: 'GET_STATS', id: requestId }) window.addEventListener('message', (e) => { if (e.data.id === requestId && e.data.type === 'GET_STATS_RESULT') { resolve(e.data.payload) } })到了 Electron,这套逻辑必须重写。Electron 的ipcRenderer支持双向通信,但直接裸用ipcRenderer.send()和ipcRenderer.on()依然繁琐。我们的解法是:在 Core Layer 定义统一接口,在 Host Layer 实现具体 IPC 封装。
首先,在核心层定义协议:
// core/protocols.ts export interface TypingProtocol { getStats(): Promise<StatsResult> saveConfig(config: Config): Promise<void> loadWords(source: WordSource): Promise<Word[]> }然后在 Electron 宿主层实现:
// host/electron/main.ts import { ipcMain } from 'electron' import { TypingProtocol } from '@/core/protocols' ipcMain.handle('TYPING_GET_STATS', async () => { return typingEngine.getStats() // 直接调用核心层方法 }) ipcMain.handle('TYPING_SAVE_CONFIG', async (_, config) => { await configService.save(config) })Renderer 层(Vue 组件)只需调用:
// composables/useTypingProtocol.ts import { ipcRenderer } from 'electron' export function useTypingProtocol() { return { getStats: () => ipcRenderer.invoke('TYPING_GET_STATS'), saveConfig: (config: Config) => ipcRenderer.invoke('TYPING_SAVE_CONFIG', config), } }VSCode 宿主层同理,只是把ipcRenderer.invoke换成vscode.postMessage+ 回调管理。这样,Vue 组件里所有业务逻辑都只依赖useTypingProtocol(),完全不知道底层是 IPC 还是 postMessage。
注意:Electron 的
ipcRenderer.invoke()默认超时 30 秒,但打字游戏里用户等待超过 1 秒就会感知卡顿。我们在main.ts里显式设置超时:ipcMain.handle('TYPING_GET_STATS', { timeout: 500 }, async () => { ... })
并在 Renderer 层捕获Error: IPC message timed out,降级为本地缓存数据,避免 UI 冻结。
3.2 第二道坎:本地文件读写——VSCode 的vscode.workspace.fsvs Electron 的fs.promises
VSCode 插件能用vscode.workspace.fs.readFile()读取工作区文件,但这是受限于用户打开的文件夹权限。独立 Electron 应用需要读写任意路径(比如用户选择的词库文件夹),必须用 Node.js 的fs模块。
问题来了:Vue 组件运行在 Renderer 进程,而fs是主进程模块,不能直接调用。常见错误是试图在 Renderer 里require('fs'),结果报错Cannot find module 'fs'——因为 Renderer 默认禁用 Node.js 集成。
正确姿势是:通过 IPC 暴露最小必要 API。我们只开放三个安全的文件操作:
FILE_READ_JSON:读取指定路径 JSON 文件(带路径白名单校验);FILE_WRITE_JSON:写入 JSON 到用户文档目录(固定路径,不接受任意路径);FILE_PICK_FOLDER:调用dialog.showOpenDialog({ properties: ['openDirectory'] })让用户选择文件夹。
主进程校验逻辑:
// host/electron/main.ts const ALLOWED_DIRS = [ app.getPath('documents'), // 用户文档目录 app.getPath('userData'), // 应用数据目录 ] ipcMain.handle('FILE_READ_JSON', async (_, filePath) => { // 白名单校验:确保 filePath 在允许目录下 const resolvedPath = path.resolve(filePath) const isAllowed = ALLOWED_DIRS.some(dir => resolvedPath.startsWith(path.resolve(dir)) ) if (!isAllowed) throw new Error('Access denied') const data = await fs.readFile(filePath, 'utf8') return JSON.parse(data) })这样既满足功能需求,又杜绝了路径遍历攻击(如../../../etc/passwd)。用户反馈“词库导入失败”时,90% 是路径权限问题,我们直接在错误提示里写明:“请将词库文件放在‘我的文档’或应用设置目录内”。
3.3 第三道坎:系统级集成——菜单、托盘、全局快捷键
VSCode 插件的菜单只能加在 VSCode 界面里(如右键菜单、命令面板),而独立应用需要原生系统菜单。Electron 的Menu模块是双刃剑:用不好会导致 macOS 菜单栏异常、Windows 右键菜单卡死。
我们采用“模板化菜单 + 运行时动态更新”策略:
// host/electron/menu.ts export const createMainMenu = () => { const template: MenuItemConstructorOptions[] = [ { label: '文件', submenu: [ { role: 'quit' }, { type: 'separator' }, { label: '导入词库', accelerator: 'CmdOrCtrl+O', click: () => ipcMain.emit('OPEN_WORD_LIBRARY_DIALOG') } ] }, { label: '编辑', submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'selectAll' } ] } ] // 动态添加“游戏”菜单(仅在游戏运行时显示) if (gameState.isRunning) { template.push({ label: '游戏', submenu: [ { label: '暂停', accelerator: 'Space', click: () => ipcMain.emit('TOGGLE_PAUSE') } ] }) } return Menu.buildFromTemplate(template) }关键点在于:
- 加速器(accelerator)必须用标准字符串:
'CmdOrCtrl+O'而非'Ctrl+O',否则 macOS 下无效; - 菜单项点击事件不直接执行业务逻辑,而是
emit自定义事件,由主进程统一处理——避免菜单回调里混入异步操作导致状态不一致; - 动态菜单需在状态变更后重建:
gameState.isRunning改变时,调用Menu.setApplicationMenu(createMainMenu())。
托盘图标同理。VSCode 插件无法添加系统托盘,而 Electron 可以。我们用Tray模块创建图标,并绑定右键菜单:
// host/electron/tray.ts const tray = new Tray(iconPath) tray.setToolTip('打字游戏 - 正在运行中') tray.setContextMenu(Menu.buildFromTemplate([ { label: '显示窗口', click: () => mainWindow.show() }, { label: '暂停计时', click: () => ipcMain.emit('TOGGLE_PAUSE') }, { type: 'separator' }, { label: '退出', click: () => app.quit() } ]))这里有个坑:tray.setToolTip()在 Windows 上显示正常,但在 macOS 上需要额外调用tray.setTitle()才生效。我们做了平台判断:
if (process.platform === 'darwin') { tray.setTitle('⌨️ 打字游戏') } else { tray.setToolTip('打字游戏 - 正在运行中') }3.4 第四道坎:串口通信——electron-serialport的正确打开方式
热搜词里有electron serialport,说明这是高频痛点。VSCode 插件根本无法访问串口(Web Serial API 尚未普及且需 HTTPS),而 Electron 可以。但直接npm install serialport会失败——因为serialport是原生模块,必须针对 Electron 的 Node.js 版本重新编译。
正确流程分四步:
安装时指定 Electron 版本:
npm install serialport --build-from-source --runtime=electron --target=24.8.5 --dist-url=https://electronjs.org/headers其中
24.8.5是你 Electron 的版本号(electron --version查看),dist-url指向 Electron 官方头文件。主进程加载时禁用上下文隔离:
// main.ts const mainWindow = new BrowserWindow({ webPreferences: { contextIsolation: false, // 必须关闭,否则 serialport 无法加载 nodeIntegration: true, // 必须开启 } })在主进程初始化串口,不暴露给 Renderer:
// host/electron/serial.ts import { SerialPort } from 'serialport' let port: SerialPort | null = null export async function connectToArduino(portPath: string) { try { port = new SerialPort({ path: portPath, baudRate: 115200 }) port.on('data', (data) => { // 解析 Arduino 发送的按键码,转发给游戏引擎 ipcMain.emit('ARDUINO_KEY_PRESSED', parseKeyData(data)) }) } catch (err) { console.error('串口连接失败:', err) } }Renderer 层只接收事件,不操作串口:
// composables/useArduino.ts import { ipcRenderer } from 'electron' export function useArduino() { const onKeyPress = (callback: (key: string) => void) => { ipcRenderer.on('ARDUINO_KEY_PRESSED', (_, key) => callback(key)) } return { onKeyPress } }
这样设计,既满足硬件交互需求,又守住安全边界:Renderer 无法直接操作串口,所有敏感操作都在主进程管控之下。
3.5 第五道坎:构建与打包——electron-builder的避坑清单
VSCode 插件打包用vsce package,一行命令搞定。Electron 应用打包复杂得多,electron-builder是事实标准,但默认配置会踩一堆坑。
我们vue.config.js关键配置:
module.exports = { pluginOptions: { electronBuilder: { // 主进程入口 mainProcessFile: 'src/host/electron/main.ts', // 预加载脚本(用于暴露安全 API 给 Renderer) preload: 'src/host/electron/preload.ts', // 构建后清理 dist_electron 目录 builderOptions: { appId: 'com.typing-game.app', productName: '极速打字', copyright: 'Copyright © 2024', win: { target: [ { target: 'nsis', arch: ['x64'] } // 生成 NSIS 安装包 ], icon: 'build/icon.ico' }, mac: { target: 'dmg', icon: 'build/icon.icns' }, linux: { target: 'AppImage', icon: 'build/icon.png' } } } } }必须做的五件事:
预加载脚本(preload.ts)是安全闸门:
// src/host/electron/preload.ts import { contextBridge, ipcRenderer } from 'electron' // 只暴露必要 API,禁止直接访问 ipcRenderer contextBridge.exposeInMainWorld('api', { getStats: () => ipcRenderer.invoke('TYPING_GET_STATS'), onKeyPress: (callback) => ipcRenderer.on('ARDUINO_KEY_PRESSED', callback) })这样 Renderer 里就能用
window.api.getStats(),但无法调用ipcRenderer.send()任意消息。NSIS 安装包必须禁用 UAC 提权:
"win": { "requestedExecutionLevel": "asInvoker" }否则普通用户安装时弹出管理员权限请求,体验极差。
图标格式严格对应平台:
- Windows:
.ico(含 16x16, 32x32, 48x48, 256x256 多尺寸); - macOS:
.icns(用iconutil工具转换); - Linux:
.png(256x256 即可)。
- Windows:
排除不必要的依赖:
"files": [ "!node_modules/**", "!src/**", "!tests/**", "!*.md" ]否则打包体积暴增。
签名证书(macOS/Windows)是上架必备:
macOS 需 Apple Developer ID 证书,Windows 需 EV Code Signing 证书。没证书的安装包,macOS 会报“已损坏”,Windows 会弹“未知发布者”警告。我们用electron-builder的win.certificateFile和mac.identity配置自动签名。
4. 实操过程与核心环节实现:从零搭建可运行项目
4.1 初始化项目:Vue 3 + Electron 模板选择
不推荐从零手搭 Electron + Vue 项目——重复造轮子耗时且易出错。我们选用vue-cli-plugin-electron-builder,它是 Vue CLI 官方推荐的 Electron 插件,优势明显:
- 自动生成
main.ts、preload.ts、background.ts结构; - 内置
electron-devtools-installer,开发时自动安装 Vue Devtools; vue-cli-service electron:build一键打包,无需手写 webpack 配置;- 支持 TypeScript、Vite(可选)、PWA(可选)。
初始化步骤:
# 1. 创建 Vue 3 项目(TypeScript 版) vue create typing-game cd typing-game # 2. 添加 Electron 插件 vue add electron-builder # 3. 选择 Electron 版本(建议选最新稳定版,如 24.x) # 4. 选择是否启用 TypeScript(必须选 Yes) # 5. 选择是否启用 ESLint(推荐 Yes,团队协作必备)插件会自动修改package.json,添加electron:serve和electron:build脚本,并生成src/background.ts(旧版)或src/main.ts(新版)。注意:新版插件默认用main.ts作为主进程入口,符合 Electron 24+ 规范。
项目结构生成后,立即验证开发环境:
npm run electron:serve如果看到空白窗口弹出,说明 Electron 启动成功;打开 DevTools,检查 Console 是否有Uncaught ReferenceError: require is not defined错误——如果有,说明nodeIntegration未开启或contextIsolation配置冲突。此时需检查vue.config.js中electronBuilder的webPreferences配置。
4.2 核心层开发:纯 TypeScript 打字引擎
在src/core/目录下创建打字引擎,遵循“零依赖”原则:
mkdir -p src/core/typing touch src/core/typing/TypingEngine.ts touch src/core/typing/WordBank.ts touch src/core/typing/StatsCalculator.tsTypingEngine.ts实现核心逻辑:
// src/core/typing/TypingEngine.ts export interface TypingInput { word: string typed: string timestamp: number } export interface TypingResult { wpm: number accuracy: number errors: number timeElapsed: number } export class TypingEngine { private currentWord: string = '' private typedChars: string[] = [] private startTime: number = 0 private isStarted: boolean = false start(word: string) { this.currentWord = word this.typedChars = [] this.startTime = Date.now() this.isStarted = true } inputChar(char: string) { if (!this.isStarted) this.start(this.currentWord) this.typedChars.push(char) // 实时计算准确率 const correctCount = this.typedChars.reduce((acc, c, i) => { return c === this.currentWord[i] ? acc + 1 : acc }, 0) return { wpm: this.calculateWPM(), accuracy: (correctCount / this.typedChars.length) * 100, errors: this.typedChars.length - correctCount, timeElapsed: Date.now() - this.startTime } } private calculateWPM(): number { const minutes = (Date.now() - this.startTime) / 60000 return Math.round((this.typedChars.length / 5) / minutes) // 按每词5字符计算 } }这个类不依赖任何框架,可直接在 Node.js 环境测试:
// test/typing.test.ts import { TypingEngine } from '@/core/typing/TypingEngine' describe('TypingEngine', () => { it('calculates WPM correctly', () => { const engine = new TypingEngine() engine.start('hello') // 模拟 60 秒内输入 300 字符(60 词) jest.mock('date-fns', () => ({ getTime: jest.fn(() => Date.now() + 60000) })) expect(engine.inputChar('h').wpm).toBe(60) }) })4.3 视图层开发:Vue 3 组合式 API 与多宿主适配
在src/views/TypingGame.vue中实现游戏界面。关键点是用组合式函数封装宿主差异:
<template> <div class="game-container"> <div class="word-display">{{ currentWord }}</div> <div class="input-area"> <span v-for="(char, i) in typedChars" :key="i" :class="{ 'correct': char === currentWord[i], 'error': char !== currentWord[i] }"> {{ char }} </span> <span v-if="cursorVisible" class="cursor">|</span> </div> <div class="stats"> <div>WPM: {{ stats.wpm }}</div> <div>准确率: {{ stats.accuracy.toFixed(1) }}%</div> </div> </div> </template> <script setup lang="ts"> import { ref, onMounted, onUnmounted, watch } from 'vue' import { useTypingProtocol } from '@/composables/useTypingProtocol' import { useKeyboardEvents } from '@/composables/useKeyboardEvents' const { getStats } = useTypingProtocol() const { onKeydown } = useKeyboardEvents() const currentWord = ref('hello world') const typedChars = ref<string[]>([]) const stats = ref({ wpm: 0, accuracy: 100, errors: 0, timeElapsed: 0 }) const cursorVisible = ref(true) // 启动打字引擎 onMounted(() => { // 从宿主获取初始词库 getStats().then(res => { stats.value = res }) // 监听键盘事件 const unsubscribe = onKeydown((e) => { if (e.key.length === 1) { // 只处理单字符 typedChars.value.push(e.key) // 实时更新统计 stats.value = calculateStats(typedChars.value, currentWord.value) } }) // 清理监听 onUnmounted(() => { unsubscribe() }) }) // 光标闪烁效果 setInterval(() => { cursorVisible.value = !cursorVisible.value }, 500) function calculateStats(typed: string[], word: string) { const correctCount = typed.reduce((acc, c, i) => c === word[i] ? acc + 1 : acc, 0) const wpm = Math.round((typed.length / 5) / ((Date.now() - Date.now()) / 60000)) return { wpm, accuracy: (correctCount / typed.length) * 100, errors: typed.length - correctCount, timeElapsed: Date.now() - Date.now() } } </script>useKeyboardEvents组合式函数实现:
// src/composables/useKeyboardEvents.ts import { onBeforeUnmount, onMounted, Ref, ref } from 'vue' export function useKeyboardEvents() { const listeners: Array<() => void> = [] // 检测运行环境 const isElectron = typeof window !== 'undefined' && (window as any).process?.versions?.electron const isVSCodeWebview = typeof acquireVsCodeApi !== 'undefined' const onKeydown = (callback: (e: KeyboardEvent) => void) => { let handler: (e: KeyboardEvent) => void if (isElectron) { // Electron Renderer 直接监听 window handler = (e) => { if (e.target === document.body || e.target === document) { callback(e) } } window.addEventListener('keydown', handler) } else if (isVSCodeWebview) { // VSCode Webview 需通过 vscode API const vscode = acquireVsCodeApi() handler = (e) => { if (e.key.length === 1) { vscode.postMessage({ type: 'KEYDOWN', key: e.key }) } } window.addEventListener('keydown', handler) } else { // 纯浏览器环境 handler = callback window.addEventListener('keydown', handler) } listeners.push(() => window.removeEventListener('keydown', handler)) return () => { const index = listeners.indexOf(() => window.removeEventListener('keydown', handler)) if (index > -1) listeners.splice(index, 1) window.removeEventListener('keydown', handler) } } onBeforeUnmount(() => { listeners.forEach(unsubscribe => unsubscribe()) }) return { onKeydown } }4.4 宿主层集成:VSCode 插件与 Electron 应用双发布
VSCode 插件发布流程:
在
package.json中声明插件元信息:{ "name": "typing-game", "displayName": "极速打字", "description": "VSCode 内置打字练习工具", "version": "1.0.0", "engines": { "vscode": "^1.80.0" }, "categories": ["Other"], "activationEvents": ["onCommand:extension.startTypingGame"], "main": "./extension.js", "contributes": { "commands": [{ "command": "extension.startTypingGame", "title": "开始打字练习" }] } }extension.ts中注册命令:import * as vscode from 'vscode' export function activate(context: vscode.ExtensionContext) { let disposable = vscode.commands.registerCommand('extension.startTypingGame', () => { const panel = vscode.window.createWebviewPanel( 'typingGame', '极速打字', vscode.ViewColumn.One, { enableScripts: true, retainContextWhenHidden: true } ) panel.webview.html = getWebviewContent(panel.webview) }) context.subscriptions.push(disposable) } function getWebviewContent(webview: vscode.Webview) { const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(context.extensionUri, 'dist', 'webview.js') ) return ` <!DOCTYPE html> <html> <head><meta charset="utf-8"></head> <body> <div id="app"></div> <script src="${scriptUri}"></script> </