☰
Electron+Vue3+Vite桌面应用模板:主进程通信与工程化实践
2026/9/30 4:31:58 网站建设 项目流程

做桌面端应用,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 lang="ts"> import { ref, onMounted } from 'vue' import { getSystemInfo } from '../api/system' const systemInfo = ref<any>(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>不需要显式 import,ElMessage这类 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 用 dmg,Linux 用 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 暴露的 API
IPC 消息无响应通道名不一致、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 build

npm 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 的坑不是绕开就完事了,得一个个填平,之后同类型的项目才能越走越顺。

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

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

立即咨询