Electron+Vue3桌面打字游戏架构重构实战
2026/9/12 3:45:31 网站建设 项目流程

1. 项目概述:为什么一个打字游戏值得做两次?

Electron + Vue 3 桌面打字游戏实战——这个标题里藏着三个关键信号:它不是玩具 demo,而是真实项目;它经历了从 VSCode 扩展到独立桌面应用的完整生命周期;它核心是一次架构层面的重构,不是简单打包迁移。我带团队做过 7 个 Electron 项目,其中 4 个是从编辑器插件起步的,这个打字游戏是唯一一个最终脱离编辑器生态、跑在用户桌面上还能保持 60fps 动画和毫秒级按键响应的案例。它解决的不是“能不能写”,而是“怎么写得既轻量又健壮”——比如 VSCode 插件里用vscode.window.showInformationMessage弹个提示就行,但独立应用里你得自己处理窗口焦点丢失时的输入中断、系统级快捷键冲突、多显示器 DPI 缩放适配,甚至 USB 键盘热插拔事件监听。Vue 3 的 Composition API 在这里不是炫技,而是为了解耦「打字逻辑」和「渲染逻辑」:前者要跑在主线程做实时字符比对和速度计算,后者必须用<canvas>做离屏渲染避免 DOM 重排卡顿。Electron 不是简单的“网页套壳”,它的contextIsolation: true默认策略让require('fs')在渲染进程直接报错,而打字游戏需要读取本地词库文件——这就逼着你必须用preload.js建立安全通道,而不是像老项目那样粗暴关闭隔离。很多人卡在第一步:VSCode 扩展里vscode.workspace.getConfiguration()能直接读配置,但 Electron 里你得自己实现 JSON 配置持久化,还要考虑 Windows 注册表、macOS plist、Linux XDG 标准三套路径。这不是技术堆砌,是每个选择背后都有血泪教训:我们曾因没处理好webPreferences.nodeIntegration: false下的serialport加载失败,在测试机上反复蓝屏三次才定位到是 preload 里contextBridge.exposeInMainWorld没过滤掉__proto__导致原型链污染。

2. 架构设计与思路拆解:从插件到应用的四层剥离

2.1 为什么必须放弃 VSCode 插件架构?

VSCode 插件本质是运行在编辑器沙箱里的 JavaScript 模块,它的生命周期由编辑器控制:激活时加载,关闭编辑器时卸载。而桌面应用需要自主管理进程、窗口、系统托盘、后台服务。我们最初把打字游戏做成插件时,所有状态都存在globalState里,比如当前训练模式、历史最高 WPM、错词记录——这在插件里很自然,但迁移到 Electron 后问题立刻暴露:用户最小化窗口再恢复,globalState会丢失(因为插件被回收),而桌面应用要求数据必须持久化到磁盘。更致命的是依赖倒置:插件依赖 VSCode 提供的vscode全局对象,但 Electron 里没有这个对象,强行模拟会导致vscode.workspace.rootPath返回undefined,而我们的词库加载逻辑恰恰依赖这个路径拼接相对地址。我们试过用process.env.VSCODE_PID判断是否在 VSCode 环境下运行,但 Electron 主进程根本不会设置这个环境变量,导致条件编译失效。最终方案是彻底解耦:把所有 VSCode 特有 API 封装成接口,插件版和桌面版分别实现。比如IStorageService接口,插件版用vscode.workspace.getConfiguration().get(),桌面版用electron-store库配合app.getPath('userData')。这种抽象不是过度设计,而是为了应对真实场景——我们后来接到需求,要把游戏嵌入公司内部培训平台,这时只需要实现IStorageService的 Web 版本,核心打字引擎代码一行不用改。

2.2 Electron 主进程与渲染进程的职责边界

