主题切换
第 3 章:Vue3 + Electron 工程化实践
本章将带你使用现代前端工程化方案搭建 Vue3 + Electron 项目。告别手动配置,拥抱 Vite 的极速开发体验。
从手搭项目到工程化
在第 1 章中,我们通过 npm install electron --save-dev 手动搭建了开发环境。那个项目让我们理解了 Electron 的主进程、预加载脚本、渲染进程三者的角色和分工。
现在,我们将迁移到一个更接近实际生产环境的工程化方案。两个项目之间的核心概念是完全相通的:
| 手搭项目概念 | electron-vite 对应 |
|---|---|
main.js(项目根目录) | src/main/index.ts |
preload.js(项目根目录) | src/preload/index.ts |
index.html + renderer.js | src/renderer/ 下的 Vue 应用 |
npm start → electron . | pnpm dev → Vite + Electron 并行启动 |
手动 require('electron') | TypeScript import { app } from 'electron' |
nodeIntegration: false 手动设置 | 脚手架默认安全配置 |
这个迁移的关键价值在于:
- 极速开发体验:Vite HMR 让 Vue 组件修改即时生效,无需重启应用
- 类型安全:TypeScript 在编译期捕获大量错误,告别
Cannot read property of undefined - 工程化维护:统一的目录结构、构建配置和依赖管理
- 开箱即用的 Electron 优化:electron-vite 内置了主进程热重启、预加载脚本自动构建、生产环境优化
实践建议
如果你对手动项目仍有感情,可以在学习本章时新建一个 electron-vite 项目并行实践。两个项目中的 IPC 通信、窗口管理、系统交互等概念完全互通。旧项目可以作为一个"原理参考"保留。
为什么选 Vue3
在 Electron 中集成前端框架,Vue3 是一个极佳的选择。以下是核心优势:
| 特性 | Vue3 优势 | 对 Electron 开发的意义 |
|---|---|---|
| Composition API | 更灵活的代码组织方式,逻辑复用更简单 | IPC 通信、窗口管理等逻辑可以封装为可复用的 composables |
| 性能 | Proxy 响应式,初始化速度提升 2x+ | 桌面应用启动更快,内存占用更低 |
| TypeScript | 原生支持,类型推导完善 | 与 Electron 的 API 类型完美配合 |
| 体积 | 运行时约 10KB(gzip) | 减少打包体积(虽然 Electron 本身较大) |
| 生态 | Element Plus、Ant Design Vue 等成熟 UI 库 | 快速构建漂亮的桌面界面 |
给前端开发者的建议
如果你已经熟悉 Vue3 的 Composition API,那么在 Electron 中使用它几乎没有任何学习成本。你可以把 Electron 的 API 当作一个新的 "composable" 来使用——useWindowState()、useFileDialog()、useSystemNotification(),这些都可以封装成可复用的组合式函数。
项目搭建:electron-vite
electron-vite 是目前最推荐的 Vue3 + Electron 工程化方案。它基于 Vite,提供了:
- ⚡ 极速的 HMR(热更新)
- 📦 开箱即用的多进程构建(主进程 + 预加载 + 渲染进程)
- 🔧 自动化的开发模式(无需手动重启 Electron)
- 🎯 生产环境优化打包
创建项目
使用脚手架创建项目
bashnpm create electron-vite@latest my-electron-vue-app # 交互式选项: # ? Project template: Vue # ? Add TypeScript? Yes # ? Add Electron updater? No (后续可手动添加) # ? Enable Electron download mirror? Yes (国内推荐) cd my-electron-vue-app npm install安装 Vue 生态依赖
bash# 安装 Vue Router 和 Pinia(状态管理) npm install vue-router@4 pinia # 安装 UI 库(以 Element Plus 为例) npm install element-plus # 安装 Electron 工具库 npm install electron-store # 配置持久化@electron-toolkit 工具集说明
electron-vite 脚手架自动安装了
@electron-toolkit/utils和@electron-toolkit/preload:- @electron-toolkit/utils:提供
electronApp.setAppUserModelId()(任务栏图标)、optimizer.watchWindowShortcuts()(F12 开发者工具快捷键)、is对象(环境/平台判断)等实用函数 - @electron-toolkit/preload:提供
electronAPI对象,封装了常用的预加载脚本逻辑(如ipcRenderer的类型安全封装)
- @electron-toolkit/utils:提供
项目目录结构
electron-vite 生成的项目采用多入口结构,每个进程有独立的代码目录:
text
my-electron-vue-app/
├── electron.vite.config.ts # Vite 配置(多进程构建)
├── package.json
├── tsconfig.json
│
├── src/
│ ├── main/ # 主进程代码
│ │ ├── index.ts # 主进程入口
│ │ └── utils/ # 主进程工具函数
│ │
│ ├── preload/ # 预加载脚本
│ │ ├── index.ts # 暴露给渲染进程的 API
│ │ └── types/ # 类型定义
│ │
│ └── renderer/ # 渲染进程(Vue 应用)
│ ├── index.html # HTML 模板
│ ├── main.ts # Vue 应用入口
│ ├── App.vue # 根组件
│ ├── router/ # Vue Router
│ ├── stores/ # Pinia 状态管理
│ ├── components/ # 公共组件
│ ├── views/ # 页面视图
│ ├── composables/ # 组合式函数(Electron API 封装)
│ └── styles/ # 全局样式
│
├── build/ # 构建输出
└── out/ # 打包输出目录设计哲学
这种多入口结构与传统的 Vue 项目不同,但逻辑清晰:main 是 "后端",preload 是 "API 网关",renderer 是 "前端"。三者职责分离,符合 Electron 的安全架构。
核心配置文件
electron.vite.config.ts
typescript
import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
main: {
// 主进程构建配置
plugins: [externalizeDepsPlugin()],
build: {
lib: {
entry: resolve(__dirname, 'src/main/index.ts'),
formats: ['cjs'],
fileName: () => '[name].js'
},
rollupOptions: {
external: ['electron']
}
}
},
preload: {
// 预加载脚本构建配置
plugins: [externalizeDepsPlugin()],
build: {
lib: {
entry: resolve(__dirname, 'src/preload/index.ts'),
formats: ['cjs'],
fileName: () => '[name].js'
}
}
},
renderer: {
// 渲染进程构建配置(Vue 应用)
resolve: {
alias: {
'@': resolve(__dirname, 'src/renderer'),
'@main': resolve(__dirname, 'src/main'),
'@preload': resolve(__dirname, 'src/preload')
}
},
plugins: [vue()],
server: {
// 开发服务器配置
port: 3000
}
}
})主进程入口(src/main/index.ts)
typescript
import { app, shell, BrowserWindow, ipcMain } from 'electron'
import { join } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import icon from '../../resources/icon.png?asset'
function createWindow(): void {
// 创建浏览器窗口
const mainWindow = new BrowserWindow({
width: 1200,
height: 800,
show: false, // 先不显示,等加载完成再显示
autoHideMenuBar: true, // 自动隐藏菜单栏
...(process.platform === 'linux' ? { icon } : {}),
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
sandbox: false,
contextIsolation: true,
nodeIntegration: false
}
})
// 加载完成后再显示窗口(避免白屏)
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
// 打开外部链接时使用系统浏览器
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)
return { action: 'deny' }
})
// 根据环境加载不同内容
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
// 开发模式:加载 Vite 开发服务器 URL
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
} else {
// 生产模式:加载打包后的文件
mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
}
}
// 应用初始化
app.whenReady().then(() => {
// 设置 Windows 应用用户模型 ID(任务栏图标)
electronApp.setAppUserModelId('com.electron')
// 默认打开或关闭 DevTools 的快捷键(F12 / Cmd+Option+I)
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
// IPC 示例:处理来自渲染进程的请求
ipcMain.handle('get-app-version', () => {
return app.getVersion()
})
createWindow()
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})预加载脚本(src/preload/index.ts)
typescript
import { contextBridge, ipcRenderer } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
// 暴露 API 给渲染进程
const api = {
// 获取应用版本
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
// 文件操作(示例)
openFile: () => ipcRenderer.invoke('dialog:openFile'),
saveFile: (content: string) => ipcRenderer.invoke('dialog:saveFile', content),
// 窗口控制
minimizeWindow: () => ipcRenderer.send('window:minimize'),
maximizeWindow: () => ipcRenderer.send('window:maximize'),
closeWindow: () => ipcRenderer.send('window:close')
}
// 使用 contextBridge 安全暴露
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('api', api)
} catch (error) {
console.error(error)
}
} else {
// 降级方案(不推荐生产环境使用)
// @ts-ignore
window.electron = electronAPI
// @ts-ignore
window.api = api
}Vue 应用入口(src/renderer/main.ts)
typescript
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import router from './router'
import App from './App.vue'
// 引入 Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')根组件(src/renderer/App.vue)
vue
<template>
<div id="electron-app">
<router-view />
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useAppStore } from './stores/app'
const store = useAppStore()
onMounted(async () => {
await store.loadSettings()
})
</script>
<style>
#electron-app {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>Vue Router 配置(src/renderer/router/index.ts)
typescript
import { createRouter, createWebHashHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'home',
component: () => import('../views/HomeView.vue')
},
{
path: '/settings',
name: 'settings',
component: () => import('../views/SettingsView.vue')
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue')
}
]
const router = createRouter({
// Electron 中使用 hash 历史,避免 file:// 协议下的路由问题
history: createWebHashHistory(),
routes
})
export default router为什么使用 Hash 路由
Electron 应用通过 file:// 协议加载页面,HTML5 History 模式需要服务端支持。使用 createWebHashHistory() 可以避免路由刷新后白屏的问题。如果你在生产环境通过 HTTP 加载页面,可以切换为 createWebHistory()。
全局样式(src/renderer/styles/global.css)
css
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--border-color: #30363d;
--accent-blue: #58a6ff;
--accent-green: #3fb950;
--accent-red: #e81123;
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
}
/* 在 main.ts 中使用: import './styles/global.css' */开发模式与生产模式
开发模式(Development)
bash
npm run dev
# 内部流程:
# 1. Vite 启动开发服务器(端口 3000)
# 2. Electron 主进程启动
# 3. 主进程加载 http://localhost:3000 到 BrowserWindow
# 4. 文件修改 → HMR 热更新 → 界面自动刷新开发体验
electron-vite 在开发模式下会同时启动 Vite 开发服务器和 Electron。修改 Vue 组件时,界面会热更新而不丢失状态;修改主进程代码时,Electron 会自动重启。
生产模式(Production)
bash
# 构建应用(编译主进程、预加载、渲染进程)
npm run build
# 打包为可执行文件(使用 electron-builder)
npm run build:win # Windows
npm run build:mac # macOS
npm run build:linux # Linux
# 构建 + 打包一步到位
npm run dist环境判断
typescript
// 在主进程中判断环境
import { is } from '@electron-toolkit/utils'
if (is.dev) {
console.log('开发模式')
// 打开开发者工具
mainWindow.webContents.openDevTools()
} else {
console.log('生产模式')
}
// 判断平台
if (is.windows) { /* Windows 特有逻辑 */ }
if (is.macos) { /* macOS 特有逻辑 */ }
if (is.linux) { /* Linux 特有逻辑 */ }热更新配置
electron-vite 已经内置了热更新,但你可以进一步优化体验:
Vue 组件热更新(已内置)
Vite 的 HMR 对 Vue 单文件组件(SFC)支持完美,修改模板、样式或逻辑都会即时更新。
主进程热重启配置
json
{
"scripts": {
"dev": "electron-vite dev",
"build": "electron-vite build",
"preview": "electron-vite preview",
"postinstall": "electron-builder install-app-deps",
"build:win": "npm run build && electron-builder --win",
"build:mac": "npm run build && electron-builder --mac",
"build:linux": "npm run build && electron-builder --linux"
}
}工程化最佳实践
1. 封装 Composables(组合式函数)
typescript
import { ref, onMounted } from 'vue'
// 封装 Electron API 为 Vue composable
export function useAppVersion() {
const version = ref('')
onMounted(async () => {
if (window.api?.getAppVersion) {
version.value = await window.api.getAppVersion()
}
})
return { version }
}
// 窗口控制
export function useWindowControl() {
const minimize = () => window.api?.minimizeWindow?.()
const maximize = () => window.api?.maximizeWindow?.()
const close = () => window.api?.closeWindow?.()
return { minimize, maximize, close }
}
// 文件对话框
export function useFileDialog() {
const openFile = async () => {
return await window.api?.openFile?.()
}
const saveFile = async (content: string) => {
return await window.api?.saveFile?.(content)
}
return { openFile, saveFile }
}2. 类型声明(TypeScript 支持)
typescript
// 为 window.api 添加类型声明
export interface ElectronAPI {
getAppVersion: () => Promise<string>
openFile: () => Promise<{ canceled: boolean; filePaths: string[] }>
saveFile: (content: string) => Promise<{ canceled: boolean; filePath?: string }>
minimizeWindow: () => void
maximizeWindow: () => void
closeWindow: () => void
}
declare global {
interface Window {
api: ElectronAPI
electron: typeof import('@electron-toolkit/preload').electronAPI
}
}3. 状态管理(Pinia)
typescript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useAppStore = defineStore('app', () => {
// State
const windowState = ref<'normal' | 'maximized' | 'minimized'>('normal')
const recentFiles = ref<string[]>([])
// Getters
const hasRecentFiles = computed(() => recentFiles.value.length > 0)
// Actions
const addRecentFile = (path: string) => {
recentFiles.value.unshift(path)
if (recentFiles.value.length > 10) {
recentFiles.value.pop()
}
}
const setWindowState = (state: typeof windowState.value) => {
windowState.value = state
}
return {
windowState,
recentFiles,
hasRecentFiles,
addRecentFile,
setWindowState
}
})4. IPC 监听器生命周期管理
在 Vue 组件中使用 IPC 监听器时,必须在组件销毁时清理,避免内存泄漏和重复触发。
typescript
import { ref, onMounted, onUnmounted } from 'vue'
export function useWindowMaximizeState() {
const isMaximized = ref(false)
// 必须保存 handler 引用,以便在 onUnmounted 中移除同一个监听器
const handler = (maximized: boolean) => {
isMaximized.value = maximized
}
onMounted(() => {
window.api?.onMaximizeChange?.(handler)
})
onUnmounted(() => {
// 关键:组件销毁时移除监听器
window.api?.offMaximizeChange?.(handler)
})
return { isMaximized }
}常见错误
在 onMounted 中注册 IPC 监听器但忘记在 onUnmounted 中移除,会导致:
- 组件销毁后监听器仍然存在,触发时访问已销毁的响应式数据
- 每次进入页面都注册新监听器但不移除旧的,监听器堆积引发内存泄漏
- 多次触发回调导致状态异常
5. API 命名约定
在教程不同章节中,你可能会看到不同的 preload API 命名方式。以下是推荐的命名规范:
| 命名方式 | 示例 | 适用场景 |
|---|---|---|
| 扁平命名 | window.api.minimizeWindow() | 小型项目,功能简单 |
| 嵌套命名(推荐) | window.electronAPI.window.minimize() | 生产项目,功能模块化 |
本教程第 7 章将展示嵌套命名的推荐模式。无论选择哪种,核心原则是在项目中保持一致。
原生模块编译
在 Electron 中使用 @nut-tree/nut-js、better-sqlite3、sharp 等包含 C++ 原生代码的包时,需要特殊处理。
为什么需要特殊处理
原生 Node.js 模块(.node 文件)是针对特定 Node.js 版本编译的。Electron 内置的 Node.js 版本可能与系统安装的版本不同,导致原生模块无法加载。此外,这些模块在打包到 asar 中时也无法直接运行。
使用 electron-rebuild
bash
# 安装 electron-rebuild
npm install -D @electron/rebuild
# 重新编译所有原生模块
npx @electron/rebuild
# 或指定 Electron 版本
npx @electron/rebuild -v 28.0.0json
{
"scripts": {
"postinstall": "electron-rebuild",
"postuninstall": "electron-rebuild"
}
}asarUnpack 配置
打包时必须将原生模块从 asar 中排除:
yaml
asar: true
asarUnpack:
- "node_modules/@nut-tree/**/*"
- "node_modules/better-sqlite3/**/*"
- "node_modules/sharp/**/*"判断一个包是否需要 asarUnpack
如果包的目录中包含 .node 文件(如 build/Release/xxx.node),或安装时执行了 node-gyp rebuild,就需要配置 asarUnpack。纯 JavaScript 包无需特殊处理。
测试策略概述
Electron 应用推荐分层测试:单元测试(Vitest)、组件测试(Vue Test Utils)、集成测试和 E2E 测试(Playwright Electron)。完整的测试策略和实战方案请参考 附录 F:Electron 测试指南。
最佳实践清单
- ✅ 使用 TypeScript 获得完整的类型提示
- ✅ 通过 Composables 封装 Electron API,保持组件纯净
- ✅ 使用 Pinia 管理跨组件状态
- ✅ 主进程和渲染进程代码严格分离
- ✅ 预加载脚本只暴露必要的 API
- ✅ 使用 Vue Router 管理多页面导航
- ✅ 开发时打开 DevTools,生产时关闭
本章小结
本章核心要点:
- Vue3 + Vite + Electron 是现代桌面开发的最佳组合
- electron-vite 提供开箱即用的多进程工程化方案
- 项目分为 main、preload、renderer 三个独立入口
- 开发模式享受 HMR 热更新,生产模式一键打包
- 通过 Composables 封装 Electron API,代码更优雅
下一章我们将学习窗口管理和原生菜单,让你的应用拥有专业的桌面体验。