Vue + Electron
项目搭建
使用 Vite 创建项目
# 创建 Vue 项目
yarn create vite my-app --template vue
cd my-app
yarn install
# 安装 Electron
yarn add -D electron electron-builder concurrently wait-on cross-env
项目结构
my-app/
├── electron/
│ ├── main.js # 主进程
│ └── preload.js # 预加载脚本
├── src/
│ ├── App.vue
│ ├── main.js
│ └── components/
├── index.html
├── package.json
└── vite.config.js
配置 package.json
{
"name": "my-app",
"version": "1.0.0",
"main": "electron/main.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"electron": "wait-on tcp:5173 && cross-env NODE_ENV=development electron .",
"electron:dev": "concurrently \"yarn dev\" \"yarn electron\"",
"electron:build": "vite build && electron-builder"
}
}
配置 vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
base: './',
server: {
port: 5173
},
build: {
outDir: 'dist'
}
})
主进程配置
electron/main.js
const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const isDev = process.env.NODE_ENV === 'development'
function createWindow() {
const win = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true
}
})
if (isDev) {
win.loadURL('http://localhost:5173')
win.webContents.openDevTools()
} else {
win.loadFile(path.join(__dirname, '../dist/index.html'))
}
}
app.whenReady().then(() => {
createWindow()
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
electron/preload.js
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('electronAPI', {
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
openFile: () => ipcRenderer.invoke('open-file'),
saveFile: (data) => ipcRenderer.invoke('save-file', data),
showNotification: (title, body) => ipcRenderer.send('show-notification', title, body),
onUpdateAvailable: (callback) => {
ipcRenderer.on('update-available', (event, info) => callback(info))
}
})
Vue 3 组合式 API 集成
创建 Electron Composable
// src/composables/useElectron.js
import { ref, onMounted } from 'vue'
export function useElectron() {
const isElectron = ref(false)
const appVersion = ref('')
onMounted(async () => {
if (window.electronAPI) {
isElectron.value = true
appVersion.value = await window.electronAPI.getAppVersion()
}
})
return {
isElectron,
appVersion,
api: window.electronAPI
}
}
在组件中使用
<!-- src/App.vue -->
<template>
<div class="app">
<header>
<h1>Vue + Electron 应用</h1>
<span v-if="isElectron">v{{ appVersion }}</span>
</header>
<main>
<FileManager v-if="isElectron" />
<p v-else>请在 Electron 环境中运行</p>
</main>
</div>
</template>
<script setup>
import { useElectron } from './composables/useElectron'
import FileManager from './components/FileManager.vue'
const { isElectron, appVersion } = useElectron()
</script>
实战示例:文件管理器
主进程文件操作
// electron/main.js
const { dialog } = require('electron')
const fs = require('fs').promises
const path = require('path')
ipcMain.handle('open-file', async () => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{ name: 'Text Files', extensions: ['txt', 'md', 'json'] },
{ name: 'All Files', extensions: ['*'] }
]
})
if (result.canceled) {
return { success: false, canceled: true }
}
try {
const filePath = result.filePaths[0]
const content = await fs.readFile(filePath, 'utf-8')
return {
success: true,
filePath,
fileName: path.basename(filePath),
content
}
} catch (error) {
return { success: false, error: error.message }
}
})
ipcMain.handle('save-file', async (event, { filePath, content }) => {
try {
let saveFilePath = filePath
if (!saveFilePath) {
const result = await dialog.showSaveDialog({
filters: [{ name: 'Text Files', extensions: ['txt', 'md'] }]
})
if (result.canceled) {
return { success: false, canceled: true }
}
saveFilePath = result.filePath
}
await fs.writeFile(saveFilePath, content, 'utf-8')
return {
success: true,
filePath: saveFilePath,
fileName: path.basename(saveFilePath)
}
} catch (error) {
return { success: false, error: error.message }
}
})
Vue 文件管理组件
<!-- src/components/FileManager.vue -->
<template>
<div class="file-manager">
<div class="toolbar">
<button @click="handleOpenFile">打开文件</button>
<button @click="handleSaveFile" :disabled="!isModified">
保存 {{ isModified ? '*' : '' }}
</button>
<span class="file-name">
{{ currentFile ? currentFile.name : '未打开文件' }}
</span>
</div>
<textarea
v-model="content"
class="editor"
@input="handleContentChange"
placeholder="打开文件或开始输入..."
/>
<div class="status-bar">
<span>{{ content.length }} 字符</span>
<span>{{ lineCount }} 行</span>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useElectron } from '../composables/useElectron'
const { api } = useElectron()
const currentFile = ref(null)
const content = ref('')
const isModified = ref(false)
const lineCount = computed(() => {
return content.value.split('\n').length
})
const handleOpenFile = async () => {
const result = await api.openFile()
if (result.success) {
currentFile.value = {
path: result.filePath,
name: result.fileName
}
content.value = result.content
isModified.value = false
} else if (!result.canceled) {
alert('打开文件失败: ' + result.error)
}
}
const handleSaveFile = async () => {
const result = await api.saveFile({
filePath: currentFile.value?.path,
content: content.value
})
if (result.success) {
currentFile.value = {
path: result.filePath,
name: result.fileName
}
isModified.value = false
api.showNotification('保存成功', '文件已保存')
} else if (!result.canceled) {
alert('保存文件失败: ' + result.error)
}
}
const handleContentChange = () => {
isModified.value = true
}
</script>
<style scoped>
.file-manager {
display: flex;
flex-direction: column;
height: 100%;
}
.toolbar {
display: flex;
gap: 10px;
padding: 10px;
background: #f5f5f5;
border-bottom: 1px solid #ddd;
}
.file-name {
margin-left: auto;
font-weight: 500;
}
.editor {
flex: 1;
padding: 20px;
border: none;
outline: none;
font-family: 'Monaco', monospace;
font-size: 14px;
line-height: 1.6;
resize: none;
}
.status-bar {
display: flex;
gap: 20px;
padding: 8px 20px;
background: #f5f5f5;
border-top: 1px solid #ddd;
font-size: 12px;
color: #666;
}
</style>
使用 TypeScript
安装依赖
yarn add -D typescript vue-tsc @types/node
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
类型定义
// src/types/electron.d.ts
export interface ElectronAPI {
getAppVersion: () => Promise<string>
openFile: () => Promise<FileResult>
saveFile: (data: SaveFileData) => Promise<FileResult>
showNotification: (title: string, body: string) => void
onUpdateAvailable: (callback: (info: UpdateInfo) => void) => void
}
export interface FileResult {
success: boolean
canceled?: boolean
error?: string
filePath?: string
fileName?: string
content?: string
}
export interface SaveFileData {
filePath?: string
content: string
}
declare global {
interface Window {
electronAPI: ElectronAPI
}
}
TypeScript Composable
// src/composables/useElectron.ts
import { ref, onMounted } from 'vue'
import type { Ref } from 'vue'
import type { ElectronAPI } from '../types/electron'
interface UseElectronReturn {
isElectron: Ref<boolean>
appVersion: Ref<string>
api: ElectronAPI | undefined
}
export function useElectron(): UseElectronReturn {
const isElectron = ref<boolean>(false)
const appVersion = ref<string>('')
onMounted(async () => {
if (window.electronAPI) {
isElectron.value = true
appVersion.value = await window.electronAPI.getAppVersion()
}
})
return {
isElectron,
appVersion,
api: window.electronAPI
}
}
TypeScript 组件
<!-- src/components/FileManager.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { Ref } from 'vue'
import { useElectron } from '../composables/useElectron'
interface FileInfo {
path: string
name: string
}
const { api } = useElectron()
const currentFile = ref<FileInfo | null>(null)
const content = ref<string>('')
const isModified = ref<boolean>(false)
const lineCount = computed<number>(() => {
return content.value.split('\n').length
})
const handleOpenFile = async (): Promise<void> => {
if (!api) return
const result = await api.openFile()
if (result.success && result.filePath && result.fileName) {
currentFile.value = {
path: result.filePath,
name: result.fileName
}
content.value = result.content || ''
isModified.value = false
}
}
const handleSaveFile = async (): Promise<void> => {
if (!api) return
const result = await api.saveFile({
filePath: currentFile.value?.path,
content: content.value
})
if (result.success && result.filePath && result.fileName) {
currentFile.value = {
path: result.filePath,
name: result.fileName
}
isModified.value = false
api.showNotification('保存成功', '文件已保存')
}
}
</script>
状态管理集成
使用 Pinia
yarn add pinia
// src/stores/file.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface FileInfo {
path: string
name: string
}
export const useFileStore = defineStore('file', () => {
const currentFile = ref<FileInfo | null>(null)
const content = ref<string>('')
const isModified = ref<boolean>(false)
const lineCount = computed(() => {
return content.value.split('\n').length
})
const charCount = computed(() => {
return content.value.length
})
function setCurrentFile(file: FileInfo | null) {
currentFile.value = file
}
function setContent(newContent: string) {
content.value = newContent
isModified.value = true
}
function markAsSaved() {
isModified.value = false
}
return {
currentFile,
content,
isModified,
lineCount,
charCount,
setCurrentFile,
setContent,
markAsSaved
}
})
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
<!-- 在组件中使用 -->
<script setup>
import { useFileStore } from '../stores/file'
const fileStore = useFileStore()
const handleOpenFile = async () => {
const result = await api.openFile()
if (result.success) {
fileStore.setCurrentFile({
path: result.filePath,
name: result.fileName
})
fileStore.setContent(result.content)
fileStore.markAsSaved()
}
}
</script>
Vue Router 集成
yarn add vue-router
// src/router/index.js
import { createRouter, createWebHashHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Editor from '../views/Editor.vue'
import Settings from '../views/Settings.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/editor',
name: 'Editor',
component: Editor
},
{
path: '/settings',
name: 'Settings',
component: Settings
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
createApp(App)
.use(router)
.mount('#app')
性能优化
1. 异步组件
<script setup>
import { defineAsyncComponent } from 'vue'
const FileManager = defineAsyncComponent(() =>
import('./components/FileManager.vue')
)
</script>
<template>
<Suspense>
<template #default>
<FileManager />
</template>
<template #fallback>
<div>加载中...</div>
</template>
</Suspense>
</template>
2. 虚拟滚动
yarn add vue-virtual-scroller
<template>
<RecycleScroller
:items="files"
:item-size="40"
key-field="id"
v-slot="{ item }"
>
<div class="file-item">
{{ item.name }}
</div>
</RecycleScroller>
</template>
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
const files = ref([])
</script>
3. 防抖和节流
// src/composables/useDebounce.js
import { ref, watch } from 'vue'
export function useDebounce(value, delay = 500) {
const debouncedValue = ref(value.value)
let timeout
watch(value, (newValue) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debouncedValue.value = newValue
}, delay)
})
return debouncedValue
}
<script setup>
import { ref, watch } from 'vue'
import { useDebounce } from '../composables/useDebounce'
const searchText = ref('')
const debouncedSearch = useDebounce(searchText, 500)
watch(debouncedSearch, (value) => {
// 执行搜索
console.log('搜索:', value)
})
</script>
打包发布
electron-builder 配置
{
"build": {
"appId": "com.example.myapp",
"productName": "我的应用",
"directories": {
"output": "dist-electron"
},
"files": [
"dist/**/*",
"electron/**/*"
],
"mac": {
"target": ["dmg"],
"icon": "build/icon.icns"
},
"win": {
"target": ["nsis"],
"icon": "build/icon.ico"
},
"linux": {
"target": ["AppImage"],
"icon": "build/icon.png"
}
}
}
构建命令
# 构建
yarn electron:build
# 指定平台
yarn electron:build --mac
yarn electron:build --win
yarn electron:build --linux
调试技巧
Vue Devtools
// electron/main.js
const { session } = require('electron')
app.whenReady().then(async () => {
if (isDev) {
try {
await session.defaultSession.loadExtension(
'/path/to/vue-devtools'
)
} catch (err) {
console.log('Vue Devtools 加载失败:', err)
}
}
createWindow()
})
VSCode 配置
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"name": "Electron: Main",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"args": ["."],
"outputCapture": "std",
"env": {
"NODE_ENV": "development"
}
}
]
}
常见问题
1. 路由模式
Electron 中必须使用 Hash 模式:
import { createRouter, createWebHashHistory } from 'vue-router'
const router = createRouter({
history: createWebHashHistory(), // 使用 Hash 模式
routes
})
2. 静态资源路径
// vite.config.js
export default defineConfig({
base: './', // 使用相对路径
})
3. 热更新
yarn add -D electron-reloader
// electron/main.js
if (isDev) {
try {
require('electron-reloader')(module)
} catch {}
}
完整示例项目
查看完整的 Vue + Electron 示例项目: