☰
Electron + Vue 桌面应用项目结构详解与实战
2026/10/14 13:48:54 网站建设 项目流程

有段时间我需要快速交付一个跨平台桌面工具,团队里全是 Web 背景,选型时几乎没怎么纠结就定了 Electron 配 Vue。原因很直接:Electron 负责壳,Vue 负责界面,原本浏览器里的开发体验能原样搬到桌面端,一套代码同时覆盖 Windows、macOS 和 Linux。这篇文章就是给第一次用 Electron 构建 Vue 项目的同学准备的,我会从最基本的项目结构讲起,把主进程、渲染进程、preload 脚本这些概念一次讲透,再给出可以直接落地的目录规划、通信方案和打包配置。我尽量不写“教科书”,而是以我自己反复搭建这套结构的实际经历为线索,省得大家再踩我踩过的坑。

1. Electron 与 Vue 的配合方式:先搞清楚两个运行时

很多人第一次接触 Electron 时,心里默认它是一个“能写桌面应用的 Node.js”,这时候去套 Vue,很容易产生一个错觉:Vue 组件里可以直接 require('fs') 读文件。实际并非如此。Electron 应用内部其实同时存在两个运行时,一个是 Node.js,另一个是 Chromium 浏览器内核,它们分别对应不同的进程角色。搞清楚这个基本模型,后续所有结构设计都会顺理成章。

1.1 主进程、渲染进程与 preload 各管什么

Electron 应用启动后至少会拉起三个层面的代码。主进程是入口文件所在的地方,整个应用只有一个实例,负责创建 BrowserWindow、管理窗口生命周期、处理系统级事件(托盘、菜单、全局快捷键),也拥有完整的 Node.js 能力。渲染进程每创建一个 BrowserWindow 就会产生一个,Vue 应用就跑在这里,但它本质上只是一个 Chromium 页面,默认不直接拥有 Node.js 权限。preload 脚本则是在页面加载前执行的桥接代码,运行在一个特殊环境里,可以通过有限的 Node.js 能力暴露安全接口。

用生活里的事情类比,主进程像餐厅的后厨,渲染进程像用餐区,preload 像传菜窗口。Vue 只负责把菜摆盘、呈给用户看,真正的“下厨”动作,比如读文件、写配置、调系统弹窗,都需要通过传菜窗口点单给后厨,由主进程实际执行。这个模型的意义在于把能力体系按权限边界切开了:渲染进程里跑的是用户可见的代码,也是攻击面最大的地方,所以默认要把 Node 能力关掉,避免页面中一旦被注入恶意脚本就直接控制整个系统。

1.2 为什么 Vue 只负责渲染层这一半

从工程角度看,Vue 在 Electron 里的定位和它在浏览器里没有本质区别:处理数据、状态、组件、路由、页面交互。真正与操作系统打交道的那部分应该收拢到主进程。我见过不少刚上手的人把“Electron 里能跑 Vue”理解成“Electron 项目等于一个 Vue 项目,挂个壳”,于是在渲染进程里开 nodeIntegration 去读本地文件。这样做在小 Demo 里确实能跑通,但一旦应用需要同时打开多个窗口、处理外部链接跳转、做自动更新,所有能力都会堆在渲染进程里,权限边界全部打乱。

现代 Electron 的默认安全策略正好相反:关闭 nodeIntegration,开启 contextIsolation,把需要的能力通过 preload 显式暴露。原生的安全设计其实已经替我们划好了项目的分界线,我们要做的是顺着这条线组织代码,而不是逆着它硬塞。所以“Electron 构建 Vue 项目基本结构”这件事,本质上是在搭三条轨道:主进程代码轨、preload 桥接轨、渲染进程 Vue 轨。三条轨道各有自己的构建目标和运行环境,结构是否清晰,直接决定了后续开发是顺畅还是混乱。

1.3 开发模式下 electron-vite 如何把两边串起来

