ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Electron + Element Plus + Vite 桌面应用模版:IPC 通信与打包避坑实践

Electron + Element Plus + Vite 桌面应用模版:IPC 通信与打包避坑实践 手里积了不少 Electron 项目每次都从零搭环境、配打包、调 IPC 通信确实烦。后来索性花时间沉淀了一套 electron element-plus vite 的开发模版把主进程、渲染进程、UI 组件库、打包脚本全串在一起新项目直接复制改改名字就能开工。这篇就聊聊这个模版的设计思路、关键配置、IPC 通信实践以及我在实际项目中踩过的坑和沉淀下来的调试经验。1. 模版整体设计与架构思路1.1 为什么选这套技术栈组合先说结论electron element-plus vite 这套组合是目前做桌面端业务系统比较省心的方案。Electron 负责桌面容器和系统能力Vite 负责 renderer 侧的开发体验和构建速度Element Plus 补齐了中后台 UI 组件。我早先用过 electron-webpack 和 vue-cli-plugin-electron-builder最大的痛点是冷启动慢改个样式要等好几秒甚至十几秒。Vite 基于 esbuild 预构建依赖renderer 侧几乎秒开再加上 electron 主进程用 esbuild 单独打包整体开发体验提升确实明显。对于业务型桌面应用来说这套组合的投入产出比很高。1.2 目录结构与进程边界划分模版我采用了前后端分离的目录组织方式虽然 Electron 项目不强制但清晰的结构对后续维护至关重要electron-element-vite-template/ ├── electron/ │ ├── main/ # 主进程代码 │ │ ├── index.ts │ │ └── ipc.ts │ └── preload/ # 预加载脚本 │ └── index.ts ├── src/ # 渲染进程Vue3 应用 │ ├── api/ # 与主进程通信的封装 │ ├── components/ │ ├── router/ │ ├── stores/ │ ├── views/ │ └── main.ts ├── electron-builder.yml # 打包配置 └── vite.config.ts主进程和渲染进程边界明确preload 作为唯一的桥接层暴露能力。这样设计的好处是渲染进程永远不会直接接触 Node.js API安全性和可维护性都更好。2. 环境搭建与核心配置细节2.1 先从 Vite 配置说起很多同学直接把 Vite 配成纯 Web 项目的方式结果在 Electron 里跑起来各种问题。模版里我做了两个关键处理// vite.config.ts import { defineConfig } from vite import vue from vitejs/plugin-vue import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()], }), Components({ resolvers: [ElementPlusResolver()], }), ], base: ./, // 关键打包后资源使用相对路径 server: { port: 5173, strictPort: true, watch: { ignored: [**/electron/**], // 避免主进程文件改动触发 renderer 刷新 }, }, })这里base: ./特别重要如果不设置Vite 默认资源路径为/assets/xxx在 Electron 的file://协议下会直接找不到资源导致白屏。这个问题我见过很多人踩过包括我自己换成相对路径后一下子就正常了。2.2 主进程与渲染进程联动的启动逻辑开发环境下主进程需要知道 renderer 的 dev server 地址生产环境则要加载本地文件。模版里做了一个兼容处理// electron/main/index.ts import { app, BrowserWindow } from electron import path from path const isDev !app.isPackaged function createWindow() { const win new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: path.join(__dirname, ../preload/index.js), contextIsolation: true, nodeIntegration: false, }, }) if (isDev) { win.loadURL(process.env.VITE_DEV_SERVER_URL || http://localhost:5173) win.webContents.openDevTools() } else { win.loadFile(path.join(__dirname, ../../dist/index.html)) } } app.whenReady().then(createWindow)开发时通过VITE_DEV_SERVER_URL加载 dev server生产时加载打包后的dist/index.html。为了在 npm script 里串起这个流程我用concurrently同时启动 Vite 和主进程再用wait-on等 dev server 就绪后才启动 Electron{ scripts: { dev: concurrently -k \vite\ \wait-on tcp:5173 cross-env VITE_DEV_SERVER_URLhttp://localhost:5173 electron .\ } }2.3 Element Plus 按需引入与自动导入Element Plus 全量引入会让 renderer 包体达到数百 KB所以我用了unplugin-auto-import和unplugin-vue-components搭配官方 resolver 实现按需加载。实际用下来自动导入最爽的地方是不用写import { ElMessage } from element-plus了。在 store、工具函数里直接调用ElMessage.success(xxx)插件会自动帮你引入对应的样式和模块。按需引入后首屏 JS 体积能减少 30% 以上对于桌面应用来说虽然本地加载速度快但减少不必要的解析也有益。3. 主进程与渲染进程通信IPC 的全套实践3.1 contextIsolation 与 preload 的正确姿势IPC 通信是 Electron 应用的核心也是最容易踩坑的地方。早期教程喜欢在 renderer 里直接require(electron)或开启nodeIntegration: true这在安全性和架构上都有问题。模版里我将contextIsolation设为truenodeIntegration设为false通过 preload 暴露白名单 API 给 renderer。// electron/preload/index.ts import { contextBridge, ipcRenderer } from electron const api { getAppVersion: () ipcRenderer.invoke(app:getVersion), readFile: (path: string) ipcRenderer.invoke(file:read, path), writeFile: (path: string, content: string) ipcRenderer.invoke(file:write, path, content), onMessage: (callback: (payload: any) void) { const handler (_event: Electron.IpcRendererEvent, ...args: any[]) callback(...args) ipcRenderer.on(main:message, handler) return () ipcRenderer.removeListener(main:message, handler) } } contextBridge.exposeInMainWorld(electronAPI, api)在 renderer 侧我封装成了统一的模块避免每个组件里直接操作window.electronAPI// src/api/index.ts export const getAppVersion () window.electronAPI.getAppVersion() export const readFile (filePath: string) window.electronAPI.readFile(filePath)3.2 主进程侧的统一注册机制主进程用ipcMain.handle统一注册业务逻辑按模块组织避免一个文件堆到上千行// electron/main/ipc.ts import { ipcMain, app, dialog } from electron import { readFile, writeFile } from fs/promises export function registerIpcHandlers() { ipcMain.handle(app:getVersion, () app.getVersion()) ipcMain.handle(file:read, async (_event, filePath: string) { try { return await readFile(filePath, utf-8) } catch (err) { dialog.showErrorBox(读取失败, (err as Error).message) return null } }) ipcMain.handle(file:write, async (_event, filePath: string, content: string) { try { await writeFile(filePath, content, utf-8) return { ok: true } } catch (err) { dialog.showErrorBox(写入失败, (err as Error).message) return { ok: false, error: (err as Error).message } } }) }用invoke/handle的模式是异步 Promise 风格比send/on的异步回调风格更契合现代 JS 的写法出错时也能直接抛异常。3.3 一个典型的双向通信场景基于事件广播的消息推送除了 invoke 这种请求/响应模式业务里常需要主进程主动推送消息比如下载进度、系统状态变化。模版里我实现了一个简单的广播模式// electron/main/broadcast.ts import { BrowserWindow, ipcMain } from electron export function broadcast(channel: string, payload: unknown) { const windows BrowserWindow.getAllWindows() windows.forEach((win) { win.webContents.send(channel, payload) }) } // 使用方 ipcMain.handle(task:start, async (event, taskId: string) { // 模拟耗时任务 for (let i 1; i 10; i) { await delay(1000) broadcast(task:progress, { taskId, progress: i * 10 }) } return { ok: true } })这里面我写了一个onMessage的辅助函数在 preload 层它返回一个取消订阅的函数这样在 Vue 组件的onUnmounted里就可以干净地解除监听避免组件销毁后回调还在的隐患。4. 打包配置与常见问题处理4.1 electron-builder 配置实战打包我用 electron-builder它的配置入口是electron-builder.yml。模版里的核心配置如下appId: com.example.template productName: TemplateApp directories: output: release files: - dist/** - electron/** asar: true win: target: - nsis nsis: oneClick: false allowToChangeInstallationDirectory: true mac: target: - dmg category: public.app-category.developer-tools这里有几个容易忽略的配置点files要同时包含dist和electron少了electron目录会导致应用启动后找不到主进程文件。asar: true是将代码打包进 asar 归档提高安全性同时减少文件数量。但记住fs读取 asar 内文件路径需要特殊处理一般可打包进resources。对于主进程有原生依赖的场景需要在build配置里注明npmRebuild: true。4.2 白屏问题的排查套路Electron 打包后白屏是我被问过最多的问题。白屏大部分情况下就三类原因打包后资源路径错误因为base没设置成./或者组件里写了绝对路径/xxx。路由模式问题Vue Router 如果使用createWebHistory在file://协议下会失效必须换成createHashHistory或createWebHashHistory。预加载脚本报错preload路径写错导致加载失败在 DevTools 的 Console 看到Unable to load preload script。还有一个很隐蔽的问题是主进程生产环境加载dist/index.html时用了错误的相对路径。我习惯用path.join(__dirname, ../../dist/index.html)来计算因为主进程打包后位于dist-electron或out目录下层级不同路径就不同需要根据实际输出目录来校准。4.3 体积优化与依赖处理Electron 打包出来的体积通常都很可观优化空间主要在于这几方面不要将所有 devDependencies 塞进生产依赖。electron-builder 打包时会分析dependencies如果全量打包node_modules 体积会爆炸。原生的 Node 模块需要重新编译以匹配 Electron 的 ABI常用工具是electron-rebuild。如果遇到NODE_MODULE_VERSION不匹配的报错基本就是原生依赖没 rebuild。对于纯静态资源的引用尽量用import而不是fs.readFileSync这样能被 Vite 处理走构建管道。5. 常见报错与问题排查实录5.1 Vite 不识别 buffer / Node 原生模块许多人在 renderer 里用Buffer或process结果报Buffer is not defined或process is not defined。这是因为 Electron 安全模式下 renderer 没有 Node 环境且 Vite 默认的 browser 构建不提供 Node 全局变量。我的处理方式是把这些操作全部挪到主进程完成renderer 只传数据过去。比如文件接口renderer 给主进程传路径和内容一切读写都在主进程。如果某些第三方库在浏览器端引用了 Node 的全局变量可以在 Vite 配置里加define来兜底但这是最后一个手段最好是通过 alias 或使用替代库解决。5.2 局域网打开 Vite 项目空白开发时如果有同学在局域网里用真机或虚拟机访问 Vite 地址发现页面空白。这通常是因为 Vite dev server 绑定的 host 是localhost。我一般在vite.config.ts里将server.host设为true让 dev server 监听所有网络接口server: { host: true, port: 5173, strictPort: true, }5.3 菜单栏与系统托盘Electron 应用默认有菜单栏业务系统经常想自定义。模版里我做了一版系统托盘加菜单的示例主要逻辑在electron/main/menu.ts和tray.ts中。托盘和菜单里可以发 IPC 事件给 renderer联动触发业务这对后台管理类应用挺实用。实际用下来托盘点击事件在 Windows 和 macOS 的行为差异比较大需要在click事件里区分平台做不同处理比如 Windows 上左键弹出菜单、macOS 上左键点击展示面板。5.4 应用的体积控制技巧打包后体积太大很多同学直接放弃优化。我实测了几个方向的收益electron-builder的compression设置为maximum只影响安装包压缩能减小 10%~20% 的体积。去掉electron-builder在extraResources中多余的二进制文件以及更新无用语言包。如果应用仅面向 Windows可以只配置wintarget避免生成 mac 相关目录。用webpack-bundle-analyzer检查 renderer 的包里有没有意外引入大型依赖Element Plus 按需引入后这块一般比较干净。6. 开发与调试过程中的独家技巧6.1 开发时调试主进程主进程的调试我常用的方案是用 VS Code 的 launch 配置{ type: node, request: launch, name: Debug Main Process, runtimeExecutable: ${workspaceFolder}/node_modules/.bin/electron, args: [.], env: { VITE_DEV_SERVER_URL: http://localhost:5173 } }配合 Vite dev server 先启动然后在主进程的 ts 文件里打断点就能直接调试。另外也可以在启动时加--inspect9222参数用 Chrome DevTools 远程调试主进程。6.2 渲染进程的 Vue DevToolsElectron 里搞 Vue DevTools 比较麻烦开发环境下我用的是浏览器开发模式直接调试生产环境需要时在devtools里单独安装扩展。Vite 给你 renderer 的 HMR 体验是浏览器一模一样所以大部分开发工作我其实是在浏览器里完成的遇到系统 API 再到 Electron 里验证。6.3 日志规范模版里集成了一个简单的日志工具主进程写入app.getPath(userData)/logs目录renderer 的 console 输出也通过 IPC 转发到主进程记入文件。这个习惯帮我在排查用户环境问题时省了大量时间。日志格式我统一用 JSON 输出带上时间戳和上下文信息方便后续用工具分析。6.4 多窗口场景的坑多窗口业务场景下最常踩的坑是window.open新窗口后新窗口没有 preload或者主进程创建窗口时忘了设置同样的配置。模版里我创建了一个createWindow方法支持传参配置不同窗口所有窗口统一复用同样的 webPreferences 配置避免行为不一致。另一个坑是如果想用原生的window.open打开子窗口一定要在主进程里拦截setWindowOpenHandler并接管创建逻辑否则子窗口会继承默认配置可能绕开 contextIsolation 设置。7. 深入理解 Electron 菜单、通信与安全性7.1 自定义菜单与快捷键菜单在桌面应用里比 Web 更有存在感。模版示例里我实现了常见菜单和快捷键的处理// electron/main/menu.ts import { Menu, app } from electron export function setupMenu() { const template [ { label: 文件, submenu: [ { label: 打开..., accelerator: CmdOrCtrlO, click: () openFileDialog() }, { type: separator }, { label: 退出, accelerator: CmdOrCtrlQ, click: () app.quit() }, ], }, { label: 窗口, submenu: [ { role: minimize }, { role: togglefullscreen }, ], }, ] const menu Menu.buildFromTemplate(template) Menu.setApplicationMenu(menu) }自定义菜单可以避免默认菜单带来的一些奇怪行为也能自定义快捷键比如将 CtrlShiftI 的开发者工具快捷键只开放给开发环境。7.2 安全的 preload 设计preload 里暴露 API 时建议遵循最小暴露原则不要直接暴露ipcRenderer本身而是暴露封装好的操作方法。这样即便 renderer 被注入恶意代码也无法任意调用系统能力。另外对于来自网络加载内容的窗口preload 可以考虑完全不加载只对本地加载内容暴露能力。7.3 和 Vue 的关系通信是进程职责不是组件职责很多同学困惑 Electron IPC 通信和 Vue 有没有关系。从架构上说IPC 通信是两个进程之间的事情Vue 只是 renderer 侧的 UI 框架。但它们在实践中必然产生联系因为业务状态通常存在于 Vue 应用里而系统能力在主进程里。我通常会在 renderer 侧做一个bridge模块封装所有 IPC 调用然后再在 Pinia store 或组件里调用这个 bridge将通信逻辑与 UI 逻辑分离。这样做的好处是UI 组件写起来就像调用业务函数一样完全感知不到底层是 Electron 的 IPC。这也方便以后如果做 Web 版本只需要替换 bridge 的实现。8. 模版的未来扩展方向8.1 支持自动更新桌面应用不做自动更新用户端爆 bug 就只能手动重装。electron-updater 配合 electron-builder 可以比较方便地接入自动更新我一般配上私有服务器或 GitHub Release 做源启动时检查更新。8.2 多平台打包与 CI/CD 集成模版里我已经把打包脚本整理成build:win、build:mac、build:linux三个命令。进一步可以接 GitHub Actions 或 GitLab CI推 tag 后自动构建三个平台的安装包省去本地交叉编译的麻烦。8.3 与鸿蒙系统的关联近期有不少同学关注 Electron 应用跨平台跑的问题尤其是国产系统生态的适配。Electron 应用移植到鸿蒙是一个热门方向。这个方向我认为可以关注官方工具链的演进但短期内不必大动架构关键是保持业务逻辑与进程通信层的清晰便于以后做迁移。模版的目录设计已经为这种切换预埋了基础主进程、preload、renderer 三层分离只要业务代码不直接依赖 Electron 特性换壳时多改配置少改逻辑。在实际开发中我最深的一个体会是与其到处抄碎片化的示例代码不如花半天时间把模版的骨架打好。IPC 通信规范、打包配置、目录边界这些一旦固定下来后续每接一个业务都会顺很多。这套 electron element-plus vite 模版我已经在几个生产项目里用过从动态表单工具到本地数据处理客户端都跑得比较稳。你如果也在搭类似的桌面端项目建议先把基础环境跑通再逐步替换成自己的业务代码能少走不少弯路。最后再分享一个小技巧模板里的electron/main/ipc.ts设计成模块注册制新业务直接新建对应的 ipc handler 文件然后在注册表里挂上去就行不用改入口文件这个习惯帮我保持了不少项目的整洁度。
返回列表