很多初学者以为 Electron 就是“把网页塞进窗口”,结果写出一堆remote模块调用,最后发现remote在 Electron 12+ 已废弃。我们的架构强制分离:主进程只做三件事——管理窗口生命周期、处理系统级事件(如全局快捷键CmdOrCtrl+Shift+T重新开始)、调用原生模块(serialport读取外接打字机硬件)。渲染进程只负责 UI 渲染和用户交互,所有耗时操作(如词库解析、WPM 计算)都放在 Web Worker 里。关键决策是contextIsolation必须开启,这是安全底线。这意味着渲染进程无法直接访问 Node.js 全局对象,所有通信必须通过contextBridge。我们定义了window.api对象暴露给 Vue 组件:

// preload.ts import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('api', { // 安全暴露的 API,全部经过参数校验 saveConfig: (config: Record<string, any>) => { // 过滤危险字段,只允许保存预定义键 const safeKeys = ['theme', 'wpmGoal', 'autoStart'] const safeConfig = Object.keys(config).reduce((acc, key) => { if (safeKeys.includes(key)) acc[key] = config[key] return acc }, {} as Record<string, any>) ipcRenderer.invoke('save-config', safeConfig) }, loadWords: () => ipcRenderer.invoke('load-words'), // 严禁暴露 fs.readFile 等原始 API })

这样 Vue 组件里就能直接调用window.api.saveConfig({ theme: 'dark' }),而主进程ipcMain.handle('save-config')里再做真正的文件写入。这种设计牺牲了一点开发速度,但换来的是可审计的安全模型——当某天发现词库加载慢,我们能快速定位到是load-wordsIPC 调用耗时,而不是在混乱的require('fs')调用链里大海捞针。

2.3 Vue 3 Composition API 的工程化落地

Vue 3 的setup()函数常被当成语法糖,但在桌面应用里它是状态管理的救命稻草。传统 Options API 里data是响应式对象,但打字游戏需要精确控制更新时机:比如用户按下一个键,要同时更新「当前输入字符」「已输入字符数」「实时 WPM」「错误标记位置」四个状态,如果用this.$set分别触发,Vue 会做四次 DOM 更新。Composition API 让我们把相关状态聚合成一个typingState响应式对象:

// composables/useTyping.ts import { reactive, computed } from 'vue' export function useTyping() { const state = reactive({ currentInput: '', targetText: '', cursorPosition: 0, errors: [] as number[], startTime: 0, endTime: 0 }) const wpm = computed(() => { const duration = (state.endTime - state.startTime) / 1000 / 60 return duration > 0 ? Math.round(state.currentInput.length / 5 / duration) : 0 }) const isCorrect = (index: number) => { return state.currentInput[index] === state.targetText[index] } return { state, wpm, isCorrect, startTest: () => { state.startTime = Date.now() state.currentInput = '' state.errors = [] state.cursorPosition = 0 } } }

注意wpmcomputed而非ref,因为 WPM 计算是纯函数,不触发副作用;isCorrect是普通函数而非computed,因为每次光标移动都要重新计算,缓存反而增加内存开销。这种粒度控制在 Options API 里很难实现——你得在methods里手动维护this.wpm的更新时机,稍有不慎就出现 WPM 显示滞后半秒的 bug。

2.4 从插件到桌面的渐进式迁移路径

我们没采用“重写”这种高风险方案,而是设计了三阶段迁移:

  1. 共用核心库阶段:把打字逻辑抽成独立 npm 包@type-game/core,包含WordGenerator(生成随机词组)、TypingEngine(实时比对算法)、StatsCalculator(WPM/准确率计算)。VSCode 插件和 Electron 应用都依赖这个包,确保业务逻辑一致性。

  2. 双入口构建阶段:Webpack 配置里定义两个入口:

    • src/extension.ts:VSCode 插件入口,调用vscode.window.createWebviewPanel
    • src/main.ts:Electron 主进程入口,调用new BrowserWindow两者共享src/renderer目录下的 Vue 组件,但main.ts里额外注入window.apiextension.ts里注入vscode对象。
  3. 独立构建产物阶段:最终 Electron 版本移除所有 VSCode 相关代码,用electron-builder打包。关键技巧是利用package.jsonbuild.extraResources字段把词库文件(words.json)复制到resources/目录,这样主进程就能用path.join(__dirname, '../resources/words.json')安全读取,避免打包后路径错乱。