我建议直接使用 electron-vite 这套构建方案,而不是手写一堆 webpack 配置。原因是它把 Electron 特有的“多入口、两个运行时”问题封装得很干净。执行开发命令后,electron-vite 会同时做三件事:先把主进程代码和 preload 代码分别编译到 out/main 和 out/preload,再启动一个 Vite dev server 托管 Vue 页面,最后拉起 Electron 并加载 dev server 的地址。这样改 Vue 代码时走的是 Vite 热更新,改主进程代码时可以通过监听重启 Electron 窗口,整个开发体验和平时写 Web 前端几乎一致。

用一句话总结,electron-vite 把“Vue 构建”和“Electron 构建”两套工具链合并到了一起,开发者只需要关心 src 下的业务代码,编译分流的事情交给它处理。这个选择还会直接影响后面的目录结构设计,因为脚手架默认的 src/main、src/preload、src/renderer 分布就是按 electron-vite 的构建目标划分的,保持这个结构,后续加配置时就不用反复改构建逻辑。如果你试图用普通 Vite 模板硬改成 Electron 项目,通常要额外引入一堆插件和自定义脚本,这也是我不建议从空项目手搓的原因。

2. 初始化项目:脚手架选择与目录规划

2.1 直接采用 electron-vite 脚手架的理由

创建项目的方式有很多种,我实际上对比过三类。第一类是手动创建 package.json,安装 electron 和 vue,再自己配置 vite 或 webpack。这条路对理解原理最有帮助,但对第一次做 Electron 的人来说很容易卡住:主进程和渲染进程要两套构建目标,还要手动处理 preload 脚本的模块转换,工作量不大但特别碎。第二类是直接用通用桌面框架的初始化工具,模板确实能跑,但模板风格、版本兼容性和我们对 Vue 技术栈的习惯不一定匹配,改起来成本反而高。

第三类就是使用 electron-vite 提供的 create 脚手架,生成 Vue 模板,再把示例代码清掉。这是我当前最推荐的方式。生成命令大致是 npm create @quick-start/electron,过程中按提示选择 Vue 和需要的语言即可。生成之后,electron-vite 的配置已经被内置好了,后续只需按业务需要调整目录和代码。语言方面,团队项目我更倾向 TypeScript,因为主进程暴露出来的接口面会越来越大,没有类型约束,通信参数很容易在调用方写错;如果只是快速验证原型,用 JavaScript 确实更省事。

2.2 生成后的目录骨架逐层拆解

打开脚手架生成的项目,核心目录基本是这个形态:

├── src │ ├── main │ │ └── index.js │ ├── preload │ │ └── index.js │ └── renderer │ ├── index.html │ └── src │ ├── main.js │ ├── App.vue │ └── ... ├── resources │ └── icon.png ├── electron-builder.yml └── package.json

逐个说清楚。src/main 是主进程代码,这里的文件会被 electron-vite 单独编译成一个可在 Node 环境执行的构建产物,最终由 Electron 入口加载。src/preload 是 preload 脚本,编译成一个独立文件,在窗口创建时通过 webPreferences.preload 指定给对应窗口使用。src/renderer 是渲染进程代码,内部就是一个常规 Vue 项目,有 index.html、入口 main.js、App.vue,还能继续细分 views、components、stores 等模块。resources 目录主要放应用图标等打包素材,electron-builder.yml 是打包相关配置。

这里有一个容易被忽视的点:renderer 目录内部有自己的 src,外层也有一个 src,两者在脚手架里是刻意分开的。Vue 工程里的 src 和 Electron 主进程的 src 不要混在一起,否则构建目标会乱掉。我自己维护中还会进一步拆细 src/main,比如单独拆出 windows.js 管理窗口创建和复用,拆出 ipc.js 集中注册 IPC 处理器,拆出 menu.js 管理应用菜单,把入口文件保持成非常薄的启动层。这个拆分看起来简单,但对后续功能扩张帮助很大。

2.3 手动补全基础文件时的注意点

