Vue + Electron 实战项目

用 Vite + Vue 搭 Electron 应用:开发热更新、生产加载、打包集成。

Advanced
Key Points开发时 Electron 加载 Vite 的 dev server,生产时加载打包好的静态文件——一个环境判断切换。

脚手架

Bash
npm create @quick-start/electron@latest my-app -- --template vue
cd my-app
npm install
npm run dev

The 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.

目录结构

Text
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

主进程加载逻辑

JavaScript
// 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

JavaScript
// 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 里使用

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.

TypeScript
// 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:// 下路由、静态资源路径都可能失效
Vue Router in history mode 404s under 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.

Friends & Partners