这个路径让我们在两周内完成迁移,期间插件版本照常更新,用户无感知。最值钱的经验是:永远不要在迁移初期就删除旧代码,先让新旧系统并行运行,用真实用户数据验证新架构的稳定性——我们发现 Electron 版本在 macOS 上首次启动慢 800ms,原因是electron-store初始化时同步读取~/Library/Application Support/TypeGame/config.json,而 VSCode 插件用的是异步vscode.workspace.getConfiguration()。解决方案是把配置加载放到app.whenReady()之后,并加 loading 状态提示。

3. 核心细节解析与实操要点:那些文档里不写的坑

3.1 Electron 菜单栏的跨平台陷阱

Electron 的Menu.buildFromTemplate看似简单,但 Windows/macOS/Linux 三端菜单行为差异巨大。比如 macOS 要求应用菜单必须有Application子菜单(含AboutQuit),而 Windows 只需要FileEdit。我们最初用同一份模板,结果 Windows 用户点击Edit菜单时,剪切板操作(Ctrl+X/Ctrl+V)全部失效——因为 Electron 默认把Edit菜单绑定到editMenu角色,但我们的模板里没声明role: 'editMenu',导致 Electron 无法自动注入标准快捷键。修复方案是显式声明角色:

const template = [ { label: 'Edit', role: 'editMenu', // 关键!告诉 Electron 这是编辑菜单 submenu: [ { role: 'undo' }, { role: 'redo' }, { type: 'separator' }, { role: 'cut' }, { role: 'copy' }, { role: 'paste' } ] } ]

更隐蔽的坑是快捷键冲突:VSCode 插件里CmdOrCtrl+Shift+P是命令面板快捷键,但 Electron 应用里这个组合键会被系统捕获。我们测试发现 macOS 上Cmd+Shift+P会触发 Spotlight 搜索,必须改成CmdOrCtrl+Alt+P。解决方案是在BrowserWindow创建时禁用默认菜单,然后用globalShortcut.register注册全局快捷键:

// main.ts app.whenReady().then(() => { const win = new BrowserWindow({ /* ... */ }) // 注册全局快捷键,绕过菜单限制 globalShortcut.register('CommandOrControl+Alt+P', () => { win.webContents.send('open-command-palette') }) })

这样 Vue 组件监听window.api.on('open-command-palette')事件即可,完全脱离菜单系统。

3.2 使用 electron-serialport 读取外接打字机硬件

标题里提到electron serialport,这不是噱头。我们接入了一台复古机械打字机改装的 USB 设备,它发送 ASCII 字符流,但要求毫秒级响应——如果按键延迟超过 50ms,用户会觉得“键盘粘滞”。serialport在 Electron 里不能直接npm install serialport,因为它的 native 模块需要针对 Electron 版本重新编译。正确流程是:

  1. 安装@serialport/core(纯 JS 版本,无 native 依赖)
  2. 如果必须用 native 版本,则用electron-rebuild重建:
    npx electron-rebuild --version 24.0.0 --arch x64 --platform darwin --force
  3. 在 preload.js 里暴露串口 API,但必须做严格权限控制:
// preload.ts import { SerialPort } from '@serialport/core' import { ReadlineParser } from '@serialport/parser-readline' contextBridge.exposeInMainWorld('serial', { // 只允许连接特定设备,禁止枚举所有端口 connect: async (portPath: string) => { // 白名单校验 const allowedPorts = ['/dev/tty.usbmodem14101', '/dev/ttyACM0'] if (!allowedPorts.includes(portPath)) throw new Error('Forbidden port') const port = new SerialPort({ path: portPath, baudRate: 9600 }) const parser = port.pipe(new ReadlineParser({ delimiter: '\r\n' })) parser.on('data', (data) => { ipcRenderer.send('serial-data', data) }) return { disconnect: () => port.close() } } })

这样 Vue 组件调用window.serial.connect('/dev/tty.usbmodem14101')时,preload 层会校验端口合法性,避免恶意脚本扫描/dev/tty*。实测发现 macOS 上SerialPort.list()返回的端口名不稳定,有时是tty.usbmodem14101,有时是cu.usbmodem14101,所以我们在连接前加了 500ms 重试逻辑,直到找到匹配设备。

3.3 从 HTML 网页转 EXE 的真相:不是打包,是构建

网络热词里“使用 electron 将 html 网页转为 exe”是典型误解。Electron 应用不是把现有网页拖进去就能运行,它需要完整的构建链路。我们对比过三种方案:

方案适用场景编译时间包体积是否支持 Vue 3
electron-packager+ 手动拷贝静态 HTML15s120MB❌ 需手动处理 Vue 运行时
vite-plugin-electronVue 3 项目8s95MB✅ 开箱即用
electron-builder+vue-cli-plugin-electron-builder复杂应用22s110MB✅ 但配置复杂

最终选择vite-plugin-electron,因为它的 HMR(热模块替换)在 Electron 环境下真正可用——修改 Vue 组件代码,渲染进程自动刷新,无需重启整个应用。关键配置在vite.config.ts

import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { electronPlugin } from 'vite-plugin-electron' export default defineConfig({ plugins: [ vue(), electronPlugin({ // 主进程入口 entry: 'src/main.ts', // 预加载脚本入口 preload: { input: 'src/preload.ts' } }) ], build: { // 关键:设置 lib 模式避免打包 Vue 运行时 rollupOptions: { external: ['electron'] } } })

这里external: ['electron']是精髓——告诉 Vite 不要把electron模块打进 bundle,因为 Electron 运行时已经提供了这些 API。如果不设 external,打包后的 JS 文件会包含electron的 polyfill,导致在nodeIntegration: false下报错Cannot find module 'electron'

3.4 VSCode 配置与 Electron 配置的语义映射

VSCode 插件的配置项(package.json中的contributes.configuration)和 Electron 的配置文件(electron-store)不是简单的一对一映射。比如 VSCode 里typeGame.wpmGoal是数字类型,但 Electron 里我们想支持“自动根据历史数据动态调整目标”,这就需要扩展配置结构:

// VSCode 配置(简单) { "typeGame.wpmGoal": 60 } // Electron 配置(增强) { "wpmGoal": { "value": 60, "mode": "fixed", // 或 "adaptive" "historyDays": 30 } }

迁移时我们写了配置转换脚本,在 Electron 首次启动时检查app.getPath('userData')下是否存在旧配置,如果存在则执行转换:

// main.ts const store = new Store<ConfigType>() if (!store.has('wpmGoal')) { // 检查是否来自 VSCode 迁移 const vscodeConfigPath = path.join(app.getPath('userData'), 'vscode-typegame-config.json') if (fs.existsSync(vscodeConfigPath)) { const oldConfig = JSON.parse(fs.readFileSync(vscodeConfigPath, 'utf8')) store.set('wpmGoal', { value: oldConfig['typeGame.wpmGoal'] || 40, mode: 'fixed', historyDays: 30 }) } }

这个脚本只运行一次,之后store就接管所有配置读写。经验是:永远不要假设用户会手动导出导入配置,自动化迁移才是专业做法。

4. 实操过程与核心环节实现:手把手复现关键步骤

4.1 初始化项目:Vite + Electron + Vue 3 的最小可行配置

跳过npm init vite的常规流程,直接用create-vite创建基础项目,然后手动集成 Electron。原因:Vite 官方模板不包含 Electron 支持,强行用vite-plugin-electron会遇到process全局变量未定义的问题。正确步骤:

  1. 创建 Vite 项目:

    npm create vite@latest type-game -- --template vue cd type-game npm install
  2. 安装 Electron 依赖:

    npm install --save-dev electron@24.0.0 vite-plugin-electron@0.24.0 npm install electron-store@4.0.0 @serialport/core@11.0.0
  3. 创建主进程文件src/main.ts

    import { app, BrowserWindow, ipcMain, globalShortcut } from 'electron' import * as path from 'path' import { Store } from 'electron-store' const store = new Store() function createWindow() { const win = new BrowserWindow({ width: 1200, height: 800, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } }) // 开发时加载 Vite 服务器,生产时加载打包文件 if (process.env.NODE_ENV === 'development') { win.loadURL('http://localhost:5173') } else { win.loadFile(path.join(__dirname, '../dist/index.html')) } } app.whenReady().then(() => { createWindow() // 注册全局快捷键 globalShortcut.register('CommandOrControl+Alt+R', () => { BrowserWindow.getAllWindows()[0]?.reload() }) })
  4. 创建预加载脚本src/preload.ts(注意必须用.ts后缀,Vite 会自动处理):

    import { contextBridge, ipcRenderer } from 'electron' // 安全暴露 API contextBridge.exposeInMainWorld('api', { saveConfig: (config: any) => ipcRenderer.invoke('save-config', config), getConfig: () => ipcRenderer.invoke('get-config'), // 其他 API... })
  5. vite.config.ts中启用插件:

    import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import { electronPlugin } from 'vite-plugin-electron' export default defineConfig({ plugins: [ vue(), electronPlugin({ entry: 'src/main.ts', preload: { input: 'src/preload.ts' } }) ], resolve: { alias: { '@': path.resolve(__dirname, 'src') } } })

关键点:vite-plugin-electron会自动把src/main.ts编译为dist-electron/main.js,把src/preload.ts编译为dist-electron/preload.js,而dist/index.html是渲染进程的入口。这种分离让主进程和渲染进程的构建完全解耦,调试时可以单独重启渲染进程而不影响主进程状态。

4.2 Vue 3 打字组件的核心实现:Canvas 渲染与性能优化

DOM 渲染在打字游戏中是性能杀手。我们测试过:用<span v-for>渲染 200 个字符,每秒 60 帧下 CPU 占用 45%,而用<canvas>渲染同样内容,CPU 占用降至 8%。核心实现:

  1. 创建 Canvas 组件TypingCanvas.vue

    <template> <canvas ref="canvasRef" @click="handleClick" @keydown="handleKeydown" tabindex="0" /> </template> <script setup lang="ts"> import { onMounted, ref, watch } from 'vue' import { useTyping } from '@/composables/useTyping' const props = defineProps<{ targetText: string currentInput: string }>() const canvasRef = ref<HTMLCanvasElement | null>(null) const { state, isCorrect } = useTyping() // 初始化 Canvas onMounted(() => { if (!canvasRef.value) return const canvas = canvasRef.value const ctx = canvas.getContext('2d') if (!ctx) return // 设置 Canvas 尺寸匹配父容器 const resize = () => { canvas.width = canvas.clientWidth * window.devicePixelRatio canvas.height = canvas.clientHeight * window.devicePixelRatio ctx.scale(window.devicePixelRatio, window.devicePixelRatio) } resize() window.addEventListener('resize', resize) // 绘制循环 const draw = () => { ctx.clearRect(0, 0, canvas.width, canvas.height) drawText(ctx, props.targetText, props.currentInput) requestAnimationFrame(draw) } draw() }) const drawText = (ctx: CanvasRenderingContext2D, target: string, input: string) => { const fontSize = 24 ctx.font = `${fontSize}px -apple-system, BlinkMacSystemFont, 'Segoe UI'` ctx.textBaseline = 'top' ctx.fillStyle = '#333' // 逐字符绘制,根据正确性变色 for (let i = 0; i < target.length; i++) { const char = target[i] ctx.fillStyle = i < input.length && isCorrect(i) ? '#28a745' : '#dc3545' ctx.fillText(char, i * fontSize * 0.6, 50) } } </script>
  2. 性能关键点:

    • requestAnimationFrame替代setInterval:保证 60fps 且不阻塞主线程
    • devicePixelRatio缩放:适配 Retina 屏幕,避免文字模糊
    • ctx.clearRect而非innerHTML = '':Canvas 清空比 DOM 重置快 10 倍
    • 字符宽度预计算:i * fontSize * 0.6是经验值,实际项目中用ctx.measureText(char).width动态计算更精确,但会增加 2ms 开销,我们选择固定宽度换取性能
  3. 输入事件处理:

    const handleKeydown = (e: KeyboardEvent) => { if (e.key === 'Backspace') { state.currentInput = state.currentInput.slice(0, -1) } else if (e.key.length === 1) { state.currentInput += e.key } e.preventDefault() // 阻止默认输入,由 Canvas 控制显示 }

这里e.preventDefault()是灵魂——它阻止浏览器在 input 框里输入,让所有渲染逻辑集中在 Canvas,避免 DOM 和 Canvas 两套状态同步的复杂性。

4.3 Electron 打包与分发:electron-builder 的避坑指南

electron-builder是事实标准,但默认配置会踩无数坑。我们的electron-builder.yml关键配置:

appId: com.typegame.app productName: TypeGame copyright: Copyright © 2024 TypeGame artifactName: ${productName}-${version}-${platform}-${arch}.${ext} directories: output: dist-electron files: - dist/**/* - node_modules/**/* - package.json - resources/**/* # 排除开发依赖 - '!node_modules/@types/**/*' - '!node_modules/vite/**/*' - '!src/**/*' # Windows 特定配置 win: target: - target: nsis icon: build/icon.ico publish: provider: generic url: https://example.com/download/ # macOS 特定配置 mac: target: - target: dmg - target: zip icon: build/icon.icns hardenedRuntime: true gatekeeperAssess: false entitlements: build/entitlements.plist # Linux 特定配置 linux: target: - target: deb - target: AppImage icon: build/icon.png category: Game

关键避坑点:

  • files字段必须显式包含resources/**/*,否则词库文件words.json不会打进安装包
  • win.targetnsis而非squirrel,因为 Squirrel 已废弃且不支持最新 Windows 10/11
  • mac.hardenedRuntime: true是 macOS App Store 上架必需,但会导致serialport加载失败,解决方案是签名时添加--options=runtime参数
  • linux.category: Game让应用在 Ubuntu 菜单里显示在游戏分类下,提升用户体验

打包命令:

# 开发时快速测试 npm run build && electron-builder --win --x64 --publish=never # 发布正式版 npm run build && electron-builder --win --mac --linux --publish=always

实测发现:electron-builder在 CI 环境下(如 GitHub Actions)会因缺少wine依赖无法构建 Windows 版本,解决方案是在 workflow 中添加setup-wine步骤。

4.4 VSCode 插件与 Electron 应用的协同调试

调试 Electron 应用不能只靠console.log,必须建立完整的调试链路。我们的方案:

  1. 主进程调试:在launch.json中配置:

    { "version": "0.2.0", "configurations": [ { "name": "Debug Main Process", "type": "node", "request": "launch", "cwd": "${workspaceFolder}", "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", "args": ["--inspect=5858", "."], "outputCapture": "std" } ] }
  2. 渲染进程调试:在src/main.tsBrowserWindow创建后添加:

    if (process.env.NODE_ENV === 'development') { win.webContents.openDevTools() // 启用远程调试 win.webContents.session.setProxy({ proxyRules: '127.0.0.1:9222' }) }
  3. VSCode 插件调试:用vscode-extension-tester运行 UI 测试,同时用debugger语句断点。关键技巧是把插件和 Electron 的@type-game/core库链接到同一份源码:

    # 在插件目录下 npm link ../core # 在 Electron 目录下 npm link ../core

这样修改核心算法时,两个环境都能实时生效。我们曾用这套调试链路发现一个隐藏 bug:VSCode 插件里performance.now()返回毫秒精度,但 Electron 里某些 Windows 机器返回微秒精度,导致 WPM 计算偏差 3%。解决方案是统一用Date.now()做时间基准。

5. 常见问题与排查技巧实录:踩过的坑比代码还多

5.1 “Cannot find module 'electron'” 的 5 种死法与解法

这是 Electron 新手第一大拦路虎,本质是模块解析路径错乱。我们整理了完整排查树:

现象根本原因解决方案
Uncaught Error: Cannot find module 'electron'在渲染进程nodeIntegration: true未开启,但代码里用了require('electron')错误方案:开启nodeIntegration正确方案:用contextBridge暴露所需 API
Module not found: Can't resolve 'electron'在 Vite 构建时报错Vite 把electron当作前端依赖打包,但 Electron 运行时已提供vite.config.ts中添加optimizeDeps.exclude: ['electron']
ReferenceError: require is not defined在渲染进程contextIsolation: truerequire被禁用必须通过preload.jscontextBridge暴露,不能直接require
Error: The module '/path/to/node_modules/electron/index.js' was compiled against a different Node.js versionelectron-rebuild未针对当前 Electron 版本执行运行npx electron-rebuild --version $(cat package.json | jq -r '.devDependencies.electron')
TypeError: Cannot read property 'remote' of undefinedElectron 12+ 移除了remote模块改用ipcRenderer.invoke+ipcMain.handle替代

最隐蔽的案例:我们在vite.config.ts里误写了build.rollupOptions.external = ['electron'],但忘了vite-plugin-electron会自动处理electron,结果导致主进程代码里import { app } from 'electron'报错。解决方案是移除external配置,让插件自动处理。

5.2 Vue 3 响应式失效的典型场景

Vue 3 的reactiveref有严格使用边界,打字游戏里高频出现:

场景问题代码正确写法原因
对象属性新增state.newProp = 'value'state.newProp = ref('value')Object.assign(state, { newProp: 'value' })reactive对象新增属性不会触发响应式
数组索引赋值arr[0] = 'new'arr.splice(0, 1, 'new')arr[0] = 'new'; arr = [...arr]直接索引赋值不触发settrap
解构响应式对象const { name } = reactive({ name: 'John' })const state = reactive({ name: 'John' }); const name = computed(() => state.name)解构会丢失响应式连接
Web Worker 中使用响应式const state = reactive({})在 Worker 里Worker 里不能用reactive,改用postMessage通信reactive依赖 Proxy,Worker 无 DOM 环境

我们曾因数组索引赋值导致错词标记不更新,调试了 3 小时才发现是errors[i] = true不触发视图更新。最终方案是把errors改成ref<number[]>([]),用errors.value.push(i)添加错误位置。

5.3 Electron 窗口在多显示器下的 DPI 适配

Windows 10/11 多显示器 DPI 缩放是 Electron 的经典噩梦。现象:主显示器 100% 缩放,副显示器 150% 缩放,窗口从主屏拖到副屏时,Canvas 文字突然放大 1.5 倍且模糊。根源是window.devicePixelRatio在窗口移动时不会自动更新。解决方案:

  1. 监听窗口移动事件:

    win.on('move', () => { // 获取当前屏幕的缩放因子 const screen = electron.screen.getDisplayMatching(win.getBounds()) const scaleFactor = screen.scaleFactor win.webContents.send('dpi-change', scaleFactor) })
  2. 在 Vue 组件中响应:

    // TypingCanvas.vue onMounted(() => { window.api.on('dpi-change', (scale: number) => { // 重新设置 Canvas 缩放 const canvas = canvasRef.value! canvas.width = canvas.clientWidth * scale canvas.height = canvas.clientHeight * scale const ctx = canvas.getContext('2d') ctx?.scale(scale, scale) }) })
  3. 更彻底的方案:在BrowserWindow创建时禁用缩放:

    new BrowserWindow({ webPreferences: { // 禁用自动缩放,由应用自行处理 disableHtmlFullscreenWindowResize: true, // 启用缩放控制 zoomFactor: 1.0 } })

5.4 VSCode 插件调试时“找不到模块”的

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询