如果不使用脚手架而是自己从零搭,重点检查三处。第一,package.json 中的 main 字段必须指向 Electron 实际加载的入口文件。在 electron-vite 场景下通常是 out/main/index.js,因为开发时 electron-vite 会把 src/main 编译到这里,生产打包时也依赖这个路径。写错的话,应用会启动失败或直接没有任何窗口。

第二,electron.vite.config.js 里同时配置 main、preload、renderer 三个部分,但不要试图在一个配置里硬塞 Vue 插件的所有选项。渲染进程部分的配置和常规 Vite 的 vue 插件配置基本一致,主进程部分则要像配置 Node 项目一样注意外部依赖的处理。electron-vite 默认把主进程依赖视为 external,所以第三方库不会被打进 out/main 的产物里,而是运行时从 node_modules 读取,这个机制理解错了,后面排查“打包后找不到模块”会很痛苦。

第三,依赖分类要分清。electron-builder 打包时,package.json 里的 dependencies 会被视为运行时依赖,打包进应用;devDependencies 则只在构建阶段使用。如果你把运行时需要的库写进了 devDependencies,打包后的应用很可能启动报错,因为模块根本不在应用目录内。到这里,一个能跑的结构已经有了,接下来最关键的是通信层。

3. 打通主进程与 Vue 页面的通信链路

3.1 用 contextBridge 暴露受控接口

Electron 的通信机制叫 IPC,全称是进程间通信。渲染进程和主进程通过 ipcRenderer 与 ipcMain 互相发消息。为了让 Vue 页面用起来安全又顺手,我不会直接暴露 ipcRenderer,而是通过 preload 脚本里的 contextBridge 把自定义 API 暴露到 window 上。典型的 preload/index.js 写法如下:

import { contextBridge, ipcRenderer } from 'electron' const api = { getSystemInfo: () => ipcRenderer.invoke('system:get-info'), onUpdate: (callback) => { const listener = (_event, data) => callback(data) ipcRenderer.on('update:push', listener) return () => ipcRenderer.removeListener('update:push', listener) } } if (process.contextIsolated) { contextBridge.exposeInMainWorld('api', api) } else { window.api = api }

这里的核心是 contextBridge.exposeInMainWorld,它做了一层对象隔离,页面能访问到的只有你定义的几个方法,而不是整个 ipcRenderer。如果直接把 ipcRenderer 暴露出去,页面里的任何代码都可以向主进程发送任意 IPC 事件,等于把整个系统能力的门钥匙交给了前台页面。接口收得越窄,越容易维护。写 preload 时还需要注意:受 contextIsolation 影响,window 对象被隔离成两个世界,普通赋值方式可能无效,所以脚手架里判断 process.contextIsolated 再分别处理的那段逻辑不要删掉。

3.2 渲染进程发起调用与主进程回应的完整示例

最常用的通信模式是“渲染进程请求,主进程回应”。以查询系统信息为例,主进程里注册:

import { ipcMain } from 'electron' ipcMain.handle('system:get-info', async () => { return { platform: process.platform, version: process.versions.electron, memory: process.getSystemMemoryInfo() } })

这里使用 ipcMain.handle 而不是 ipcMain.on,因为它天然对应 Promise 风格的调用。渲染进程中,假设组件里已经引入了 ref 和 onMounted,Vue 侧只需要这样写:

const info = ref(null) async function loadSystemInfo() { info.value = await window.api.getSystemInfo() } onMounted(loadSystemInfo)

window.api.getSystemInfo 返回的就是 Promise,与 async 函数配合非常自然,不需要回调嵌套。如果你用的是 ipcRenderer.send 加 ipcMain.on 那种旧模式,得自己维护请求与响应的对应关系,并发请求多的时候很容易串号。所以现在的 Electron 版本里,invoke/handle 是更推荐的请求-响应模式。

