Vue + Electron 实战项目
用 Vite + Vue 搭 Electron 应用:开发热更新、生产加载、打包集成。
脚手架
npm create @quick-start/electron@latest my-app -- --template vue
cd my-app
npm install
npm run devThe electron-vite scaffold already wires Vite, the main process and the preload script. Hand-rolling means dealing with ESM/CJS in the main process, preload paths and file:// loading in production.
目录结构
my-app/
├── electron.vite.config.js
├── src/
│ ├── main/
│ │ └── index.js ← 主进程
│ ├── preload/
│ │ └── index.js ← 预加载
│ └── renderer/
│ ├── index.html
│ └── src/
│ ├── main.js ← Vue 入口
│ └── App.vue
└── electron-builder.yml主进程加载逻辑
// src/main/index.js
import { app, BrowserWindow, shell } from 'electron';
import { join } from 'node:path';
function createWindow() {
const win = new BrowserWindow({
width: 1100, height: 720, show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: false // electron-vite 的预加载需要 false 才能用 ESM
}
});
win.once('ready-to-show', () => win.show());
if (process.env.ELECTRON_RENDERER_URL) {
// 开发:Vite dev server,支持热更新
win.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
// 生产:打包后的静态文件
win.loadFile(join(__dirname, '../renderer/index.html'));
}
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});ELECTRON_RENDERER_URL is injected by electron-vite in development and is undefined in production, where loadFile takes over. That is the whole trick behind one codebase, two load modes.
预加载暴露 API
// src/preload/index.js
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('api', {
getVersion: () => ipcRenderer.invoke('app:version'),
selectFile: () => ipcRenderer.invoke('dialog:open'),
onUpdate: (cb) => ipcRenderer.on('update:status', (_e, s) => cb(s))
});Vue 里使用
<script setup>
import { ref, onMounted } from 'vue';
const version = ref('');
const file = ref(null);
onMounted(async () => {
version.value = await window.api.getVersion();
window.api.onUpdate((s) => console.log('更新状态', s));
});
async function pick() {
file.value = await window.api.selectFile();
}
</script>
<template>
<main>
<h1>Vue + Electron</h1>
<p>版本 {{ version }}</p>
<button @click="pick">选择文件</button>
<p v-if="file">{{ file.name }}</p>
</main>
</template>
<style scoped>
main { padding: 2rem; font-family: system-ui, sans-serif; }
button { padding: .5rem 1rem; border-radius: .4rem; border: 0;
background: #42b883; color: #04231a; cursor: pointer; }
</style>Type checking complains that window.api does not exist. Declare it in src/renderer/src/env.d.ts to silence the error and get completion.
// env.d.ts
export {};
declare global {
interface Window {
api: {
getVersion(): Promise<string>;
selectFile(): Promise<{ name: string; path: string } | null>;
onUpdate(cb: (status: string) => void): void;
};
}
}开发体验要点
- 渲染进程热更新 —— 改 Vue 组件即时生效,不用重启应用
- 主进程改动自动重启 —— electron-vite 会重启整个应用
- DevTools 分离窗口 ——
openDevTools({ mode: 'detach' })不挡界面 - 生产构建要测 ——
file://下路由、静态资源路径都可能失效
file:// in production, because the browser treats /detail/1 as a file path. Desktop apps should use hash mode or intercept will-navigate in the main process.脚手架
npm create @quick-start/electron@latest my-app -- --template vue
cd my-app
npm install
npm run dev社区脚手架 electron-vite 已经把「Vite 构建 + Electron 主进程 + 预加载」串好了。自己从零配要处理主进程的 ESM/CJS、预加载的路径、生产环境的 file:// 加载,容易踩坑。
目录结构
my-app/
├── electron.vite.config.js
├── src/
│ ├── main/
│ │ └── index.js ← 主进程
│ ├── preload/
│ │ └── index.js ← 预加载
│ └── renderer/
│ ├── index.html
│ └── src/
│ ├── main.js ← Vue 入口
│ └── App.vue
└── electron-builder.yml主进程加载逻辑
// src/main/index.js
import { app, BrowserWindow, shell } from 'electron';
import { join } from 'node:path';
function createWindow() {
const win = new BrowserWindow({
width: 1100, height: 720, show: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: false // electron-vite 的预加载需要 false 才能用 ESM
}
});
win.once('ready-to-show', () => win.show());
if (process.env.ELECTRON_RENDERER_URL) {
// 开发:Vite dev server,支持热更新
win.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
// 生产:打包后的静态文件
win.loadFile(join(__dirname, '../renderer/index.html'));
}
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: 'deny' };
});
}
app.whenReady().then(createWindow);
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});ELECTRON_RENDERER_URL 由 electron-vite 在开发时注入。生产环境它是 undefined,于是走 loadFile。这就是「一套代码两种加载方式」的全部秘密。
预加载暴露 API
// src/preload/index.js
import { contextBridge, ipcRenderer } from 'electron';
contextBridge.exposeInMainWorld('api', {
getVersion: () => ipcRenderer.invoke('app:version'),
selectFile: () => ipcRenderer.invoke('dialog:open'),
onUpdate: (cb) => ipcRenderer.on('update:status', (_e, s) => cb(s))
});Vue 里使用
<script setup>
import { ref, onMounted } from 'vue';
const version = ref('');
const file = ref(null);
onMounted(async () => {
version.value = await window.api.getVersion();
window.api.onUpdate((s) => console.log('更新状态', s));
});
async function pick() {
file.value = await window.api.selectFile();
}
</script>
<template>
<main>
<h1>Vue + Electron</h1>
<p>版本 {{ version }}</p>
<button @click="pick">选择文件</button>
<p v-if="file">{{ file.name }}</p>
</main>
</template>
<style scoped>
main { padding: 2rem; font-family: system-ui, sans-serif; }
button { padding: .5rem 1rem; border-radius: .4rem; border: 0;
background: #42b883; color: #04231a; cursor: pointer; }
</style>window.api 在类型检查里会报「不存在」。给 src/renderer/src/env.d.ts 加一份声明就能解决,也让编辑器有补全。
// env.d.ts
export {};
declare global {
interface Window {
api: {
getVersion(): Promise<string>;
selectFile(): Promise<{ name: string; path: string } | null>;
onUpdate(cb: (status: string) => void): void;
};
}
}开发体验要点
- 渲染进程热更新 —— 改 Vue 组件即时生效,不用重启应用
- 主进程改动自动重启 —— electron-vite 会重启整个应用
- DevTools 分离窗口 ——
openDevTools({ mode: 'detach' })不挡界面 - 生产构建要测 ——
file://下路由、静态资源路径都可能失效
file:// 会 404——浏览器把 /detail/1 当成文件路径。桌面应用要么用 hash 模式,要么在主进程里拦 will-navigate 手动处理。