前阵子我在 VSCode 扩展里用 Webview 写过一个打字练习小工具,一开始觉得挺顺手,毕竟 Webview 本质就是一块 HTML 画布,Vue 3 那套响应式逻辑直接搬进去就能跑。但随着功能越加越多——词库管理、成绩曲线、窗口自适应、多主题切换——我发现自己被困在扩展的壳子里了。扩展的 Webview 面板生命周期受编辑器窗口控制,焦点管理、快捷键、弹窗交互全都要看 VSCode 脸色,更别提发布时要扯上 Extension Marketplace 的上架规则。于是我把这套打字游戏从扩展里抽出来,用 Electron 重新搭了一遍,底层逻辑、Vue 3 组件基本原样复用,但架构上从"寄生在编辑器里的小面板"改成了"独立主进程 + 渲染进程 + 预加载脚本"的标准桌面应用结构。
这篇文章就记录这次架构改造的全过程。从为什么必须从扩展迁到 Electron,到主进程、预加载脚本、渲染进程三层怎么拆分,再到打字游戏的核心逻辑(计时、按键校验、KPM/准确率统计)怎么实现,最后是我踩过的坑和排查思路。如果你也想把 VSCode 扩展里的某个 Webview 工具搬到独立桌面应用,或者正准备用 Electron + Vue 3 从零做一个工具类软件,这篇应该能让你少走不少弯路。
1. 为什么非要从 VSCode 扩展改成 Electron 独立应用
很多人第一反应是"能在扩展里跑得好好的,为什么非要折腾一个独立应用?"这不是想不开,而是当功能需求超过某个阈值之后,扩展开发模型的天花板会非常明显。
1.1 扩展 Webview 模式下的三大痛点
先说我最初那个打字工具在 VSCode 扩展 Webview 里遇到的问题。
第一,就是生命周期完全不受控。VSCode 扩展的 Webview 有两种持久化方式:retainContextWhenHidden设为 true 时,面板隐藏后 DOM 不销毁,但 Webview 内部的资源会被回收一部分;如果设为 false,面板一旦切走,整个页面状态可能直接没了。这对于打字游戏这种需要实时保存对局进度的场景来说极其致命——用户切出去查个资料,回来发现当前这一局已经断了。
第二,焦点和快捷键老打架。扩展里监听键盘事件,尤其是想拦截某些组合键时,VSCode 编辑器的命令系统会先截胡一部分按键。我在做打字游戏的"忽略大小写""跳过单词"这些快捷键时,就发现 Ctrl/Alt 组合键经常被编辑器快捷键优先占用,你只能在package.json里声明keybindings去和编辑器本身抢按键,这种体验很割裂。
第三,发布与更新流程太重。扩展想上架 VSCode Marketplace 需要走微软的发布流程,虽然不像 App Store 那么严,但版本更新、README、LICENSE、图标这些都要合规检查。对于内部工具或者个人项目来说,只是想快速给个 exe/dmg 给朋友用,上架 Marketplace 纯粹是负担。
1.2 Electron 解决这些问题的核心思路
Electron 把应用拆成了两层结构:Node.js 环境的主进程负责窗口管理、文件读写、系统能力,Chromium 渲染进程负责页面渲染。打字游戏这类纯前端交互密集的应用,正好可以把全部 UI 逻辑放在渲染进程,需要系统能力时再通过 IPC 请求主进程。
相比扩展 Webview,Electron 给了你完整的窗口生命周期控制——窗口关了才销毁,最小化、失焦都不会导致页面状态重置。键盘事件监听在渲染进程里就是想怎么拦就怎么拦,前提是你别在菜单里注册同样的全局快捷键。发布就是electron-builder一条命令打包成 exe、dmg、deb,想发给谁就发给谁。
从扩展迁到 Electron 不是重写,而是"架构降维"——把原来为了讨好宿主环境写的兼容层全都脱掉,回归到一个普通 Web 项目的开发体验。
1.3 什么场景适合"从扩展到独立应用"改造
不是所有扩展都值得迁出来。我建议你对照这三条判断:
- 工具的 UI 复杂度接近"应用级",不再是几个简单面板,比如需要多 tab、多视图、复杂交互。
- 数据需要在应用之外长期保存,且保存格式和数据结构比较独立。扩展的
globalState本质是 KV 存储,复杂查询和结构化数据很不舒服。 - 用户不一定是 VSCode 用户,或者目标用户根本不会装编辑器。
反之,如果你的工具是强绑定编辑器上下文的——比如读取当前打开文件的选中内容、联动调试器状态、在编辑器里插入代码片段——那留在扩展里才是正确选择。打字游戏恰好属于"和编辑器无关的独立工具",所以迁出来非常值。
2. Electron + Vue 3 工程架构设计与三层拆分
改造前先把 VSCode 扩展里那个 Webview 项目的构建方式理了一遍。之前扩展项目里 Webview 用的是独立 HTML 入口,资源通过getUri处理,Vue 3 是被我当纯前端框架用的。这次迁徙,底层组件逻辑大部分能平移,但工程骨架要重新搭。
2.1 主进程 / 预加载 / 渲染进程三层架构
Electron 应用刚入门最容易犯的错误,就是把主进程当 Node 后端疯狂往里塞业务逻辑,或者干脆全写在渲染进程里。我的拆分原则很简单:
- 主进程:只管窗口创建、系统菜单、对话框、文件读写、应用生命周期。不引入任何 Vue 相关代码。
- 预加载脚本:这是主进程和渲染进程之间唯一的桥。通过
contextBridge暴露白名单 API,禁用nodeIntegration。 - 渲染进程:纯浏览器环境,跑 Vue 3 + Vite。不能直接 require,不能触碰 Node 能力,只能调用预加载暴露的 API。
打字游戏里哪些归主进程?词库文件的导入导出、成绩数据的持久化路径管理。哪些归渲染进程?计时逻辑、按键监听、实时渲染、动画、当前对局的全部状态。IPC 通道我控制在极少的几个:词库读取、成绩保存、成绩读取、窗口控制,其他全部在渲染进程内部消化。
// electron/main.js —— 主进程骨架 const { app, BrowserWindow, ipcMain, dialog } = require('electron') const path = require('path') function createWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, }, }) // vite dev server 地址 if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(process.env.VITE_DEV_SERVER_URL) } else { win.loadFile(path.join(__dirname, '../dist/index.html')) } } app.whenReady().then(() => { createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit() })这一段是主进程的最小骨架。注意contextIsolation: true是安全底线,你在网上看到老教程里写nodeIntegration: true, contextIsolation: false的,基本都是直接照抄文档但没理解安全模型,在打字游戏里虽然不至于出事,但一旦将来打开外部链接、加载第三方字体或词库资源,风险就大了。
2.2 Vite + electron 插件配置
脚手架方面,我直接用vite-plugin-electron配合 Vue 3 的官方模板完成。相比老的electron-forge或者手动配 webpack,Vite 的开发体验实在好太多——启动快、热更新稳,而且vite-plugin-electron会在 dev 模式下同时拉起 Electron,改主进程代码自动重启。
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import electron from 'vite-plugin-electron' export default defineConfig({ plugins: [ vue(), electron([ { entry: 'electron/main.js', vite: { build: { outDir: 'dist-electron', }, }, }, { entry: 'electron/preload.js', vite: { build: { outDir: 'dist-electron', }, }, }, ]), ], build: { outDir: 'dist', }, })构建后主进程产物在dist-electron/main.js,渲染进程产物在dist/index.html,打包时两个目录一起打进去就行。这里有个小坑:preload.js如果设置了build.rollupOptions.output.format,Electron 对 preload 脚本的格式要求很怪,建议直接保持默认的cjs,不要为了追求"现代"改成 ESM。我在改造中间试过把 preload 写成 ES Module,结果 Electron 版本在加载 preload 时直接报错,排查半天才发现是格式问题。
2.3 预加载脚本与安全导出方式
预加载脚本是架构里最容易"写着写着就失控"的一层。很多人图省事,把 Node 的fs整个暴露出去,或者干脆把ipcRenderer直接暴露给渲染进程,这等于给渲染进程开了一扇门,任何 XSS 漏洞都可能变成任意文件读写。
我这边遵循"最小暴露"原则,把 IPC 封装成语义化接口:
// electron/preload.js const { contextBridge, ipcRenderer } = require('electron') contextBridge.exposeInMainWorld('typingGameAPI', { // 读取词库 loadWordbook: () => ipcRenderer.invoke('wordbook:load'), // 保存成绩 saveResult: (data) => ipcRenderer.invoke('result:save', data), // 加载历史成绩 loadResults: () => ipcRenderer.invoke('result:load'), // 导入词库文件(触发对话框) importWordbook: () => ipcRenderer.invoke('wordbook:import'), })渲染进程里用window.typingGameAPI调用,Vue 组件里直接注入使用。这样哪怕将来渲染进程被注入恶意脚本,能做的也只是调用这几个白名单接口,而不是拿到 Node 的全部能力。
2.4 打字游戏数据流的架构设计
打字游戏不是一个纯前端玩具,它要记录每天的练习成绩。成绩数据需要持久化。在扩展时代我用的是context.globalState,迁到 Electron 后我选择把成绩文件写到用户数据目录下,以 JSON 文件形式存储。这个选择的原因:单机单用户场景下 SQLite 太重,纯 JSON 文件读写简单、可读性好,后续如果要做数据可视化,甚至可以直接让用户打开文件看原始数据。
数据流向是这样:
- 用户完成一局打字测试。
- 渲染进程计算出 KPM(每分钟击键数)、准确率、用时、错词列表。
- 渲染进程通过
window.typingGameAPI.saveResult()发出 IPC。 - 主进程接收数据,写入
app.getPath('userData')/results.json。 - 下次打开应用,渲染进程启动时调用
loadResults()拉回全部历史成绩。
这里有个关键点:渲染进程永远不直接碰文件路径。文件路径由主进程内部计算,渲染进程只管数据长什么样。这个约束保证将来如果把数据改成 SQLite、换成云同步,渲染进程的代码一行都不用动。
3. 打字游戏核心逻辑与实操实现
架构定好了,接下来就是把游戏本身的核心逻辑串起来。打字游戏看着简单,但真正做起来有一堆细节:计时起点怎么定?按键校验哪些情况算错?KPM 到底怎么算才合理?下面逐个拆。
3.1 词库内容与题目生成策略
词库决定游戏可玩性。我从扩展时代保留了两套词库:英文高频词库和中文短语词库。英文按难度分级:基础 200 词、进阶 800 词、挑战 2000 词。中文短语则是从常用表达里人工筛出来的短句,每个短句不超过 15 个字符。
题目生成的核心是"随机但不重复"。最简单的方式是维护一个待出题索引数组,随机取一个后从数组里移除,一轮出完再重新洗牌。这个逻辑放到 Vue 3 的 composable 里很自然:
// src/composables/useWordbook.js import { ref, computed } from 'vue' export function useWordbook() { const pool = ref([]) // 当前词库全文 const queue = ref([]) // 剩余待出题序列 function loadWords(words) { pool.value = [...words] queue.value = shuffle([...words]) // 洗牌 } function nextWord() { if (queue.value.length === 0) { if (pool.value.length === 0) return '' queue.value = shuffle([...pool.value]) // 一轮结束,重新洗牌 } return queue.value.pop() } function shuffle(arr) { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)) ;[arr[i], arr[j]] = [arr[j], arr[i]] } return arr } return { pool, queue, loadWords, nextWord, } }这里用pop()从数组尾部取词,避免频繁shift()导致的数组重排性能问题。词库数量小时无所谓,但中文短语库撑到几百条之后每帧 shift 会感觉到卡顿,这是 V8 引擎数组操作的优化细节。
3.2 键盘监听与计时逻辑
打字游戏的核心是键盘事件。但直接在全局window上挂keydown监听有个隐患:输入法导致的拼音合成过程会同时触发keydown和keyup,而且key值可能是Process。玩中文词库时这是个必踩的坑——用户按下字母键时,输入法弹拼音,你这边已经记录了一次按键,结果用户还没选字呢,游戏早就判定错了。
我的方案:监听keydown,但过滤掉event.isComposing为 true 的情况,同时排查key === 'Process'的异常值。另外,对于英文词库,强制用户关闭输入法不现实,所以在判定时用event.code(物理按键位置)而不是event.key(字符值),这样中文输入法下按 Q 依然是 Q 的物理位置,不会因为输入法状态输出别的字符。
计时起点怎么定?最自然的设计是"显示题目后,用户开始输入第一个有效字符时才开始计时"。这里涉及一个细节:如果第一次按键就错了,算不算开始?我的结论是算,因为用户已经进入输入行为,测试开始了。这个设计能避免一个 bug:用户盯着题发呆,计时器空转,导致 KPM 虚低。
// src/composables/useTypingGame.js 中核心状态 const state = reactive({ currentWord: '', typed: '', startedAt: null, finishedAt: null, totalKeystrokes: 0, errors: 0, }) function onKeydown(e) { if (e.isComposing || e.key === 'Process') return if (!state.startedAt && e.key.length === 1) { state.startedAt = Date.now() // 第一个可打印字符触发计时 } if (e.key === 'Backspace') { state.typed = state.typed.slice(0, -1) return } if (e.key.length === 1) { state.totalKeystrokes++ state.typed += e.key validateCurrentChar() } }validateCurrentChar里判断当前位置的字符是否匹配目标词,不匹配就累加错误计数。注意这里不是等到整个词输完才判断对错,而是逐字校验,用户能实时看到哪个字符打错了。
3.3 KPM / 准确率的计算方式
打字游戏最常见的指标是 WPM(每分钟单词数)和 KPM(每分钟击键数)。中文词库用 WPM 不太准确——"你好"两个字算几个单词?所以统一用 KPM 更科学。但 KPM 要么只算有效字符,要么算总击键。我的做法是两者都展示:
- 总击键 KPM:
totalKeystrokes / minutes,反映手速。 - 有效完成 KPM:
完成字符数 / minutes,反映正确输入速度。
准确率就简单了:有效字符 / (有效字符 + 错误字符) * 100%。这里有个值得注意的设计决策:计算错误时,我统计的是"错误按键次数"而不是"最终字符串与目标不匹配的字符数"。比如目标词是 "hello",用户打成 "hallo",按最终结果算只有 e 和 a 两个位置错了,但用户实际流程里可能是先打了正确的 e,hand 按掉又回退重打,这两个定义的数据不同。对练习工具来说,"错误按键次数"更能反映真实输入习惯。
计时结束条件是:当前词输入完毕且完全正确,进入下一个词前,或者用户主动结束。这里要处理一个边缘情况:用户打最后一个字符后,这个字符算在总击键里吗?算。按停表逻辑,最后一个字符敲下去的瞬间就是结束时间点,这次按键应该被计入。
3.4 系统菜单和快捷键设置的踩坑记录
独立应用最大的便利之一是可以自定义系统菜单。我从扩展时代憋了一肚子"想加但编辑器不让加"的功能,迁移后第一件事就是把快捷键理顺了。
Electron 的菜单配置在Menu.buildFromTemplate里,核心快捷键我设置了这些:
CmdOrCtrl+R重新开始当前一局。CmdOrCtrl+Shift+R切换词库难度。CmdOrCtrl+S手动保存当前对局进度。
这里踩了两个坑。第一个是CmdOrCtrl在 Windows/Linux 上对应 Ctrl,macOS 上对应 Command,Electron 会自动映射,但如果你不写这个修饰键而是直接写Ctrl,macOS 上就失效了。第二个坑是菜单快捷键和渲染进程keydown事件做重复处理——比如菜单里设置了CmdOrCtrl+R,渲染进程的keydown也会同时收到r键事件,如果没有event.preventDefault(),会触发两次"重新开始"。我的处理方式是主进程通过 IPC 发送菜单命令给渲染进程,渲染进程监听统一命令源,而不是自己监听快捷键:
// 主进程菜单点击 { label: '重新开始', accelerator: 'CmdOrCtrl+R', click: () => { win.webContents.send('game:restart') } }// 渲染进程监听 window.typingGameAPI.onRestart(() => { // 仅在收到主进程命令时重置游戏 resetGame() })这样快捷键的唯一入口在主进程,渲染进程不会双触发。
3.5 打字界面的 Vue 3 组件交互实现
迁移到 Electron 后,Vue 3 组件几乎没改就复用了。打字游戏的核心 UI 是"当前词展示区 + 输入区 + 统计区 + 成绩曲线"。我的组件划分是:
TypingArea.vue:渲染目标词,逐字高亮正确/错误状态。ResultPanel.vue:展示 KPM、准确率、用时。HistoryChart.vue:展示历史 KPM 趋势。SettingsDrawer.vue:词库选择、难度层级、主题设置。
组件通信靠 Pinia。状态管理就一个 store,保存词库、当前题目、游戏状态、成绩列表。Select 和 Radio 这些交互组件直接用 Element Plus。
从扩展时代到 Electron,UI 代码迁移时真正要改的点只有两个。一是深色模式适配不再依赖 VSCode 的主题变量,而是自己读取系统prefers-color-scheme,同时提供应用内主题切换;二是弹窗和提示不再是 VSCode 的vscode.postMessage那套,直接用自己的自定义模态框。其余组件逻辑基本一字未改,这是我这次迁移最大的欣慰——当初写组件时没让它们依赖扩展 API,这个习惯确实救了命。
4. 从 Webview 代码到 Electron 应用的迁移与调试
很多人觉得迁移就是"把 HTML 文件换个壳子加载",真动手会发现有一堆细枝末节的问题。我梳理了迁移过程中最容易出问题的地方。
4.1 Webview 代码迁移时的差异化调整
VSCode Webview 里,资源文件的路径获取是用webview.asWebviewUri动态生成的,加载本地图片、字体都要走这条特殊 URL。到了 Electron 里就退化成了普通浏览器规则——loadFile加载时相对路径会自动处理,但如果你用了 Vite 的base配置,生产环境下资源路径可能挂。
我踩过的一个坑:Vite 默认base: '/',在浏览器里没问题,但 ElectronloadFile加载dist/index.html时,/assets/xxx.js会被解析为文件系统根目录/assets/xxx.js,直接 404 白屏。解决办法是base: './',让 Vite 生成相对路径。
还有一个差异是消息通信。扩展 Webview 里用的vscode.postMessage和onDidReceiveMessage,Electron 里对应的是ipcRenderer.invoke和ipcMain.handle。前者语法是 fire-and-forget 的风格,后者天然支持返回值。迁移时可以写一个极薄的数据访问层,把原来的postMessage('loadWordbook')改成invoke('wordbook:load'),渲染业务代码不用动。
4.2 主进程日志与渲染进程调试
调试是迁移后最大的一块体验提升。扩展 Webview 里调试受限于 VSCode 的调试宿主,想打个断点看渲染进程状态要配 launch.json 扩展开发模式。Electron 里直接爽多了:渲染进程自带了熟悉的 Chrome DevTools,主进程可以在终端打console.log或者用--inspect接 Node 调试器。
我的实际调试经验:
- 渲染进程问题:直接右键窗口选择"检查",跟调普通网页完全一样。
- 主进程问题:
electron . --enable-logging启动后,主进程的 console 输出会直接打到终端。 - IPC 数据问题:给预加载脚本里每一个
invoke包一层日志中间件,打印调用名和参数。
// preload.js 中简单的 IPC 日志封装 function invokeWithLog(channel, data) { console.log(`[IPC] invoke ${channel}`, data) return ipcRenderer.invoke(channel, data) }这套日志在排查询成绩不显示、词库加载失败这类问题时非常管用。问题出现时先看 IPC 层有没有收到调用、返回结果是什么形态,能过滤掉一大半"数据没传过去"的玄学 bug。
4.3 electron-builder 打包配置与体积控制
打包是 Electron 应用的收尾环节。我用的是electron-builder,配置不复杂,但有几个点值得注意。
第一,图标和资源路径。打包后应用的工作目录和开发时不同,读文件要用app.getAppPath()或process.resourcesPath,千万别用相对路径。
第二,体积控制。Electron 应用普遍体积大,是因为自带了一个完整的 Chromium。能优化的就是去除不需要的平台二进制——只打 Windows 包时设置win,只打 mac 包时设置mac,不要图省事全平台一起打。另外,electron-builder默认包含整个node_modules里生产依赖,写在主进程里用到的原生模块必须放在dependencies而不是devDependencies,否则打包后缺模块。
第三,自动更新。electron-updater配起来不难,但需要文件服务器提供 latest.yml 和安装包。我自己的项目是扔到对象存储上,客户端autoUpdater.checkForUpdatesAndNotify()就完事了。
5. 常见问题与避坑实录
迁移和开发过程中我记录了一堆问题,挑出现频率最高的整理成速查表。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
白屏,控制台报Not allowed to load local resource | Vite base 路径用了绝对路径 | Vite 配置base: './' |
preload 脚本报contextBridge is not defined | preload 被构建成了 ES Module | preload 保持 CJS 格式构建 |
| 键盘输入中文时乱码 | 输入法合成事件干扰 keydown | 过滤isComposing和Process键 |
| 快捷键按一下触发两次 | 菜单和渲染进程同时监听 | 统一通过 IPC 分发,单一入口 |
| 打包后应用找不到词库文件 | 开发和生产资源路径不同 | 用process.resourcesPath定位资源文件 |
IPC 数据返回undefined | ipcMain.handle注册在app.whenReady()之前 | 确保 handle 注册在 ready 之后执行 |
| 高 DPI 显示器上字体模糊 | 渲染进程 DPI 缩放未适配 | 主进程开启deviceScaleFactor处理或使用 CSS 响应式单位 |
| 菜单点击无响应 | 菜单 click 回调执行时机和上下文错误 | 确保 menu 创建时获取到目标 window 引用 |
这里挑两个最常见的展开说。
5.1 IPC 返回值的"幽灵 undefined"问题
你配置了ipcMain.handle('result:save', ...),渲染进程invoke调用也正常,但await拿到的结果是undefined。排查之后发现:ipcMain.handle注册代码放在了app.whenReady()之前。Electron 的handle实际上是在 ready 之后才生效的,你在错误时机注册,调用方却已经在等结果了。
正确的顺序:
app.whenReady().then(() => { ipcMain.handle('result:save', ...) createWindow() })也可以在createWindow()之前统一注册所有 handler,关键是必须在 ready 之后。
5.2 中文输入法导致的键盘错乱
这个问题我不止一次遇到。用户在中文输入状态下打字,输入的字母先进入输入法候选框,keydown事件会触发,但按下去的键可能是 "Process" 或者 "CompositionUpdate"。如果不加过滤,游戏会记一堆错误按键。
我的过滤方案分三层:
- 事件层:
if (e.isComposing || e.key === 'Process') return - 校验层:对单词校验时,只比较字母数字和基础符号,忽略组合键修饰。
- 提示层:检测到用户处于中文输入法状态时,提示建议切换英文输入模式。
第三种不是万能方案,因为某些输入法无状态可查,只能靠事件过滤。实测下来前两层已经能拦截 99% 的误判。
5.3 Electron 壳子内页面打开链接的处理
我在项目里加了"关于"页,里面有 GitHub 链接,结果默认行为是直接在应用窗口里导航过去,差点把自己应用跑飞。Electron 里window.open和链接跳转默认都在当前窗口加载,你得用shell.openExternal把链接交给系统浏览器。
最稳妥的方式是拦截所有新窗口和导航请求:
// 主进程 win.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url) return { action: 'deny' } }) win.webContents.on('will-navigate', (event, url) => { const currentUrl = win.webContents.getURL() if (!url.startsWith(currentUrl)) { event.preventDefault() shell.openExternal(url) } })这样就算渲染进程里混进了外链,也只会调起系统浏览器,不会嵌进应用窗口。做工具类应用的人容易忽略这个安全细节,但它既是体验优化也是安全加固。
6. 后续还能往哪个方向扩展
这套架构落地之后,打字游戏只是开胃菜,真正有价值的是整个 Electron 应用骨架已经能复用了。我个人后续计划做的几个方向,给你参考。
词库动态化:现在词库是内置 JSON,下一步让用户在设置面板里导入自己的词库文件,或者通过网络接口拉取每日热词。主进程里dialog.showOpenDialog已经暴露在 preload 里,导入只是数据解析的问题。
成绩数据可视化:现在成绩曲线是简易折线图,后续想接echarts做更复杂的趋势分析——按周、按月、按词库类型分组统计。数据源是results.json,渲染进程直接读然后交给图表库渲染,不用改任何 IPC。
多窗口支持:如果想加"打字对战"功能,两个用户并排各自一个窗口,底层可以开两个BrowserWindow,共享一份词库数据。主进程负责同步双方进度,渲染进程内部逻辑保持不变。
如果你也想做类似改造,我最大的建议是:先写一个"最小可运行的骨架"——主进程创建窗口、preload 暴露一个接口、渲染进程加载 Vue 3、electron-builder 打出第一个包——走通这条链路之后再开始写业务逻辑。不要一上来就堆 Electron 功能和 Vue 组件,因为骨架阶段的坑最多,而且都是连锁反应:Vite 路径不对白屏、preload 构建格式不对报错、打包后资源缺失……这些问题如果在业务代码已经几千行的时候才暴露,排查起来会非常痛苦。先拿最小骨架把这些风险全部扫掉,再往里面填打字游戏的逻辑,你就会发现后面的开发其实跟写普通 Vue 项目没什么区别。