要注意的是,invoke 传输的数据会被结构化克隆。可以传普通对象、数组、Buffer,但不能传函数和带原型链的类实例。所以接口设计上尽量传 JSON 兼容数据,别试图把“会话对象”直接透传过去,这个习惯能省掉很多怪异的报错。

3.3 主进程主动推送事件的订阅与清理

另一类常见通信是主进程主动向页面推送消息,比如后台任务进度、文件下载状态、系统电量变化。主进程侧推送只需要一行:

win.webContents.send('update:push', { progress: 42 })

页面侧就不能再用 invoke 了,因为这是一条单向流。preload 里我习惯把 ipcRenderer.on 包成一个订阅函数,具体写法就是 3.1 里 api.onUpdate 那个函数:它接收一个回调,返回一个取消订阅函数。组件在使用时就能很规范地清理:

let unsubscribe onMounted(() => { unsubscribe = window.api.onUpdate((data) => { progress.value = data.progress }) }) onBeforeUnmount(() => { if (unsubscribe) unsubscribe() })

为什么强调清理?因为渲染进程里的组件销毁后,listener 仍然挂在 ipcRenderer 上。如果组件频繁创建销毁(弹窗、切换页签),listener 会不断堆积,导致回调执行越来越慢,内存占用持续上涨。真实项目里我还见过因为漏掉清理,同一个事件被回调上百次的现象。把 preload 的 API 设计成“返回取消函数”的形态,配合 Vue 的生命周期钩子,是成本最低且不容易忘的写法。

4. 开发与生产的资源路径处理

4.1 base 路径与 file 协议之间的关系

Vue 项目在普通 Web 部署时,构建产物里 JS 和 CSS 的资源路径默认以 / 开头,因为页面最终通过 HTTP 协议从域名根路径加载。Electron 生产环境不一样:渲染进程加载的是本地 HTML 文件,走 file:// 协议。这时候如果资源路径写成 /assets/index.js,浏览器会去访问本地磁盘根目录下的 assets,结果必然是资源加载失败、页面白屏。解决方式很直接:在渲染进程的构建配置里把 base 设置为相对路径。

electron-vite 的 Vue 模板通常已配置好这一点,但从普通 Vite Vue 项目迁移过来时,一定要检查是否设置过 base: '/' 之类的选项。我见过不少项目本地开发正常,一打包就全白,根因就是这里。在 Electron 渲染进程中,任何资源的加载路径最终都会被映射为本地文件系统路径,所以不要用绝对根路径,尽量用相对路径或自己掌控路径拼接。

4.2 开发模式和生产模式加载入口的分叉

主进程创建窗口时,加载页面的方式必须区分环境:

const devUrl = process.env['ELECTRON_RENDERER_URL'] if (devUrl) { win.loadURL(devUrl) } else { win.loadFile(join(__dirname, '../renderer/index.html')) }

开发模式下 electron-vite 会设置 ELECTRON_RENDERER_URL 环境变量指向 dev server 地址,所以走 loadURL;生产打包后没有这个变量,走 loadFile。判断逻辑丢了或者路径写错,都会造成失败。

这里有一个隐藏细节:loadFile 时,路径是基于编译后的 out/main/index.js 来解析的,所以要用 path.join(__dirname, '../renderer/index.html')。写成相对于项目根目录的路径,在开发时也许碰巧有效,打包后就不一定了,因为应用内的目录结构在 asar 归档里会被重新组织。这个坑我踩过一次,排查了一个多小时,最后发现只是路径基准点错了。

4.3 动态资源与外部文件的加载

如果应用不仅要显示界面,还需要读取用户本地的图片、数据库文件、导出报表,不能指望 Vue 直接通过 fetch 相对路径去读。因为渲染进程的 file 协议访问范围有限,而且路径一旦写死,换台电脑就失效。比较实用的做法是区分两类资源。

