做桌面端应用Electron 基本上是目前绕不开的选项。但真正动手搭一套可用的 Electron Vue3 工程新手老手都得掉几层头发。主进程、渲染进程、预加载脚本、打包配置、开发热更新每一环都有坑。我这次整理了一套 electron element-plus vite 的开发模版把工程化轨道铺平让项目起步就能直接写业务代码而不是先跟环境搏斗三天。这套模板适合谁准备入坑 Electron 的 Vue 开发者、需要快速给团队搭桌面端基座的架构同事、以及想把 Vite 开发体验延伸到桌面场景的人。它解决的核心问题是electron 主进程与渲染进程的通信链路、Element Plus 在 Electron 环境下的按需加载、Vite 开发态和 electron-builder 打包态的配置切换。下面整个过程拆开细聊包括我在实际搭建中踩过的坑和验证过的稳定方案。1. 内容整体设计与思路拆解1.1 为什么是 Electron Element Plus Vite 这套组合先聊技术选型。桌面端框架其实有小众选项比如 Tauri、Neutralino但 Electron 的生态成熟度依然是最高的。团队里如果已经有 Vue 开发经验Electron 的上手成本就集中在主进程、IPC 和打包这几个点上而不是从零学一套新语法。Element Plus 则解决了中后台界面的痛点表格、表单、对话框、上传组件开箱即用Vite 负责把开发体验拉到秒级启动。我把这套模板的定位想清楚它不是一个 Demo 演示仓库而是一个开箱即用的业务起点。里面包含完整的进程模块划分、统一的主进程入口、预加载脚本、IPC 通信封装、Vite 插件配置、electron-builder 打包配置。目标是一个新项目 clone 后改改 package.json 里的应用名就能开始写业务页面。1.2 模板要解决的核心痛点Electron 项目最常见的痛点不是 Electron 本身而是工具链的割裂。纯 Electron 官方模板默认用 CommonJS、没有热更新对 Vue 单文件组件支持一般。Vite 默认面向 Web 场景直接用它跑 Electron 会遇到两个关键问题一是 Vite dev server 的页面加载到 Electron 窗口时跨域、路径、资源加载都可能出问题二是打包后的文件路径如果直接按 Web 项目处理Electron 加载本地 file 协议时白屏概率很高。这套模板的底层设计逻辑就是弥合这条缝。开发态用 Vite dev server生产态用 Vite build 产物但中间靠ELECTRON_RENDERER_URL这类环境变量区分两种模式。主进程代码单独处理避免被打进渲染进程的 bundle。预加载脚本单独构建确保它能在 Node 和浏览器混合环境下安全运行。1.3 方案选型背后的对比我在搭模板之前对比了三种方式直接用electron-vite脚手架、手动配置 Vite Electron、使用vite-plugin-electron插件。最终选的是 vite-plugin-electron 这套思路但做了自己的封装。electron-vite很成熟但它把渲染进程、主进程、预加载脚本的构建全接管了配置自由度下降。手动配置 Vite Electron 的思路过于原始需要自己处理主进程的tsc编译、开发时的重启逻辑模板的维护成本都在自己身上。vite-plugin-electron 这种插件路由方案的好处是配置收敛在vite.config.ts一个文件里渲染进程和主进程的依赖关系清晰开发时vite dev一个命令同时拉起两套环境。注意选插件方案不是因为它最潮而是它和 Vite 的生命周期贴合度最高。模板在实际使用中不需要频繁修改构建配置这是最重要的一点。2. 核心细节解析与实操要点2.1 模板目录结构与职责划分目录是模板的地基我采用的分层方式是这样的electron-template/ ├── electron/ │ ├── main/ │ │ └── index.ts # 主进程入口 │ ├── preload/ │ │ └── index.ts # 预加载脚本 │ └── shared/ │ └── ipc-channels.ts # IPC 通道名常量 ├── src/ │ ├── api/ # 渲染进程 API 封装 │ ├── assets/ # 静态资源 │ ├── components/ # 公共组件 │ ├── router/ # 路由配置 │ ├── stores/ # Pinia 状态管理 │ ├── views/ # 页面组件 │ ├── App.vue │ └── main.ts # 渲染进程入口 ├── index.html ├── package.json ├── tsconfig.json └── vite.config.ts这套结构的核心是 electron 目录和 src 目录的严格分离。主进程代码不能和渲染进程代码混在一起否则构建时容易把 Node 内置模块打进渲染进程 bundle运行时一堆require is not defined的错误。shared 目录存放 IPC 通道名常量这样主进程和渲染进程引用同一个字符串值不会因为手写拼错而出现一对默默失败的消息。预加载脚本的结构也很关键。它运行在 renderer 和 main 之间的隔离层通过 contextBridge 暴露 API。我这里把预加载脚本独立成一个文件而不是塞进主进程代码里是因为 Electron 的沙箱机制下预加载脚本会在独立的上下文执行混在一起会出现contextBridgeundefined。2.2 Vite 配置的核心参数Vite 配置是整套模板最容易翻车的地方。我先放核心代码片段再逐行解析为什么这么写。import { defineConfig } from vite import vue from vitejs/plugin-vue import electron from vite-plugin-electron/simple import renderer from vite-plugin-electron-renderer import { resolve } from path export default defineConfig({ base: ./, resolve: { alias: { : resolve(__dirname, src) } }, plugins: [ vue(), electron({ main: { entry: electron/main/index.ts, onstart(args) { // 开发时用 electron 启动入口文件 args.startup() }, vite: { build: { outDir: dist-electron/main, rollupOptions: { external: [electron] } } } }, preload: { input: electron/preload/index.ts, onstart(args) { args.reload() }, vite: { build: { outDir: dist-electron/preload, rollupOptions: { external: [electron] } } } } }), renderer() ], server: { port: 5173, strictPort: true }, build: { outDir: dist-renderer, emptyOutDir: false } })首先base: ./是必须的。Electron 生产环境下通过 file 协议加载本地文件如果 base 保持默认的/资源路径会变成file:///assets/...这种错误指向。设成相对路径后构建出的 index.html 里引用资源的路径都是以./开头的打包后能正常加载。electron 插件的 main 和 preload 块分别配置入口和输出。开发时args.startup()会自动拉起 Electron 进程加载 dev server改动主进程代码时也会自动重启。preload 块里的args.reload()是只刷新 renderer 窗口不需要重启主进程这个区分在实际开发中体验差别很大。renderer 插件用来处理 Node 相关模块在渲染进程的兼容问题。比如window.require、Buffer这类全局对象如果不用这个插件渲染进程访问会直接抛Buffer is not defined。2.3 主进程入口的设计主进程入口是模板的心脏。它负责创建窗口、处理生命周期、注册 IPC 通信、管理菜单。我贴一段核心的窗口创建逻辑import { app, BrowserWindow, Menu } from electron import { join } from path const isDev process.env.ELECTRON_RENDERER_URL function createWindow() { const win new BrowserWindow({ width: 1200, height: 800, minWidth: 900, minHeight: 600, show: false, autoHideMenuBar: true, webPreferences: { preload: join(__dirname, ../preload/index.js), sandbox: true, contextIsolation: true, nodeIntegration: false } }) if (isDev) { win.loadURL(process.env.ELECTRON_RENDERER_URL) } else { win.loadFile(join(__dirname, ../dist-renderer/index.html)) } win.on(ready-to-show, () { win.show() }) } app.whenReady().then(() { createWindow() // 这里注册 IPC 处理器 registerIpcHandlers() })这里的关键参数是contextIsolation: true和nodeIntegration: false。很多人图省事把 nodeIntegration 打开渲染进程里直接能用 Node但这样会把安全模型彻底破坏任何 XSS 漏洞都可能直接升级成远程代码执行。把 nodeIntegration 关掉后渲染进程只能通过 preload 暴露的白名单 API 和主进程通信。show: false加ready-to-show的配合能避免窗口白屏闪烁。如果直接 show 窗口渲染进程资源还没加载完用户会看到一瞬间的白色闪屏。这个设置虽然小但对应用的第一印象影响很大。窗口创建完成后菜单这块模板也做了处理。autoHideMenuBar在 Windows 上会隐藏默认菜单栏但快捷键仍然有效。如果业务需要自定义菜单可以在Menu.buildFromTemplate里配置比如给一个关于菜单或者开发者工具菜单。2.4 预加载脚本与 contextBridge预加载脚本是主进程和渲染进程之间的安全桥梁。它运行在隔离的上下文里通过contextBridge向页面暴露 API。我的模板里统一了 IPC 调用的入口import { contextBridge, ipcRenderer } from electron const api { send: (channel: string, data?: any) { const validChannels [app:minimize, app:maximize, app:close] if (validChannels.includes(channel)) { ipcRenderer.send(channel, data) } }, invoke: (channel: string, data?: any) { const validChannels [file:read, file:write, system:info] if (validChannels.includes(channel)) { return ipcRenderer.invoke(channel, data) } }, on: (channel: string, callback: (event: any, data: any) void) { const validChannels [update:progress] if (validChannels.includes(channel)) { ipcRenderer.on(channel, (event, data) callback(event, data)) } } } contextBridge.exposeInMainWorld(electronAPI, api)这里做了通道白名单校验。没有白名单的话渲染进程拿到的是一个可以往任意 channel 发消息的对象一旦有 XSS 漏洞攻击者可以直接往主进程的所有 listener 上发数据风险极高。白名单虽然只多了一行判断但安全边界立刻清晰了。渲染进程里使用window.electronAPI时TypeScript 类型也需要同步声明。我在模板的src/types/global.d.ts里定义了这个全局对象的结构保证开发时有类型提示也不会因为拼写错误而调用不存在的 API。3. 实操过程与核心环节实现3.1 IPC 通信全链路示例IPC进程间通信是 Electron 应用的核心机制。主进程和渲染进程通过ipcMain与ipcRenderer收发消息配合 preload 暴露的 API形成了一套完整的通信链路。我以一个读取系统信息的例子来演示整个链路如何打通。主进程侧// electron/main/index.ts import { ipcMain, app } from electron ipcMain.handle(system:info, async (event) { return { platform: process.platform, version: process.version, electron: process.versions.electron, appVersion: app.getVersion() } })渲染进程侧// src/api/system.ts export async function getSystemInfo() { const info await window.electronAPI.invoke(system:info) return info }在 Vue 组件里调用script setup langts import { ref, onMounted } from vue import { getSystemInfo } from ../api/system const systemInfo refany(null) onMounted(async () { systemInfo.value await getSystemInfo() }) /script这套链路的时序是渲染进程调用window.electronAPI.invoke(system:info)preload 层校验通道名后通过 ipcRenderer.invoke 发送到主进程主进程的 ipcMain.handle 处理请求并返回结果Promise 一路 resolve 回渲染进程。这里要注意的是通道名的一致性问题。我在模板里用了 shared 目录下的常量文件主进程和渲染进程都从同一个源引用。没有这个约束代码里到处写裸字符串重构时一个地方没同步改就是线上事故。3.2 Element Plus 按需引入与主题定制Element Plus 如果全量引入打包后的 renderer bundle 会非常臃肿。模板里采用按需引入的方式搭配 unplugin-vue-components 和 unplugin-auto-import 两个插件组件和 API 都能自动导入。对应的 vite 配置扩展import Components from unplugin-vue-components/vite import AutoImport from unplugin-auto-import/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers // plugins 里追加 Components({ resolvers: [ElementPlusResolver()] }), AutoImport({ imports: [vue, vue-router, pinia], resolvers: [ElementPlusResolver()] })配置之后在组件里可以直接写el-button不需要显式 importElMessage这类 API 也能直接使用选项式 API 里的reactive、ref、computed全都不用手动导入。代码清爽很多构建时按需自动引入对应的 CSS 和 JS体积大幅下降。主题定制这块Element Plus 用 SCSS 变量覆盖的方式最直接。模板里在 src/styles 目录下放了element-variables.scss通过use引入后修改主色变量配合 vite 的 css.preprocessorOptions 配置实现主题替换。3.3 路由与状态管理的集成Electron 应用的路由和 Web 应用有个区别不能用 history 模式必须用 hash 模式。原因是生产环境下加载的是 file:// 协议history 模式的路由刷新后会找不到对应的路径。我在这套模板里用的是createHashHistory绕开了这个问题。import { createRouter, createWebHashHistory } from vue-router const router createRouter({ history: createWebHashHistory(), routes: [ { path: /, component: () import(../views/Home.vue) }, { path: /settings, component: () import(../views/Settings.vue) } ] })状态管理用的 Pinia。Electron 的应用窗口生命周期比较长状态管理的持久化需求比较常见所以我建议把 pinia-plugin-persistedstate 也集成进去默认持久化到 localStorage。这样用户偏好配置、窗口状态这类数据重开应用后能自动恢复。3.4 开发调试与热更新验证这套模板跑起来后验证链路是否正常就靠三个现象主进程控制台没有报错、Vite dev server 里改 Vue 代码能秒级热更新、preload 里改代码后窗口自动重载。实际操作中electron 主进程的日志会输出到启动命令的终端里渲染进程的 console.log 在 DevTools 里。开发时默认加了打开 DevTools 的快捷键F12 可以直接调出调试面板。跨端的调试信息通过 IPC 穿插排查问题时先看哪端报错、报错类型是什么再决定是查主进程还是渲染进程。4. 打包与发布的关键配置4.1 electron-builder 的配置解析打包是 Electron 项目的最后一道大坑。模板选用 electron-builder配置写在 package.json 的 build 字段里。核心配置项{ build: { appId: com.example.electron-template, productName: ElectronTemplate, directories: { output: release }, files: [ dist-renderer/**/*, dist-electron/**/* ], win: { target: nsis, icon: build/icon.ico }, mac: { target: dmg, category: public.app-category.developer-tools }, linux: { target: AppImage, category: Development } } }files 数组指定了哪些目录会被打进安装包。这里如果没有包含 dist-electron 目录主进程代码直接缺失应用根本起不来。我见过太多人打包后报Cannot find module错误十有八九就是 files 配置漏了。Windows 平台用 NSIS 作为安装包目标优点是体积小、支持自定义安装界面、支持增量更新。macOS 用 dmgLinux 用 AppImage。这套组合基本覆盖了三大平台的常规分发需求。4.2 打包体积优化Electron 应用的体积天生就大因为内置了整个 Chromium。electron-builder 提供了一些压缩选项模板里通过compression: maximum让安装包进一步压缩。另一个优化点是 asar 归档。asar 把应用代码打包成一个归档文件既减少了文件数量也能防止源码直接裸露。electron-builder 默认开启 asar但在某些情况下如果资源文件需要被外部程序读取可以在 asarUnpack 里排除。例如读取外部动态链接库的场景asarUnpack: [ resources/** ]初次打包完检查一下 release 目录里的文件大小如果超出预期排查方向只有两个一是 node_modules 里有没有被误打包的依赖二是静态资源有没有体积异常大的文件。4.3 多平台构建注意在 macOS 上打包 Windows 版安装包和跨平台交付相关的坑很多。Electron 的 native 模块在不同平台上需要重新编译所以模板里没有引入任何需要编译的原生模块。如果业务确实需要比如涉及到系统对话框增强之类的功能一定要处理 electron-rebuild 的流程。注意模板默认用 Electron 的 App Store 兼容方案不可行。如果应用要上 Mac App Store需要单独处理 sandbox、entitlements 这些配置这不是通用模板的免费范畴别等到提审的时候才发现一堆签名问题。CI 里构建可以配合 GitHub Actions 的 matrix 策略分别跑 windows-latest、macos-latest、ubuntu-latest 三个 runner每个 runner 只构建自己平台的安装包避免交叉编译的坑。5. 常见问题与排查技巧实录5.1 打包后白屏与资源路径错误这是 Electron 项目遇到率最高的一个问题。开发环境一切正常打包出来双击启动窗口直接白屏控制台报错一堆net::ERR_FILE_NOT_FOUND。排查路径只有一条先看dist-renderer/index.html里的资源引用路径。如果 script 标签的 src 是/assets/index.js这种绝对路径就是 vite 的 base 没配置。模板里已经设成./如果还白屏检查 build.outDir 和 electron 主进程里loadFile的路径是否一致。一个容易忽略的细节是emptyOutDir: false这个配置不能让 vite build 时把 dist-electron 目录清掉。不加这个配置vite 默认清空 outDir 的上层目录会把 electron 的主进程产物连带删掉打包出的应用缺主进程文件必然白屏。这个坑非常隐蔽因为 dev 模式完全正常只有 build 后才会暴露。5.2 渲染进程不识别 Buffer 等 Node 全局变量Vite 面向浏览器场景默认不提供 Buffer。但 Electron 渲染进程有时确实需要 Buffer 处理数据比如读取文件的内容。直接使用 Buffer 会报Buffer is not defined这就是开头说的 vite-plugin-electron-renderer 插件处理的场景之一。如果业务代码真的需要 Buffer模板里建议的解决方案是不要直接在渲染进程里用 Node API而是把文件读写交给主进程渲染进程只接收序列化后的结果。这符合 Electron 的安全模型也避免了 Buffer polyfill 带来的兼容问题。如果你必须用 Buffer 类的库装buffer包并配置resolve.alias和define全局变量。但这条路径会带来额外的兼容性考虑实际上还是建议走主进程处理。5.3 局域网访问页面空白Vite dev server 默认只监听 localhost如果要在局域网里调试 Electron 应用会遇到另一个问题其他设备访问白屏或者拒绝连接。更常见的是在开发时把 Vite 的 host 设成0.0.0.0但 Electron 加载的地址是localhost导致 Electron 本身无法访问 dev server。处理方式是在 vite 配置里把 server.host 设成0.0.0.0然后在 Electron 主进程里判断 dev 模式时读取环境变量加载真实的局域网 IP。但这里还有个配套问题跨域访问 dev server 会出现 CORS 报错需要设置server.cors: true或者在 dev server 上加上对应的响应头。移动端调试反向影响的情况也类似热词里提到的局域网打开空白多半就是上面这些组合问题而不是单一原因。排查时先确认是否能在宿主机的浏览器里正常访问再回到 Electron 里面试。5.4 IPC 消息丢失与重复触发IPC 通信看似简单但有几个隐蔽的坑。第一种是通道名拼写不一致渲染进程发到file:read主进程监听file:read带了个尾随空格消息就静默丢失了。第二种是ipcRenderer.on在组件销毁时没有移除监听器导致同一个回调被多次触发页面出现数据重复、弹窗多开。模板的on方法封装里建议额外提供一个off方法并在 Vue 组件的onUnmounted里调用。这样能避免大部分内存泄漏和重复触发问题。还有一种场景是渲染进程在 preload 还没加载完成时就开始调 API这时 window.electronAPI 还是 undefined。处理办法是初始化代码放在app.mount之后或者在 preload 里用process.once(loaded)做一次就绪通知。5.5 常见问题速查表现象可能原因排查方向开发正常打包后白屏vite base 路径、emptyOutDir、loadFile 路径检查 dist-renderer/index.html 资源路径渲染进程报 require 未定义nodeIntegration 关闭但代码里用了 require改用 preload 暴露的 APIIPC 消息无响应通道名不一致、ipcMain 未注册检查 shared 常量确认处理器已注册点击关闭按钮后进程残留主进程没有调用 app.quit() 的完整流程检查 window-all-closed 事件处理打包提示文件被占用前一次启动的 electron 实例还没退出任务管理器结束 electron 进程后重试应用启动非常慢渲染进程加载了过多重资源排查路由懒加载、组件按需引入6. 模板的使用方式与扩展方向6.1 快速启动一个业务项目这个模板不是看一遍就行的实际用起来才是真体验。我把启动流程简化到四步# 1. clone 模板代码 git clone 仓库地址 my-desktop-app # 2. 安装依赖 npm install # 3. 启动开发环境 npm run dev # 4. 构建安装包 npm run buildnpm install 阶段如果网络不好electron 二进制下载容易失败建议配置 npm 镜像或者手动设置 ELECTRON_MIRROR 环境变量。这一步是最容易卡住新人的很多项目卡在装依赖连代码都没看到。第一次跑npm run dev时Vite 启动到 Electron 窗口弹出整个过程应该在 3 秒左右。如果超过 10 秒检查一下是不是 node_modules 里有包体积异常、或者加载了太多插件。开发体验流畅是这套模板的底线。6.2 后续扩展方向模板的业务功能可以往几个方向扩展。第一是自动更新electron-updater 配合 electron-builder 的 publish 配置可以实现安装包自动升级。第二是系统托盘Tray 本身是 Electron 的主进程能力联动窗口显示隐藏。第三是多窗口场景比如主窗口加独立设置的子窗口用 BrowserWindow 的 parent 和 modal 属性管理关系。我最近也在关注 Electron 应用向其他平台移植的方向比如鸿蒙这类新的桌面生态。Electron 应用移植到鸿蒙是个热门话题核心思路是把主进程的 Node 能力替换成鸿蒙的 API 能力、渲染层保留 Web 技术栈、IPC 通信换一套桥接方案。模板里已经把主进程和渲染进程严格分层这为后续移植省了很多事。不过这个话题展开又是一篇长文这里先记个想法。模板里主进程与渲染进程的通信链路和 Vue 业务代码是解耦的改造 IPC 层的时候不需要动页面这是当初架构设计时特意保留的扩展口。提示迁移类改造一定要保证 IPC 通道的抽象层足够稳定。跨平台迁移时业务页面基本可以保留但 IPC 层几乎必须重写。所以模板里的 IPC 调用全部收敛在 api 目录页面里不出现裸的 window.electronAPI 调用这是一个重要的约束。经验收尾模板搭建过程中我印象最深的一个坑是emptyOutDir配置和 Buffer 报错这两个问题叠加出现。第一次打包改好了 base 路径主进程产物又被 vite build 清了排查了很久才意识到是两个配置在互相干扰。这类问题的共性是它不会在开发时暴露只有套餐后才报错而且报错信息往往不指向根因。现在我布新项目时的习惯是先跑一次npm run build用生成的安装包在当前机器上装一遍确认主进程、渲染进程、preload 三个部分在产物环境都正常再开始写业务代码。这一步前置检查成本很低但能避开后续 80% 的构建问题。模板仓库里我也放了 build:dir 命令只生成未压缩的目录结构方便快速排查。这里再分享一个团队协作的建议Electron 模板是典型的用起来越顺手、维护时越反胃的项目类型。建议在docs/目录里维护主进程、IPC、preload 三者的接口说明新成员入职时先看这份文档再上手改代码。IPC 白名单的通道列表也应该在文档里同步更新。文本越简单越好重点是把通信链路说清楚。这套模板后面我还会持续维护。如果你在搭建过程中遇到模板本身的问题欢迎把现场报错贴出来一起看。Electron 的坑不是绕开就完事了得一个个填平之后同类型的项目才能越走越顺。