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 内容;中间用 IPC(Inter-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/on
Electron 的 IPC 有两种常见模式。ipcRenderer.send+ipcMain.on是单向/事件通知,适合"发消息不关心返回值"的场景;ipcRenderer.invoke+ipcMain.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、准确率。这次改造我加了一个"连续正确数"指标,用来衡量节奏稳定性,同时对结果结算的"星级评价"提供依据。
WPM(Words Per Minute)计算方式:(正确输入字符数 / 5) / 用时(分钟)。行业惯例中一个英文单词按 5 个字符折算,中文字符按一个字一个字符算,然后 WPM 数值独立显示中文模式下为 CPM(Characters 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/electron@latest 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(): Promise<StatsData> saveStats(data: StatsData): Promise<{ success: boolean }> getWordlist(lang: 'en' | 'zh'): Promise<WordItem[]> minimize(): Promise<void> maximize(): Promise<void> close(): Promise<void> getAppVersion(): Promise<string> } }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 readJsonFile<T>(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 writeJsonFile<T>(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.yml:
appId: 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带了一堆全局快捷键。你没主动注册任何快捷键,但菜单栏的 Ctrl+R/Ctrl+Shift+R 仍然有效——是的,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() } }) })我实际用的是第一种:把应用菜单清空,只留"退出"项。这样 Ctrl+R、Ctrl+Shift+I 全部失效,对打字游戏来说反而更干净。代价是如果你想调试打包版本,就没法用快捷键开 DevTools,得在代码里留一个隐藏入口或者在开发环境才注册菜单。
5.2 窗口失焦后计时器不暂停
最开始实现的版本,用户打到一半切去浏览网页,回来发现 WPM 计时还在走,准确率却因为回来自动输入的误操作一路暴跌。这绝对要修。
核心逻辑:窗口blur事件触发时,如果游戏正在进行,暂停计时器,记录pausedAt;focus返回时,把暂停时间从总计时中扣掉,继续。这样用户离开再回来,总用时不会变得离谱。
// 渲染进程里监听窗口失焦(通过 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 应用搬上桌面,这篇东西应该能帮你少走不少弯路。