一类是随应用安装包一起发布的静态资源,比如默认模板、内置样张。这类资源建议放到 resources 目录,并在 electron-builder 的 extraResources 中声明,运行期通过 process.resourcesPath 拼接访问。resourcesPath 是 Electron 运行时提供的固定目录,所有 extraResources 内容都会被复制到这里,不会受当前工作目录影响。另一类是用户运行时产生的动态文件。这类资源通常由主进程通过文件对话框打开并读取,再把文件内容或可访问地址通过 IPC 返回给渲染进程。如果图片数量大,要避免走 IPC 传整个 Buffer,更推荐注册一个自定义协议,比如 app://,在主进程里通过 protocol.handle 把文件读取逻辑收拢,页面像访问普通 URL 一样展示图片。当前阶段只要记住“不要用硬编码的相对路径去读运行期文件”就够了。

5. 打包发布:electron-builder 的基本配置

5.1 最小可用的 electron-builder 配置

electron-vite 脚手架默认集成了 electron-builder,electron-builder.yml 大致长这样:

appId: com.example.yourapp productName: YourApp directories: output: release/${version} files: - out/**/* - package.json asar: true win: target: - nsis mac: target: - dmg linux: target: - AppImage

几个配置项的作用要理解清楚:appId 是应用唯一标识,macOS 用作 bundle id,最好写成反域名风格;productName 是最终安装包和应用菜单里显示的名字;directories.output 是安装包输出目录;files 决定哪些内容进入安装包,上面只放了 out 和 package.json,源码和配置文件不会进去;asar 开启后应用文件会打包成一个 asar 归档,减少文件数量,也避免用户轻易翻源码。

asar 开启后需要注意:运行期需要动态读取的文件,不能继续用拼路径的方式直接 fs.readFileSync,因为文件在 asar 内部,普通路径读取不到。要么把这类动态文件通过 extraResources 放到 resources 目录,要么用 Electron 的 asar 解包能力去读取。最简单的规则是:代码逻辑文件放 out 走 asar,用户数据和大型静态资源走 extraResources。

5.2 图标、命名与应用元信息

打包时图标是个高频出事点。Windows 需要 .ico,macOS 需要 .icns,Linux 需要一组 png 图标。如果只准备了一张 png,可以在构建机上用图标转换工具离线转换,但 macOS 对图标尺寸要求严格,建议用 1024x1024 的源图生成全套图标。另一个容易被忽略的点是 productName 的确定:它出现在用户的菜单、任务栏、dock 栏、安装界面里,不要在打包前才临时改,否则旧的快捷方式会残留一系列问题。

还有文件关联和协议关联的声明。桌面应用需要打开某种自定义后缀的文件时,要在打包配置里声明 fileAssociations;需要从浏览器唤起应用时,则配置自定义协议。对基本结构来说是选修课,但提前知道位置,后面加功能时不至于临时翻文档。另外建议在应用内的 about 面板、默认窗口标题中和 productName 保持一致,用户体验会统一很多。

5.3 常见打包失败与修复记录

我把自己遇到过的打包问题整理成一张表,供大家对照:

现象常见原因处理方式
打包时提示下载 Electron 或相关依赖超时构建机访问官方下载源不稳定配置 ELECTRON_MIRROR 镜像源环境变量
打包后启动提示找不到某个第三方模块运行时依赖被写进 devDependencies把该依赖移到 dependencies 并重新安装
打包失败,报错指向某个原生模块Native 模块未针对 Electron ABI 编译在构建机安装编译工具链,开启 npmRebuild 或执行对应重编译命令
打包成功但页面白屏,控制台报资源 404渲染进程构建产生绝对路径检查 vite 配置里的 base 是否已改为相对路径
打包后体积明显偏大dependencies 里混入了仅构建期使用的库将只用于构建的依赖移到 devDependencies

这张表不需要背下来,遇到问题来查就行。核心思路是:先确认是构建问题还是运行期问题,再检查配置与依赖分类,必要时调整依赖源和重编译原生模块。如果碰到表格里没有的新问题,我一般先看构建日志里第一个报错,绝大多数打包失败都是配置类错误,不会太难定位。

