
1. 从 VSCode 扩展到独立应用这步改造到底图什么1.1 项目来源那个在 VSCode 里跑起来的打字游戏事情要从一个 VSCode 扩展说起。当时我用 Webview 写了一款打字练习游戏在编辑器里直接唤出面板就能练支持中英文词库、实时 WPM每分钟单词数统计、正确率追踪代码量不大但相当能打。扩展虽然小巧在编辑器场景里跑得也算顺但把项目推给朋友和同事用了一段时间之后问题一个一个浮出来了。最明显的一条是用户根本不想为了练打字去打开一个代码编辑器。打字练习的场景往往很碎片是想练的时候立刻进入全屏专注状态练完关掉就走——VSCode 的窗口、侧边栏、状态栏都还在画面上那种沉浸感完全谈不上一款正式打字游戏该有的样子。有人问能不能让我把它当成一个独立 APP 打开能全屏吗能开机直接启动吗能自定义皮肤词库并且自己保存配置文件吗——这些诉求一个 Webview 扩展几乎都答不了。这个背景直接决定了改造的路线不再作为一个扩展寄生在 VSCode 里而是把整个游戏从 VSCode 体系中解耦出来做成一个独立桌面应用。1.2 为什么最终选择 Electron Vue 3 这套组合桌面应用的技术路线有很多Tauri、Electron、Qt、Flutter Desktop、原生开发。对这款打字游戏来说Electron 是综合成本最低、收益最直接的选择。第一VSCode 扩展本身就是跑在 Electron 壳里的Webview 的渲染环境本质上是 Chromium。这意味着原来扩展里的 React/Vue 组件、业务逻辑、DOM 操作、样式几乎可以无缝搬运过来改造成本集中在外壳而不是引擎。第二项目里大量逻辑是前端思维文本渲染、键盘事件监听、DOM 高亮、动画反馈。用 Electron 就等于把浏览器能力搬到桌面上Vue 3 的 Composition API 对这类交互频率高、状态分散的应用写起来非常舒服。尤其是打字游戏里那种开始→计时→输入→统计→结算的流程用组合式函数把定时器、按键监听、统计数据聚合在一起比类组件时代清爽太多。第三生态和坑的资料足够多。Electron 从打包到分发到自动更新都有成熟方案做个小游戏级别的独立应用完全够用。要说有没有考虑过 Tauri考虑过但当时团队对 Rust 的掌控力一般而且 Tauri 在 Windows 上需要 WebView2 运行时旧一点系统的用户会被卡住。对一个小型打字游戏来说Electron 的 70MB-100MB 体积成本可以接受换来的是装哪都能跑的确定性。1.3 改造的目标与范围定义动工之前先把目标写清楚否则很容易变成推翻重写。我这次改造明确划定了三个目标和一个不做什么边界目标一游戏功能完整迁移——文本生成、键盘输入判定、错误标注、实时统计WPM/准确率/用时、结果结算一个都不能少。 目标二体验升级——窗口可全屏、可无边框、可记住上次窗口大小数据从 VSCode 的 globalState 迁移到本地 JSON 文件用户可备份。 目标三架构可扩展——单词库、主题配色、按键音效、多语言文案都能通过配置文件扩展。明确不做的不做登录、不做云同步、不做多人对战、不做内置词库编辑器第一版先通过配置文件维护。范围控制住了工期才能控制在两周内。2. 架构改造的整体设计三个进程的高效分工2.1 从 Webview 单进程到 Electron 主进程/渲染进程的思维转换这是整个改造中最关键的思维切换。VSCode 扩展里你只有一面 Webview所有东西都跑在这个前端环境里VSCode 扩展本身跑在一个独立的 Node.js 扩展宿主进程通过acquireVsCodeApi()这唯一通道收发消息。Electron 里就不一样了主进程Main Process负责创建窗口、管理系统级事件渲染进程Renderer Process跑页面也就是原来的 Webview 内容中间用 IPCInter-Process Communication通信preload 脚本通过 contextBridge 安全地暴露能力给页面。打字游戏恰好是那种典型的三层结构应用渲染进程管 UI、管游戏循环、管键盘事件、管动画。主进程管窗口创建、管本地文件读写统计数据、用户词库、管系统级快捷键。preload作为中间枢纽只暴露有限的 API不让渲染进程直接拿到 Node 能力。为什么不让页面直接引入 Node.js 的 fs 去读写文件这是 Electron 安全模型的基本要求。渲染进程页面加载的是本地 html理论上如果存在 XSS 漏洞恶意脚本能拿到 Node 权限就是灾难。所以 preload 里通过 contextBridge 暴露window.api.readStats()、window.api.saveStats()这类白名单接口渲染进程只管调不管底层实现。2.2 目录结构与模块边界划分改造后的项目目录我按功能而非技术栈分层typing-game-electron/ ├── electron.vite.config.ts ├── package.json ├── src/ │ ├── main/ │ │ ├── index.ts │ │ ├── window.ts │ │ ├── ipc.ts │ │ └── store.ts │ ├── preload/ │ │ ├── index.ts │ │ └── index.d.ts │ ├── renderer/ │ │ ├── index.html │ │ └── src/ │ │ ├── main.ts │ │ ├── App.vue │ │ ├── assets/ │ │ ├── components/ │ │ │ ├── TypingArea.vue │ │ │ ├── StatsBar.vue │ │ │ ├── ResultModal.vue │ │ │ └── SettingsPanel.vue │ │ ├── composables/ │ │ │ ├── useTypingEngine.ts │ │ │ └── useTimer.ts │ │ └── stores/ │ │ └── stats.ts │ └── shared/ │ ├── constants.ts │ └── types.ts └── resources/ ├── wordlist-en.json ├── wordlist-zh.json └── icon.pngmain、preload、renderer、shared 四个目录各司其职。shared 放 IPC 通道名常量和公共类型定义两端都引它保证消息契约不错位。renderer 内部按 Vue 习惯继续拆但整个应用逻辑核心收敛在useTypingEngine里。2.3 IPC 通信设计用 invoke/handle 还是 send/onElectron 的 IPC 有两种常见模式。ipcRenderer.sendipcMain.on是单向/事件通知适合发消息不关心返回值的场景ipcRenderer.invokeipcMain.handle是请求-响应模式适合调接口拿结果的场景。打字游戏里绝大部分 IPC 调用都是请求-响应保存统计数据需要知道是否成功、读取词库需要拿到内容、获取用户数据目录需要返回路径。这些都走 invoke/handle。事件推送我只用在一个场景主进程在打包版本里向渲染进程通知更新了——这个用一次就够了。通道名统一收敛在 shared/constants.ts 里// src/shared/constants.ts export const IpcChannels { StatsGet: stats:get, StatsSave: stats:save, WordlistGet: wordlist:get, AppVersion: app:get-version, WindowMinimize: window:minimize, WindowMaximize: window:maximize, WindowClose: window:close, } as const用常量而不是字符串字面量是为了后续维护时函数签名变了、通道名被误改编译器能第一时间报错。3. 核心细节解析与实操要点打游戏逻辑的迁移动线3.1 打字引擎从 VSCode Webview 到独立渲染进程的移植策略原来 VSCode 扩展里打字游戏的核心逻辑是一个 TypeScript 类输入判定靠 DOM 事件配合状态机管理。逻辑并不依赖 VSCode API所以这次移植策略很明确把引擎层原封不动搬到 Vue 的 composable 里只改 UI 层的调用方式。具体实现拆解看几个关键点。第一文本生成。用户选择词库后从 JSON 里随机抽取 N 个单词拼接成测试文本。中文词库我按短语为单位每个条目是 2-12 个字符的一个词或短句英文则是单词。生成算法很简单但有个坑不能有重复词太密集否则键盘输入会产生肌肉记忆干扰统计。我的做法是洗牌抽样保证同一轮文本中 90% 的条目不重复。第二输入监听。打字游戏必须监听keydown事件在字符到达屏幕之前判定正确/错误。这里有个核心逻辑beforeinput事件在中文输入法下的行为不可靠所以英文模式监听keydown中文模式需要特殊处理。// renderer/src/composables/useTypingEngine.ts (核心逻辑节选) const handleKeydown (e: KeyboardEvent) { if (isComposing.value) return if (e.ctrlKey || e.metaKey || e.altKey) return const expected currentText.value[currentIndex.value] // 英文模式直接对比 key 与预期字符 if (e.key.length 1 /^[a-zA-Z0-9.,;:!?]$/.test(e.key)) { e.preventDefault() const success e.key.toLowerCase() expected.toLowerCase() success ? onCorrect() : onError(e.key) } // 退格处理 if (e.key Backspace currentIndex.value 0) { currentIndex.value-- // 回退时把最后一个字符恢复为未输入状态允许重输 } }注意preventDefault()要放在判定成功与否之前防止浏览器把按键回显到页面导致文本错位。这一步在 VSCode Webview 和 Electron 渲染进程里行为一致没有额外坑。第三计时器。计时从用户按下第一个有效字符开始不是从页面加载开始。这个点很容易被偷懒实现成进来就开始计时那是错的测试者会犹豫几秒再动手统计出来的 WPM 严重偏低。我用一个startedAt标志位首次按键成功后才开始记录performance.now()基准时间。3.2 统计逻辑的增强WPM、准确率与连续正确率VSCode 版本里统计指标比较单一总字数、用时、WPM、准确率。这次改造我加了一个连续正确数指标用来衡量节奏稳定性同时对结果结算的星级评价提供依据。WPMWords Per Minute计算方式(正确输入字符数 / 5) / 用时(分钟)。行业惯例中一个英文单词按 5 个字符折算中文字符按一个字一个字符算然后 WPM 数值独立显示中文模式下为 CPMCharacters Per Minute。准确率计算正确输入字符数 / 总输入字符数。这里有个容易被忽略的细节退格后的重输是否计入分母我的处理是不计。因为打字游戏考察的是当前按键是否是目标字符退格本身不是输入行为而是纠错行为如果计入会惩罚过度。连续正确数从游戏开始后连续输入正确的最大次数超过 10 个会在 StatsBar 上显示连击特效。这个指标对新手有反馈感对老手有挑战性实现成本极低但体验提升明显。StatsBar 组件用requestAnimationFrame每 250ms 从引擎的响应式数据取一次值更新显示避免每个字符都触发整栏重渲染导致掉帧。3.3 输入法IME冲突处理中文打字最容易踩的隐形雷这是所有中文打字游戏开发者必须面对的问题。输入法在 Electron 渲染进程里的表现和浏览器略有差异主要坑在compositionend事件时序。中文模式下用户用拼音输入一个词期间会产生一串compositionupdate事件此时你不能按逐个字符判定输入是否正确因为用户正在候选框里选字。如果引擎在 composition 期间响应keydown会把拼音字母误判为输入字符统计直接爆炸。我的方案是// 渲染进程入口或根组件 const isComposing ref(false) window.addEventListener(compositionstart, () { isComposing.value true }) window.addEventListener(compositionend, (e) { isComposing.value false // 这里读取 e.data它是最终上屏的完整文本 const committed e.data || typingEngine.commitChineseText(committed) })英文模式下isComposing永远为 false所以不影响keydown实时判定。中文模式下等compositionend把整段上屏文本交给引擎统一判定速度会略慢于逐个字符但正确率显著提升。另一个小坑部分输入法在英文模式下也会触发compositionstart比如搜狗输入法的中英切换不彻底。所以判断isComposing时不能只看事件还要看e.isComposing属性是否存在于KeyboardEvent上。这个属性在 Chrome 68 有原生支持Electron 必然支持直接用。4. 实操过程与核心环节实现从工程搭建到窗口管理4.1 工程脚手架为什么用 electron-vite 而不是手动拼装手写 Electron 工程配置其实不复杂但繁琐点很多主进程 TS 编译、渲染进程热更新、开发时加载本地 dev server、生产打包时路径切换。这些折腾一轮后就发现直接上 electron-vite 是最省心的方案。electron-vite 相当于把 Vite 的构建能力同时应用到主进程、preload、渲染进程三个部分npm create quick-start/electronlatest typing-game -- --template vue-ts这个脚手架帮你搞定src/main/index.ts、src/preload/index.ts、src/renderer/的标准结构和基础配置。后续新增文件直接往对应目录塞就行构建时 electron-vite 会分别输出out/main、out/preload、out/renderer。开发模式跑npm run dev就能同时拉起 Vite dev server 和 Electron 窗口主进程改动还会自动重启应用比手写配置爽太多。4.2 主进程与 preload 实现细节主进程我拆成 window.ts 和 ipc.ts分别管窗口生命周期和IPC 业务接口。window.ts 的窗口创建逻辑注意几个参数// src/main/window.ts import { BrowserWindow, shell } from electron import { join } from path export function createMainWindow(): BrowserWindow { const win new BrowserWindow({ width: 960, height: 640, minWidth: 600, minHeight: 400, show: false, autoHideMenuBar: true, frame: process.platform darwin, // macOS 保留系统标题栏Windows/Linux 用无边框自定义标题栏 webPreferences: { preload: join(__dirname, ../preload/index.js), sandbox: false, contextIsolation: true, nodeIntegration: false, }, }) win.on(ready-to-show, () win.show()) win.webContents.setWindowOpenHandler((details) { // 外部链接一律用默认浏览器打开绝不在应用内开新窗口 const url new URL(details.url) if (url.protocol https:) { shell.openExternal(details.url) } return { action: deny } }) if (is.dev process.env[ELECTRON_RENDERER_URL]) { win.loadURL(process.env[ELECTRON_RENDERER_URL]) } else { win.loadFile(join(__dirname, ../renderer/index.html)) } return win }contextIsolation: true和nodeIntegration: false是安全红线Electron 官方和主流安全指南都反复强调页面里绝不能直接开 Node 集成。sandbox: false是因为 preload 里用了 Node 的 path 模块虽然这里只是拼接路径但沙箱模式限制了 preload 里的 require在业务能跑通的前提下尽量别开 true除非你能保证 preload 完全不需要 Node API。preload 的接口暴露 Na长这样// src/preload/index.ts import { contextBridge, ipcRenderer } from electron import { IpcChannels } from ../shared/constants const api { getStats: () ipcRenderer.invoke(IpcChannels.StatsGet), saveStats: (data: StatsData) ipcRenderer.invoke(IpcChannels.StatsSave, data), getWordlist: (lang: en | zh) ipcRenderer.invoke(IpcChannels.WordlistGet, lang), minimize: () ipcRenderer.invoke(IpcChannels.WindowMinimize), maximize: () ipcRenderer.invoke(IpcChannels.WindowMaximize), close: () ipcRenderer.invoke(IpcChannels.WindowClose), getAppVersion: () ipcRenderer.invoke(IpcChannels.AppVersion), } contextBridge.exposeInMainWorld(api, api)渲染进程里 TS 的类型声明可以这样接上// src/renderer/src/env.d.ts interface Window { api: { getStats(): PromiseStatsData saveStats(data: StatsData): Promise{ success: boolean } getWordlist(lang: en | zh): PromiseWordItem[] minimize(): Promisevoid maximize(): Promisevoid close(): Promisevoid getAppVersion(): Promisestring } }4.3 统计数据的本地持久化从 VSCode globalState 到 JSON 文件VSCode Webview 里数据存在context.globalState它会跟着 VSCode 设置同步走。独立应用里我们需要自己的存储方案最简单靠谱的就是一个 JSON 文件放在用户数据目录下。// src/main/store.ts import { app } from electron import { join } from path import { readFileSync, writeFileSync, existsSync, mkdirSync } from fs const DATA_DIR join(app.getPath(userData), data) const STATS_FILE join(DATA_DIR, stats.json) const SETTINGS_FILE join(DATA_DIR, settings.json) export function readJsonFileT(file: string, defaultValue: T): T { try { if (!existsSync(file)) return defaultValue return JSON.parse(readFileSync(file, utf-8)) as T } catch (err) { console.error([store] read failed: ${file}, err) return defaultValue } } export function writeJsonFileT(file: string, data: T): void { try { mkdirSync(DATA_DIR, { recursive: true }) writeFileSync(file, JSON.stringify(data, null, 2), utf-8) } catch (err) { console.error([store] write failed: ${file}, err) } }stats.json 的结构{ totalSessions: 126, totalCharacters: 182340, totalTimeSeconds: 21256, bestWpm: 92.5, bestAccuracy: 99.1, history: [ { date: 2025-02-14, wpm: 78.3, accuracy: 97.2, mode: en, duration: 85 } ] }保存策略是每回合结束时写一次即可没必要实时写盘。但要注意关机前若窗口被强杀最后一次结果可能丢失。解决的办法是在渲染进程收到beforeunload事件的同一时间先把本次会话记录通过 IPC 同步写盘再做窗口关闭。这样即使崩溃至多丢一回合数据不会丢整个文件。主进程的 ipc.ts 里把这些接口接上ipcMain.handle(IpcChannels.StatsGet, () readJsonFile(STATS_FILE, defaultStats)) ipcMain.handle(IpcChannels.StatsSave, (_e, data: StatsData) { writeJsonFile(STATS_FILE, data) return { success: true } })4.4 窗口控制与自定义标题栏主进程的窗口控制逻辑配合渲染进程的自定义标题栏。Windows/Linux 下我关掉了系统标题栏用-webkit-app-region: drag实现可拖拽区域三个窗口按钮最小化、最大化、关闭渲染在自定义标题栏右侧点击后调 preload 暴露的接口。.titlebar { height: 36px; -webkit-app-region: drag; user-select: none; } .titlebar-btn { -webkit-app-region: no-drag; }// 渲染进程标题栏按钮事件 const minimize () window.api.minimize() const maximize () window.api.maximize() const close () window.api.close()主进程端ipcMain.handle(IpcChannels.WindowMinimize, (e) { BrowserWindow.fromWebContents(e.sender)?.minimize() }) ipcMain.handle(IpcChannels.WindowMaximize, (e) { const win BrowserWindow.fromWebContents(e.sender) if (!win) return win.isMaximized() ? win.unmaximize() : win.maximize() }) ipcMain.handle(IpcChannels.WindowClose, (e) { BrowserWindow.fromWebContents(e.sender)?.close() })这里的关键是始终用e.sender去定位窗口不要自己持有mainWindow全局引用。多窗口场景下全局引用容易指错窗口且会导致变量内存泄漏。4.5 打包与分发配置要点打包我用 electron-builder配置写在 electron-builder.ymlappId: com.typinggame.app productName: TypeFaster directories: buildResources: resources files: - out/** - resources/** asar: true win: target: - target: nsis arch: [x64] nsis: oneClick: false allowToChangeInstallationDirectory: true mac: target: [dmg] category: public.app-category.education关于 asar 的一个必知细节默认asar: true打包后资源和 JS 都被塞进app.asar里但resources/目录下的词库 JSON 不会被压缩进 asar因为files配置里包含了resources/**electron-builder 会把它作为 extraResources 拷贝到主进程可访问的位置。主进程读取词库时应该用process.resourcesPath拼接路径而不是硬编码__dirname的相对路径——因为 production 模式下__dirname指向 asar 内部直接拼相对路径会找不到文件。// src/main/ipc.ts import { app } from electron import { join } from path const WORDLIST_DIR join(process.resourcesPath, wordlists) ipcMain.handle(IpcChannels.WordlistGet, (_e, lang: string) { // 注意.json 打包到 asar 外用 readFileSync 正常读取 const file join(WORDLIST_DIR, wordlist-${lang}.json) return JSON.parse(readFileSync(file, utf-8)) })5. 常见问题与排查技巧实录我踩过的那些坑5.1 键盘监听与全局快捷键冲突打字游戏的键盘监听是核心交互但 Electron 里默认Menu带了一堆全局快捷键。你没主动注册任何快捷键但菜单栏的 CtrlR/CtrlShiftR 仍然有效——是的Electron 默认 Menu 包括 reload、devtools、zoom 等这些快捷键在打字过程中误触简直是灾难。解决办法有两个。一是在主进程里自定义菜单只保留退出项import { Menu } from electron Menu.setApplicationMenu(null)二是精准屏蔽快捷键事件// 主进程里拦截 app.on(web-contents-created, (_e, contents) { contents.on(before-input-event, (event, input) { if (input.type keyDown (input.control || input.meta) input.key R) { event.preventDefault() } }) })我实际用的是第一种把应用菜单清空只留退出项。这样 CtrlR、CtrlShiftI 全部失效对打字游戏来说反而更干净。代价是如果你想调试打包版本就没法用快捷键开 DevTools得在代码里留一个隐藏入口或者在开发环境才注册菜单。5.2 窗口失焦后计时器不暂停最开始实现的版本用户打到一半切去浏览网页回来发现 WPM 计时还在走准确率却因为回来自动输入的误操作一路暴跌。这绝对要修。核心逻辑窗口blur事件触发时如果游戏正在进行暂停计时器记录pausedAtfocus返回时把暂停时间从总计时中扣掉继续。这样用户离开再回来总用时不会变得离谱。// 渲染进程里监听窗口失焦通过 document 的 visibilitychange 也可以但 resize 窗口不算失焦 window.addEventListener(blur, () { if (typingEngine.isRunning) { typingEngine.pause() } }) window.addEventListener(focus, () { if (typingEngine.isPaused !typingEngine.isFinished) { typingEngine.resume() } })这里有一个我纠结过的点用户主动离开算不算停止计时如果是专注练习离开时间不应该算进练习时间如果是比赛计时离开就必须算。我的方案是设置里加一个离席暂停开关默认开启全力练习用户自己改成关闭即可。5.3 打包后随机崩溃或找不到文件最经典的坑fs.readFileSync在打包前开发环境好好的打包后报ENOENT。原因十有八九是路径用了相对路径或者直接拼接了__dirname。上面已经在词库读取处说了要基于process.resourcesPath这里再补充一个通用排查思路开发模式electron-vite dev下__dirname指向out/main文件在源目录能读到。生产模式asar下__dirname指向 asar 包内部如果文件没被打进 asar用join(__dirname, ../resources/xx)必然失败。排查工具在 main 进程代码里临时打日志把__dirname、process.resourcesPath、join(...)的结果都console.error出来然后跑打包版本看实际输出。路径问题基本十眼就能定位。另一个隐蔽问题是 asar 内文件的写入。mm某些情况下你直接在一个路径下写文件那个路径位于 asar 内部运行时会报EISDIR或EROFS。写入类操作保存统计、写日志必须写到app.getPath(userData)下它是操作系统真正的用户数据目录不是 asar 内部。5.4 DPI 缩放与窗口尺寸适配在高分屏比如 Windows 150% 缩放下Electron 默认的缩放行为和浏览器一致但如果窗口设置了minHeight: 400在 125% 缩放下实际渲染高度可能是400 * 1.25 500逻辑像素导致界面拥挤。我在样式里统一用 CSS 变量和clamp()函数做响应式处理:root { --titlebar-height: 36px; --min-pane-height: clamp(320px, 45vh, 640px); min-width: 100%; }测试反馈里最常遇到的问题是结果弹窗modal在小窗口里被截断。解决办法是给弹窗做max-height: 80vh; overflow-y: auto并在窗口 resize 时更新一层布尔值控制尺寸微调。这不算 Electron 独有但桌面 APP 的弹窗通常不会经常调整窗口大小容易漏测。5.5 数据持久化与多窗口并发写文件的冲突我在设置界面实现了实时保存用户调整词库、主题、音效立即写 settings.json。可如果同一秒内两个 IPC 请求并发触发写文件可能出现 writeFileSync 互相覆盖。Node 的writeFileSync在同一进程内是同步且串行的所以并发不是真正的并行写但我仍然遇到了一个现象用户连续切换设置时文件内容偶尔变成旧数据。原因在渲染进程Vue 的响应式更新是异步批量的我设置变更 → 立刻调用 saveSettings()时传进来的可能还是上一次的完整快照。规避方案是防抖 全量快照每次设置变更只触发一个 500ms 防抖的保存请求保存的永远是JSON.stringify(settings.value)的当前值。这样无论用户多快切设置最终落盘的永远是最新状态。6. 最终的经验体会这次架构改造给我留下了什么改完后把 VSCode 扩展和 Electron 应用并排跑了一遍发现当初只是换个壳的预想太天真了。表面上看渲染层确实无缝迁移但窗口生命周期管理、进程间通信、本地存储、系统级交互全屏、快捷键、拖拽、打包分发全都需要额外设计。这些恰恰是 Web 开发转桌面开发时最容易被低估的部分。有几个体会想重点分享给后来者。第一IPC 边界要尽早划清。不要等到页面写了几百行再回头补 preload 接口。我从第一天就把页面能碰什么这件事想明白了页面只通过window.api这唯一入口调用系统能力任何页面内直接用require(electron)的代码都是违规操作。坚持这个纪律后续排查安全问题会轻松很多。第二数据存储别等到最后再做。VSCode 的 globalState 给了你免费的数据持久化独立应用里就得自己扛。虽然 JSON 文件方案简单但最好在架构设计阶段就定下来放哪、怎么备份、怎么写。拖延到功能开发完再补存储逻辑很容易出现页面状态和本地文件不同步的硬伤。第三中文输入法支持优先级远比想象高。打字游戏的目标用户里中文用户占相当比例而中文输入与英文打字是完全不同的交互模型。如果产品定位是中英双语打字那么 IME composition 处理必须在第一版就做好否则发布后被抱怨中文模式下各种乱入字符几乎是板上钉钉的事。第四打包验证越早做越好。别等应用写完了才发现 Windows 上打包后字体不一致、macOS 下无边框窗口的圆角渲染有问题。我建议在写第一个完整功能时就跑一次 electron-builder确认 asar 和资源路径没问题再继续往下写。这个成本很低能避免后期大改路径逻辑。现在这个打字游戏已经作为一个独立桌面应用在跑了打包后 80MB 左右开机启动、全屏专注、自定义主题都能用。数据文件挪到本地后用户手动备份也方便直接把 data 目录拷走就行。后续的扩展方向我也想好了几个按键音效的自定义载入、云端词库同步、历史成绩的趋势图表。底层的 Electron Vue 3 架构还算稳固这些功能都是在渲染进程和 IPC 层增加模块不必再动主框架。如果你也正打算把某个 VSCode 扩展或者 Web 应用搬上桌面这篇东西应该能帮你少走不少弯路。