尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从VSCode扩展到Electron+Vue3:打字应用架构改造实战

从VSCode扩展到Electron+Vue3:打字应用架构改造实战 如果你曾经在 VSCode 里做过一个打字练习插件后来又被迫把它搬出来做成独立应用那这个标题背后的痛点你应该能直接共鸣。我一开始就是在 VSCode 的扩展体系里写了个打字小游戏当玩具自嗨结果身边同事一用就停不下来紧接着就有人问能不能不开编辑器直接启动一个应用来练这时候我才意识到扩展做得再好用户要的其实是一个“能直接双击运行的打字工具”。于是就有了这次从 VSCode 扩展到 Electron Vue 3 独立应用的架构改造。这篇文章我把从 WebView 面板逻辑迁到 Electron 主进程、再把整个前端用 Vue 3 重写的完整过程拆给你看包括技术选型取舍、窗口与菜单封装、打字引擎的数据结构设计、中文输入法冲突怎么处理以及最后打包分发时踩过的真实坑。整篇文章适合两类人一类是写 VSCode 扩展想拓宽应用场景的开发者另一类是刚上手 Electron Vue 3、想做个小而美的桌面工具但不知道从哪下手的同学。无论你是想照抄思路还是想避开我踩过的坑这篇文章都应该能帮到你。主进程、渲染进程、预加载脚本这些概念在实际项目里到底怎么配合打字游戏这种高频键盘输入场景在 Electron 里和网页里有什么区别VSCode 扩展能轻松实现的功能搬出来之后为什么反而复杂了这些问题我会结合这次的改造经历逐一说清楚。1. 项目缘起与架构决策为什么把打字扩展搬出 VSCode1.1 VSCode 扩展暴露的三道坎这不是一个“为了技术而技术”的项目是真的被逼出来的。VSCode 扩展看起来写起来都挺爽有完整的 API 文档、有 Webview 可以自定义 UI、有 workspaceState 可以存数据但真要把一个工具型应用长期跑在里面会很别扭。第一道坎是启动成本。VSCode 连编辑器都还没开就想练个打字你得先启动整个 VSCode、打开一个工作区、再通过命令面板唤起扩展面板这套流程对一个只想“练五分钟”的场景来说太重了。第二道坎是 UI 自由度。VSCode 的 Webview 面板虽然能塞 HTML/CSS/JS但它毕竟寄生在编辑器内皮肤的切换、字号缩放的连带影响、以及各种默认样式覆盖问题会一直缠着你。第三道坎最致命扩展没法独立分发。你想给朋友用总不能让人家先装个 VSCode 再把你的扩展装进去吧我在实际使用中还发现VSCode 扩展的 Webview 资源加载策略对本地化工程不太友好。扩展打包成 vsix 后Webview 指向的本地资源路径会经过一层特殊映射特别是当你用了 Vite 这类构建工具输出带 hash 的资源文件时路径处理逻辑会比较绕。我一开始在 VSCode 扩展里用 Vite 构建 Webview 前端每次开发都要处理烦琐的路径重写代码能跑通但构建链路负责这种摩擦随着功能增多被不断放大。1.2 独立应用要解决的核心诉求去做独立应用之前我梳理了四个核心诉求这也是后来架构设计的主线。第一分发要简单。用户拿到的应该是一个能安装、能双击运行的桌面程序而不是依赖某个宿主环境的东西。第二输入响应要快。打字游戏的体验上限由输入延迟决定——键盘按下去到屏幕出现反馈的延迟如果超过 50ms玩家立刻就能感知到卡顿这也是为什么后来我把输入处理逻辑全部收敛到渲染进程的独立模块里避免任何不必要的 IPC 往返。第三状态要能持久化。用户练了多少字、WPM 曲线是怎样的、常用错键是哪些这些数据应该存在本地文件里而不是内存里关掉应用再打开仍然还在。第四窗口行为要像一个真正的桌面应用。支持固定置顶、支持托盘最小化、支持全局快捷键唤起这些是“扩展”实现不了、“应用”必须具备的体验。这些诉求听着不多但每一条都直接影响技术选型。Electron 能同时满足这四点这也是它成为最终选择的核心原因。1.3 架构改造的整体思路从扩展到 App改造不是推倒重来我是保留核心逻辑、替换宿主环境的思路来做的。VSCode 扩展阶段的核心资产有两个一个是打字匹配算法另一个是题库数据。这两个都是纯 TypeScript 模块不依赖 VSCode API可以直接搬到 Electron 项目里复用。整体架构分成两层。上层是 Electron 提供桌面能力窗口管理、应用生命周期、全局快捷键、系统托盘、本地文件存储下层是 Vue 3 应用负责 UI 和交互打字面板、结果统计、设置页、历史记录。中间通过 preload 脚本暴露的桥接 API 通信。VSCode Webview 里的打字组件 ↓ 抽出 TypeScript 打字引擎核心纯逻辑无宿主依赖 ↓ 接入 Electron 主进程窗口/存储/快捷键 ↓ 桥接 preload 脚本 contextBridge API ↓ 渲染 Vue 3 组件树打字面板/统计图表/设置页这套结构的好处是职责非常清晰核心逻辑层不感知 Electron 存在渲染层不直接接触 Node.js API所有跨进程调用都走统一的桥接接口。后续如果想出 Web 版只需要把桥接层换成 HTTP 接口即可如果想把打字引擎单独商业化也可以直接包一个 npm 包出来。2. 技术栈选型与工程初始化Electron Vue 3 的组合拳2.1 选型对比为什么是 Electron 而不是 Tauri做独立桌面应用绕不开的一个问题就是用 Electron 还是 Tauri我当时专门做了对比结论是 Electron 更适合这个项目。Tauri 的优势是包体积小、内存占用低因为它的前端资源由系统 WebView 加载后端是 Rust。但打字游戏这个场景有个特殊性需要大量监听键盘事件并实时渲染反馈不同平台的 WebViewWindows 上是 WebView2、macOS 上是 WKWebView在键盘事件行为上有差异跨平台一致性很难保证。Electron 内置的是 Chromium一套渲染引擎通吃所有平台行为完全一致这对做输入类应用很重要。另外还有一点实战上的考虑Electron 的进程模型和 Node.js 集成比较成熟主进程可以很方便地操作文件系统、注册全局快捷键、管理系统托盘Tauri 虽然有 Rust 后端但要把大量逻辑写进 Rust 里开发效率和生态成熟度短期都不如 Node.js 顺手。这个项目最开始的代码就是 TypeScript 写的迁到 Electron 等于零语言转换成本迁到 Tauri 还要 Rust 实现一遍文件存储和全局快捷键逻辑投资回报率不如 Electron。对比项ElectronTauri渲染引擎内置 Chromium跨平台一致系统 WebView存在平台差异后端语言Node.js / TypeScriptRust输入事件一致性高Chromium 统一中受 WebView 影响包体积大约 60MB小约 5MB键盘监听能力成熟全局快捷键 渲染进程相对复杂迁移成本低TypeScript 逻辑直接复用高需 Rust 重写系统能力当然Electron 也不是没有代价。安装包大、内存占用高这是它一直被诟病的点。但对打字游戏这种工具型应用来说用户更在意的是“开起来就能用”和“输入不卡”包体积大一点反而不是核心矛盾。如果你的应用是分发到低配置用户场景里Tauri 值得试否则 Electron 的稳定性和生态真的要省心太多。2.2 工程搭建Vite Vue 3 Electron 的整合步骤我选用了 electron-vite 这个构建工具来搭工程骨架。electron-vite 跟 Vite 生态兼容得比较好一套配置同时处理主进程、preload 脚本和渲染进程开发模式还自带热更新非常顺滑。# 用模板创建项目 npm create quick-start/electronlatest typing-trainer -- --template vue-ts cd typing-trainer npm install npm run dev这个模板会生成如下目录结构先看懂它再动手改typing-trainer/ ├── src/ │ ├── main/ # 主进程 │ │ └── index.ts │ ├── preload/ # 预加载脚本 │ │ └── index.ts │ └── renderer/ # 渲染进程(Vue 3) │ ├── index.html │ └── src/ │ ├── main.ts │ ├── App.vue │ └── components/ ├── electron.vite.config.ts # electron-vite 配置 ├── electron-builder.yml # 打包配置 └── package.jsonelectron-vite 有个重要特性主进程和 preload 脚本也用 Vite 构建。这意味着你在主进程里可以放心用 ESM 语法、TypeScript、路径别名构建时会自动打包为对应格式。这个体验比我之前手动用 tsc 编译主进程、再用 vite 构建渲染进程的旧方案舒服太多。不过有个注意点electron-vite 默认把 renderer 的构建目标定为浏览器环境如果你需要在渲染进程里用 Node.js 的 fs 模块必须在 preload 里做桥接而不是直接在渲染进程引入 Node.js 模块。我在这个项目里的做法是文件存储全部放主进程preload 暴露读写接口渲染进程完全不碰 Node API这样安全性更好也符合 Electron 官方推荐的架构。2.3 进程模型与通信协议设计Electron 的三进程模型主进程、渲染进程、preload是最容易让新手懵的地方。我当时也花了不少时间才理顺它们之间的关系。简单说主进程是应用的大总管管窗口、管系统集成渲染进程是每个窗口里跑的网页负责 UIpreload 是主进程和渲染进程之间的“翻译官”通过 contextBridge 暴露安全接口。我的通信协议设计是围绕四个能力域组织的窗口控制、数据存储、全局快捷键、系统集成。每个能力域都定义一组统一的 IPC 方法名和参数结构。// preload/index.ts import { contextBridge, ipcRenderer } from electron const api { // 窗口控制 window: { minimize: () ipcRenderer.send(window:minimize), togglePin: () ipcRenderer.invoke(window:toggle-pin), close: () ipcRenderer.send(window:close) }, // 数据存储 storage: { get: (key: string) ipcRenderer.invoke(storage:get, key), set: (key: string, value: unknown) ipcRenderer.invoke(storage:set, key, value) }, // 全局快捷键 shortcut: { register: (accelerator: string) ipcRenderer.invoke(shortcut:register, accelerator), onTriggered: (callback: () void) { const listener () callback() ipcRenderer.on(shortcut:triggered, listener) return () ipcRenderer.removeListener(shortcut:triggered, listener) } }, // 系统托盘 tray: { onDoubleClick: (callback: () void) { const listener () callback() ipcRenderer.on(tray:double-click, listener) return () ipcRenderer.removeListener(tray:double-click, listener) } } } contextBridge.exposeInMainWorld(desktop, api)然后在 Vue 3 组件里我会声明一个全局类型来获得智能提示// src/renderer/src/env.d.ts import type { DesktopAPI } from ../../preload declare global { interface Window { desktop: DesktopAPI } }渲染进程里这样用const storage await window.desktop.storage.get(settings)IPC 设计上有一个小教训能用ipcRenderer.invoke就用调用它能拿到返回值适合“请求-响应”模式ipcRenderer.send适合单向通知比如最小化窗口不需要返回值。如果混用代码容易变得混乱后期排查问题也没头绪。我把这个规则定死所有从渲染进程发起并需要读取结果的调用一律用 invoke只有明确的“通知类”操作才用 send。3. 打字游戏核心功能实现从原型到可玩3.1 打字引擎的数据结构与匹配算法打字引擎是这个项目的心脏。它在 VSCode 扩展时代验证过逻辑但这次改造里我对数据结构做了大幅优化原因后面会讲。先看最初版的核心数据结构interface TypingSession { targetText: string // 目标文本 userInput: string // 用户已输入内容 startTime: number // 开始时间 errors: number[] // 出错的字符索引 currentIndex: number // 当前输入位置 }这个结构简单直观但性能和功能上都有隐患。用户输入一长串文本后要把 targetText 和 userInput 做逐字符 Diff每次按键都要遍历整个文本文章越长越慢。而且这个结构没有按“单词”维度存统计信息用户打错了哪个单词、哪个键位错误率最高都没法算。这次改造我换成了“单词流”结构interface Word { text: string typed: string status: pending | typing | correct | incorrect errorPositions: Setnumber } interface TypingSession { words: Word[] // 按空格切分的单词列表 currentWordIndex: number // 当前正在输入的单词索引 startTime: number // 实时统计 totalKeystrokes: number totalCorrectKeystrokes: number totalErrors: number history: WordHistoryItem[] // 用于生成 WPM 曲线 }这样做的核心收益是每次按键只需要判断当前单词内的字符匹配判断范围为单个单词长度平均 5 到 10 个字符不用再遍历整个文档。对于打字游戏这种高频输入场景这个优化非常关键。匹配算法的核心逻辑如下function matchWord(word: Word, typed: string): Word { const errorPositions new Setnumber() const length Math.max(word.text.length, typed.length) for (let i 0; i length; i) { if (word.text[i] ! typed[i]) { errorPositions.add(i) } } return { ...word, typed, errorPositions, status: typed word.text ? (typed ? pending : correct) : (typed.length 0 word.text.startsWith(typed) ? typing : incorrect) } }这里有个边界情况要特别注意typed 比目标文本长的情况。比如目标单词是 “hello”用户多敲了个字符变成 “hellop”简单的遍历会判定第 5 个位置出错但 d 实际上在 “hello” 的索引 5 处是 undefined也就是超出了目标单词的长度也算是错误。上面的循环用Math.max取较长长度遍历对超出部分也会标错逻辑是对的。“typing”状态是使用体验上的关键。只有当用户当前输入的完整字符串恰好是目标文本的前缀时才显示为“正在输入中”比如目标 “hello”用户打了 “hell”状态是 typing如果打了 “help”前缀匹配失败状态立即切换为 incorrect同时单词颜色变红。这套状态机是打字游戏反馈是否及时的体验分水岭。3.2 键盘输入捕获与中文输入法冲突处理打字游戏最核心的交互就是键盘输入但这恰恰是浏览器和 Electron 渲染进程之间差异最大的地方。我第一次在 Electron 里做打字游戏时踩了大坑系统中文输入法开启时按下的字母键不会直接触发 keydown 事件而是进入了输入法组合流程。于是游戏里我按一个键结果输入框里冒出拼音候选游戏界面完全收不到字符。这个问题的根源是键盘事件和输入法事件是两条独立的通道。中文输入法激活时按键事件被输入法拦截组合后的文本通过 compositionend 事件一次性输出。对于打字游戏来说必须绕过输入法的组合流程直接读取原始按键值。我的解决方案是双保险监听keydown事件通过event.key Process或event.isComposing判断是否处于输入法状态录入目标设置选择区分中英文模式英文模式下强制要求用户关闭输入法并且在页面顶部给出提示。// 输入处理核心 hook function useTypingInput() { const inputRef refHTMLInputElement() function handleKeydown(e: KeyboardEvent) { // 屏蔽输入法组合状态 if (e.isComposing || e.key Process) return if (e.key Backspace) { // 处理退格逻辑 return } if (e.key.length 1 /[a-zA-Z0-9]/.test(e.key)) { // 处理单个字符输入 return } } onMounted(() { window.addEventListener(keydown, handleKeydown) window.addEventListener(compositionstart, handleCompositionStart) window.addEventListener(compositionend, handleCompositionEnd) }) onUnmounted(() { window.removeEventListener(keydown, handleKeydown) }) }这里有个细节值得展开event.isComposing属性不是所有浏览器都完全一致但 Chromium 内核下是可靠的Electron 正好就是 Chromium所以这个方案在 Electron 环境没问题。如果是做普通网页应用还得兼容 Safari 的差异。另一个体验层面上的处理是用户按 Tab 键切到下一个单词比如跳过当前单词这在 VSCode 扩展版本里实现很别扭因为 Webview 面板的焦点管理跟编辑器有冲突。到了 Electron 里我可以直接拦截 Tab 键的默认行为逻辑干净纯粹这是独立应用带来的自由度。3.3 画面渲染与游戏循环Vue 3 响应式的高级用法打字游戏的 UI 渲染不像普通的表单应用它的状态变化频率非常高。用户打字时每按一个键当前单词的状态就变一次如果文章很长传统做法是拿响应式数组保存所有单词状态每个按键都会触发 Vue 的依赖收集和更新性能很容易出问题。关于 Vue 3 的高频状态更新我的建议是把状态分成“高频热路径”和“低频冷路径”两层高频热路径当前单词的文本、当前输入内容、光标位置、是否出错、统计数字。这些每按一次键都会变化应该用独立的 ref 保存尽量缩小依赖该 ref 的组件范围。低频冷路径单词列表、文章标题、题库数据、设置项。这些只在开始一篇文章或切换模式时变化频率极低。实现上最关键的点是把“单词列表”做成只有当前和前后两个单词被“激活”的渲染模式。不用在键盘上给所有单词都绑定响应式状态只在当前正在输入的那个单词上做实时绑定script setup langts const session refTypingSession() // 只有当前单词是响应式的核心 const currentWord computed(() { const words session.value?.words if (!words) return { text: , typed: , status: pending } const index session.value!.currentWordIndex ?? -1 if (index 0 || index words.length) return { text: , typed: , status: pending } return words[index]! }) const previewWords computed(() { const words session.value?.words ?? [] const index session.value?.currentWordIndex ?? 0 return { before: words.slice(Math.max(0, index - 12), index), current: words[index], after: words.slice(index 1, index 13) } }) /script这样做的好处是键盘输入时Vue 只需要更新 currentWord 相关的渲染碎片其他单词的视图节点完全不需要重新渲染。我在实际测试中看过 DevTools 的 Perf Monitor打开 FPS 面板通常保持在 55 到 60 帧之间没有出现输入卡顿。游戏循环方面我放弃了你常见的 setInterval 计时方案改用performance.now() 标记清除的时间戳来做统计。原因是你 setInterval 在后台窗口或系统负载高的时候会有漂移对于“计时精确到秒”的打字统计来说这误差不可接受。我用的是记录开始时间和结束时间再计算差值的方式。function calculateMetrics(session: TypingSession) { const elapsedMinutes (session.endTime! - session.startTime) / 1000 / 60 const grossWPM Math.round(session.totalKeystrokes / 5 / elapsedMinutes) const netWPM Math.round((session.totalKeystrokes - session.totalErrors) / 5 / elapsedMinutes) const accuracy Math.round((session.totalCorrectKeystrokes / session.totalKeystrokes) * 100) return { grossWPM, netWPM, accuracy } }这个计算里有个约定俗成的标准是 “一个单词约等于 5 次敲击”也就是用总敲击数除以 5 来估算单词数。这种做法在打字测试领域很常见比如 10fastfingers 和 typing.com 都这么算。你不需要单独统计用户敲了多少个空格和多少字符VM 用总 keystrokes / 5 统一折算就行。3.4 数据统计与本地持久化独立应用相比扩展在数据持久化上有了质的变化。VSCode 扩展里存点数据要用 workspaceState跟工作区绑定很容易被清理独立应用我可以直接用文件系统把数据存在用户数据目录下。存储层我放在主进程来做。渲染进程只需要调用window.desktop.storage.get(history)就能拿到完整的历史记录。// 主进程 storage 模块 import { app, ipcMain } from electron import { promises as fs } from fs import path from path interface StorageData { history: TypingResult[] settings: Settings } const dataPath path.join(app.getPath(userData), typing-data.json) let cache: StorageData | null null async function readData(): PromiseStorageData { if (cache) return cache try { const raw await fs.readFile(dataPath, utf-8) cache JSON.parse(raw) } catch { cache { history: [], settings: { language: en, wordCount: 50 } } } return cache } async function writeData(data: StorageData) { cache data await fs.writeFile(dataPath, JSON.stringify(data, null, 2), utf-8) } ipcMain.handle(storage:get, async (_event, key: keyof StorageData) { const data await readData() return data[key] }) ipcMain.handle(storage:set, async (_event, key: keyof StorageData, value: unknown) { const data await readData() ;(data as any)[key] value await writeData(data) return true })存储这一层我给一个实战建议一定要做内存缓存就是上面的 cache 变量。每次读写都走文件系统的话游戏结束保存结果时会卡维护一层内存缓存读操作走缓存写操作同步刷盘你体感上就是零延迟。历史记录的展示我用了一个折中的办法不引入 ECharts 这种重量级图表库而是用 Vue 3 的 SVG 绑定手写了一个超级简化的折线图组件。代码量不大但足够展示 WPM 的趋势数据。引入一个完整图表库对这个小工具来说太奢侈4KB 的 SVG 组件完全够用而且打包体积也被压下来了。4. 应用外壳与桌面体验从网页到真正的 App4.1 窗口定制无边框、置顶与托盘把打字应用从 VSCode 扩展搬到独立窗口后第一件事就是定制窗口行为。VSCode Webview 面板被编辑器窗口限制窗框、工具栏都是编辑器决定的独立的 Electron 窗口完全由主进程掌控自由度提升是质的。我为这个打字应用做了三个窗口设计无边框 半透明背景。打字时全屏沉浸感很重要默认的边框和标题栏都是无关干扰。Electron 创建一个无边框窗口后窗口拖动需要一个自定义可拖拽区域我用 CSS 给顶部留了一条拖拽热区.drag-region { -webkit-app-region: drag; height: 32px; position: fixed; top: 0; left: 0; right: 0; z-index: 1000; }这里有个容易踩的坑-webkit-app-region: drag;区域内的按钮默认点不了需要给按钮单独加-webkit-app-region: no-drag;否则按钮就是死键。置顶模式。用户练打字时可能想开着直播软件边看边练或者边看教学视频边模仿。我在窗口控制里添加了一个“置顶”切换按钮调用setAlwaysOnTop控制。这个能力在 VSCode 扩展里完全没法实现。ipcMain.handle(window:toggle-pin, (event) { const win BrowserWindow.fromWebContents(event.sender) if (!win) return false const next !win.isAlwaysOnTop() win.setAlwaysOnTop(next, floating) win.setFocusable(!next) // 置顶时允许点击穿透可选 return next })托盘常驻。最小化时不退出而是收进系统托盘点击托盘图标可以重新唤起窗口。训练中途临时去处理别的事回来点一下图标就能继续练状态不丢。import { Tray, nativeImage } from electron let tray: Tray | null null function createTray() { const icon nativeImage.createFromDataURL(trayIconBase64) tray new Tray(icon) tray.setToolTip(打字训练器) tray.setContextMenu(Menu.buildFromTemplate([ { label: 显示主界面, click: () mainWindow.show() }, { type: separator }, { label: 退出, click: () app.quit() } ])) tray.on(double-click, () mainWindow.show()) }这组窗口行为做完后应用才有“桌面应用”的感觉使用体验与 VSCode Webview 拉开了明显差距。原来用户得开编辑器才能进入打字界面现在点托盘图标、按全局快捷键1 秒内就能进入训练状态。4.2 菜单体系与全局快捷键Electron 窗口默认有一个菜单栏但这个菜单对这个应用没有用处反而引出了“CtrlShiftI”这种开发者工具的暴露风险。我选择把默认菜单栏直接移除Menu.setApplicationMenu(null)这样窗口更清爽F12 打开 DevTools 的快捷键也没了一定程度上避免了普通用户的误操作。但你开发期间可能也需要调样式所以在非生产环境下我保留了一个隐藏菜单栏的打开方式按 CtrlShiftD 在 production 开启快捷调试入口之前要通过环境变量隔离开谨慎一点总没错。真正让这个应用从“网页”升级为“桌面应用”的功能是全局快捷键。用户在任意窗口按下 CtrlShiftT可配置应用立即唤醒并把输入焦点定位到打字区域。import { globalShortcut } from electron ipcMain.handle(shortcut:register, async (_event, accelerator: string) { globalShortcut.unregisterAll() const ok globalShortcut.register(accelerator, () { if (!mainWindow) return mainWindow.show() mainWindow.focus() mainWindow.webContents.send(shortcut:triggered) }) return ok })全局快捷键配置存在主进程里渲染进程通过设置页面写入下次启动时自动恢复。这里有个小坑要提醒全局快捷键是系统级别的如果你注册的快捷键跟系统自带热键冲突Electron 的 register 会静默失败返回 false。所以你在代码里一定要检查返回值并在 UI 上提示用户换一个组合键。4.3 打包分发electron-builder 实践开发调试跑通之后最后一步是打包成可分发的安装程序。我用了 electron-builder它跟 electron-vite 模板集成得比较好配置都在electron-builder.yml里。appId: com.typingtrainer.app productName: 打字训练器 directories: buildResources: build files: - out/** - resources/** asar: true win: target: - target: nsis arch: [x64] artifactName: ${productName}-${version}-setup.${ext} nsis: oneClick: false allowToChangeInstallationDirectory: true createDesktopShortcut: true createStartMenuShortcut: true打包时有一个必须注意的点如果渲染进程用到了本地图片资源比如背景图、logo这些资源会被打进 asar 包里路径必须在代码中通过__dirname或process.resourcesPath动态拼接而不是写成硬编码的相对路径。实际上更省心的做法是把这类资源放到public目录由 Vite 在构建渲染进程时把它打进产物目录然后在渲染进程里正常用相对路径引用。另一个容易踩的坑是 Windows 系统默认的 SmartScreen 和杀毒软件拦截。第一次打包出来的 exe 没有签名分发后被 Windows Defender 警告拦截很影响体验。解决方案是给安装包做代码签名常见选择是购买 EV 证书或者用免费的证书比如自签名。对个人项目来说如果只是自用或小范围分发可以先用自签名但你要告诉用户“运行可能被警告”并详细说明如何通过如果你打算公开发布还是得买一个签名证书。5. 踩坑实录与性能优化5.1 中文输入法冲突最影响体验的坑这个坑我在前文提过但值得单独拉出来再细说因为它是打字类应用的分水岭问题。VSCode 扩展时代我就被它折磨过搬到 Electron 后同样存在。具体现象是中文输入法开启时打字游戏界面无法正常接收单字符按键。按键被输入法拦截后进入拼音候选状态无论按什么都不触发 keydown 的字符事件直到用户敲空格或数字选词通过 compositionend 一次性输出一堆组合文本。我的处理方案是分层的应用内检测在组合开始到结束期间暂停接收字符输入同时展示提示“请关闭中文输入法”语言设置检测在打字模式设置里默认进入英文模式并在会话开始时检测系统输入语言是否匹配。技术上是读 Electron 的systemPreferences.getKeyboardLayout()把它和设定的目标语言做比对不匹配就提示CSS 视觉提示在状态栏放一个语言指示灯绿色代表未检测到输入法红色代表有输入法在活动。这个提示非常直观用户在打字前就能发现自己的输入法没关。即使这样还有部分用户处于半中途输入法状态我最终加了一个折衷方案在打字游戏进行时不拦截非英文字符而是直接标记为错误输入并提示以此强迫用户切换到英文输入法。表面上看有点粗暴但从实际反馈来看比减少误输入更有效打字体验更稳定。5.2 打字延迟排查与渲染优化打包后有用户反馈说“按 Enter 换文章时感觉有点卡”排查发现问题出在换文章的流程上。换文章要干的事情包括从题库随机取一篇、重建整个单词列表、重置统计状态、可能还要滚动到文章开头。这几个动作串行执行还是并行执行对用户体感影响很大。我最初写成同步处理后来改成异步 按时序分批。具体来说第一步先清空当前渲染状态并显示“加载中”第二步用setTimeout把耗时操作放进下一个宏任务第三步渲染完成后再把输入焦点恢复到打字框。这样虽然用户感知到的总等待时间没变但因为界面是分步反馈的体感上流畅很多。function loadNextArticle() { session.value null isPreparing.value true // 先让 UI 立即响应重置 requestAnimationFrame(() { setTimeout(() { const words prepareWords(pickRandomArticle()) session.value { words, currentWordIndex: 0, ...resetStats() } isPreparing.value false // 设置焦点到隐藏输入框 hexInputRef.value?.focus() }, 0) }) }另外一个性能隐患是隐藏输入框的自动对焦。打字游戏通常不在每帧都给输入框加焦点而是在用户点击背景时重新对焦。如果玩游戏时鼠标点到了窗口外部输入焦点丢失再回来时按键虽然被捕获但 games 逻辑里可能没有把输入焦点恢复到指定区域导致前面输入的内容全部丢失。解决方案是在窗口 blur 事件里记录丢失焦点状态并在用户恢复到应用时调用focus()重新聚焦同时暂停计时——打了一会儿字不小心点到外面回来发现计时还在跑这就很滑稽了。5.3 其他值得记录的细节坑网页应用中常见的问题是字体渲染。VSCode 扩展时代字体会继承编辑器的设置比如等宽字体、字号等独立应用需要自己定义。我用了一套比较合适的字体栈.font-mono { font-family: JetBrains Mono, Fira Code, Consolas, Courier New, monospace; }等宽字体对打字游戏来说非常重要因为每个字符宽度一致眼睛追踪起来才舒服。这里有个细节数字和字母在不同字体下宽度不一致如果不小心用了非等宽字体单词对齐会很乱严重影响体验。所以我一律用等宽字体并给每个单词的宽度设置了固定值。还有一个 Egg 是 Electron 开发模式与打包后表现不一致开发时窗口能正常加载本地资源打包后资源 404。我排查后发现是 electron-vite 打包后的资源路径是以file://协议加载的与开发时的http://localhost不同。解决办法是在 electron-builder 配置里设置files时确保渲染进程的index.html被包含进来了并且使用相对路径./而不是绝对路径/来引用资源。代码签名问题也值得记录一下。第一次尝试免费自签方案生成后安装倒是成功但每次安装打开都被 SmartScreen 拦用户体验极其糟糕。后来换了一个开源项目里常用的 SignPath 免费签名服务针对开源项目提供免费代码签名签名之后只要不在下载量太大的环境下一般不会再触发警告。如果你也是个人开源项目要留意一下这些免费签名服务是否认领你项目的开源源码。结束前再说几句到这里从 VSCode 扩展到独立应用的改造就完整走了一遍技术选型时明确了为什么选 Electron 和 Vue 3工程搭建时处理好三进程模型和 IPC 通信功能实现时搞定了打字引擎、输入捕获和数据持久化最后通过打包和优化让应用真正可以分发出去用。如果你也想做类似的事我个人建议是先把你现存项目的核心逻辑抽干净确保它不依赖任何宿主 API这一步决定了你以后迁移的轻松程度。这次我的打字引擎就是纯 TypeScript 核心逻辑所以才能这么顺畅地塞进 Electron 项目。真正迁移时优先解决输入法与用户输入的问题这是打字类应用的生命线然后用 reducer 切片处理高频率状态避免一更新就整棵树重渲染。窗口外壳和托盘这些属于锦上添花做完核心有富余时间再搞别本末倒置。最后分享一个小技巧我在开发阶段用electron-vite的热更新时明明改了渲染进程代码界面却没有变化。排查了半天发现是主进程窗口背景下需要刷新前把开发工具打开然后强制location.reload()才能生效。后来发现这是因为 preload 脚本里某个 api 的执行缓慢导致did-finish-load事件被阻塞页面还没来得及注入window.desktop就完成了加载。你现在如果遇到类似问题不妨去 DevTools 控制台打印一下window.desktop是否为 undefined如果为 undefined说明 preload 加载异常此时优先检查 preload 脚本路径或代码是否报错远比反复重启桌面环境来得高效。这次的架构改造是个小型但完整的实践希望对你有参考价值。
返回列表