6. 实测中遇到的几个高频问题与处理思路

6.1 页面白屏的第一排查顺序

白屏是 Electron + Vue 项目里出现频率最高的现象,我推荐的排查顺序是固定的。第一步,按 F12 打开开发者工具,看 Console 和 Network。Console 有 JS 报错,说明渲染进程已加载但脚本执行出了问题;Network 里 JS/CSS 红了一大片,基本是路径或协议问题。第二步,检查 preload 是否生效,在 Vue 代码里打印 window.api,如果它是 undefined,说明 preload 路径配置不对,或者 contextIsolation 设置影响了注入。常见原因是 BrowserWindow 的 webPreferences.preload 路径写错,特别在 electron-vite 下,preload 会编译到 out/preload/index.js,不是 src/preload/index.js。

第三步,检查主进程加载分支。确认生产环境确实走到了 loadFile,可以在主进程里打印当前加载目标来验证。第四步,检查 CSP。有些模板会设置较严格的内容安全策略,页面里用了内联脚本或某些第三方库时会被 CSP 拦截,控制台通常会明确提示违反了哪个 directive。按这个顺序走,大多数白屏都能在几分钟内定位,而不是盲目改配置浪费一下午。

6.2 开发环境正常但打包后加载失败的根因

这类问题本质上是开发与生产两个环境的信息不一致。开发环境走 HTTP dev server,页面从网络路径加载资源;生产环境走 file 协议,从本地文件加载。资源路径、入口判断、协议行为都不同。我在实际项目里遇到三个典型案例。

第一是 Vue Router 使用 history 模式。Web 部署时 history 模式需要服务器配合,在 Electron 的 file 协议下,history 模式刷新页面会直接找不到路径,桌面端应用请使用 hash 模式或记忆历史模式。第二是动态 import 的 chunk 路径问题,Vite 打包后异步组件会拆成独立 chunk,如果 base 不是相对路径,这些 chunk 同样 404,修复办法是统一设置相对 base。第三是代码里拼接 window.location 来请求接口,生产环境 window.location 会变成 file:// 开头,直接请求后端自然失败,正确做法是把 API 地址写在环境变量里,不受页面 URL 影响。这一点在从纯 Web 项目迁移到 Electron 时尤其要改干净。

6.3 性能层面的几个基础优化点

项目结构搭好之后,性能优化通常排在功能后面,但有几点建议在早期就建立意识。第一,渲染进程不要承担大计算量任务。Vue 页面里的逻辑再顺手,它也只是一个浏览器环境,遇到 CPU 密集计算应转移到主进程或独立 worker 线程,否则窗口会卡顿,主线程阻塞影响整个应用交互。第二,注意监听器泄漏,页面销毁时一定要清理 ipcRenderer 的 listener。对桌面端来说这不是小事,窗口可能长时间开着,泄漏累积比浏览器标签页更明显。

第三,窗口创建时避免加载过重资源。如果首页有大量图表或图片,可以先创建隐藏窗口,等关键资源加载完成后再显示,让用户感知到的是秒开而不是白屏等待。第四,生产环境关闭调试相关选项,不要注册远程调试端口,也不要默认打开开发者工具。第五,谨慎关闭背景节流。setBackgroundThrottling(false) 能让隐藏窗口继续以满帧渲染,但 CPU 占用明显增加,除非业务确实需要后台持续渲染,否则保持默认节流,让系统帮我们省电。

这套结构我前后搭过不止一次,最深的体会是:与其一开始就研究各种高级技巧,不如先把 main、preload、renderer 三条轨道的边界划清楚,把目录、配置、通信、打包这四件事一次做对。通信层尤其建议一开始就把所有暴露给页面的 API 集中定义在一个文件里,命名带上领域前缀,后面加功能就是顺着这条主线往两边延伸。如果你正从纯 Web 开发转向桌面端,按这个骨架先跑通一个最小可用版本,再去扩展业务模块,会省掉相当多的弯路。

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

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

立即咨询