引言

大家好!作為一名前端開發者,你是否曾經想過將你的 Vue 應用打包成桌面應用?今天我要分享的是使用 Electron 將 Vue 應用轉換為桌面應用的完整實戰經驗。從項目搭建到最終上線,我會詳細介紹每個步驟,包括一些實用的自動化腳本和最佳實踐。

需求背景

在開發過程中,我們經常遇到這樣的場景:

  • 需要將 Web 應用打包成桌面應用
  • 希望應用能夠離線運行
  • 需要訪問本地文件系統
  • 要求應用具有原生桌面體驗

Electron 正是解決這些需求的完美方案。它基於 Chromium 和 Node.js,讓我們可以用 Web 技術開發跨平台的桌面應用。

工作原理

Electron 的核心架構包含兩個進程:

  • 主進程(Main Process):負責創建和管理應用窗口,處理系統級 API
  • 渲染進程(Renderer Process):運行我們的 Vue 應用,類似於瀏覽器中的網頁

兩個進程通過 IPC(進程間通信)進行數據交換,主進程可以訪問 Node.js API,渲染進程則專注於 UI 展示。

代碼實現

1. 項目初始化

首先創建項目目錄結構:

mkdir electron-vue-app
cd electron-vue-app
npm init -y

安裝必要的依賴:

npm install electron electron-builder --save-dev
npm install vue@next @vitejs/plugin-vue vite --save-dev

2. 主進程配置

創建 main.js 文件:

const { app, BrowserWindow, ipcMain } = require('electron')
const path = require('path')
const fs = require('fs')

let mainWindow

function createWindow() {
  mainWindow = new BrowserWindow({
    width: 1200,
    height: 800,
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false,
      enableRemoteModule: true
    }
  })

  // 開發環境加載本地服務器,生產環境加載打包文件
  if (process.env.NODE_ENV === 'development') {
    mainWindow.loadURL('http://localhost:3000')
    mainWindow.webContents.openDevTools()
  } else {
    mainWindow.loadFile('dist/index.html')
  }
}

app.whenReady().then(createWindow)

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow()
  }
})

3. Vue 應用配置

創建 vite.config.js

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  base: './',
  build: {
    outDir: 'dist'
  }
})

4. 自動化腳本實現

這裏我們實現一個實用的文件備份腳本,展示 Electron 與 Node.js 的深度集成:

# backup_manager.py
import os
import shutil
import schedule
import time
import logging
from datetime import datetime
from pathlib import Path

# 配置日誌
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('backup.log'),
        logging.StreamHandler()
    ]
)

class BackupManager:
    def __init__(self, source_dir, backup_dir):
        self.source_dir = Path(source_dir)
        self.backup_dir = Path(backup_dir)
        self.backup_dir.mkdir(exist_ok=True)
        
    def backup_folder(self, folder_name):
        """複製指定文件夾到備份目錄"""
        try:
            source_path = self.source_dir / folder_name
            if not source_path.exists():
                logging.warning(f"源文件夾 {folder_name} 不存在")
                return False
                
            timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
            backup_path = self.backup_dir / f"{folder_name}_{timestamp}"
            
            shutil.copytree(source_path, backup_path)
            logging.info(f"成功備份 {folder_name} 到 {backup_path}")
            return True
            
        except Exception as e:
            logging.error(f"備份 {folder_name} 失敗: {str(e)}")
            return False
    
    def schedule_backup(self, folder_name, interval_hours=24):
        """定時備份任務"""
        schedule.every(interval_hours).hours.do(
            lambda: self.backup_folder(folder_name)
        )
        logging.info(f"已設置 {folder_name} 每 {interval_hours} 小時自動備份")
    
    def run_scheduler(self):
        """運行定時任務"""
        logging.info("開始運行定時備份任務...")
        while True:
            schedule.run_pending()
            time.sleep(60)  # 每分鐘檢查一次

# 使用示例
if __name__ == "__main__":
    backup_manager = BackupManager(
        source_dir="./src",
        backup_dir="./backups"
    )
    
    # 立即備份一次
    backup_manager.backup_folder("components")
    
    # 設置定時備份
    backup_manager.schedule_backup("components", 12)  # 每12小時備份一次
    backup_manager.schedule_backup("views", 24)      # 每24小時備份一次
    
    # 運行定時任務
    backup_manager.run_scheduler()

5. Vue 組件實現

創建 App.vue

<template>
  <div class="app">
    <header class="header">
      <h1>Electron Vue 桌面應用</h1>
      <div class="actions">
        <button @click="openFileDialog" class="btn btn-primary">
          選擇文件夾
        </button>
        <button @click="startBackup" class="btn btn-success">
          開始備份
        </button>
        <button @click="viewLogs" class="btn btn-info">
          查看日誌
        </button>
      </div>
    </header>
    
    <main class="main">
      <div class="file-list">
        <h3>文件列表</h3>
        <ul>
          <li v-for="file in fileList" :key="file.name" class="file-item">
            <span class="file-name">{{ file.name }}</span>
            <span class="file-size">{{ formatFileSize(file.size) }}</span>
            <span class="file-date">{{ formatDate(file.date) }}</span>
          </li>
        </ul>
      </div>
      
      <div class="backup-status">
        <h3>備份狀態</h3>
        <div class="status-item" v-for="status in backupStatus" :key="status.id">
          <span :class="['status', status.type]">{{ status.message }}</span>
          <span class="timestamp">{{ status.timestamp }}</span>
        </div>
      </div>
    </main>
  </div>
</template>

<script>
import { ref, onMounted } from 'vue'

export default {
  name: 'App',
  setup() {
    const fileList = ref([])
    const backupStatus = ref([])
    
    const openFileDialog = () => {
      // 調用 Electron 主進程打開文件對話框
      window.electronAPI.openFileDialog()
    }
    
    const startBackup = () => {
      backupStatus.value.push({
        id: Date.now(),
        type: 'info',
        message: '開始備份...',
        timestamp: new Date().toLocaleString()
      })
      
      // 調用備份腳本
      window.electronAPI.startBackup()
    }
    
    const viewLogs = () => {
      window.electronAPI.viewLogs()
    }
    
    const formatFileSize = (bytes) => {
      if (bytes === 0) return '0 Bytes'
      const k = 1024
      const sizes = ['Bytes', 'KB', 'MB', 'GB']
      const i = Math.floor(Math.log(bytes) / Math.log(k))
      return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
    }
    
    const formatDate = (date) => {
      return new Date(date).toLocaleString()
    }
    
    onMounted(() => {
      // 監聽來自主進程的消息
      window.electronAPI.onBackupComplete((event, result) => {
        backupStatus.value.push({
          id: Date.now(),
          type: result.success ? 'success' : 'error',
          message: result.message,
          timestamp: new Date().toLocaleString()
        })
      })
    })
    
    return {
      fileList,
      backupStatus,
      openFileDialog,
      startBackup,
      viewLogs,
      formatFileSize,
      formatDate
    }
  }
}
</script>

<style>
.app {
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  margin: 0;
  padding: 20px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
}

.header {
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(10px);
  border-radius: 15px;
  padding: 20px;
  margin-bottom: 20px;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.header h1 {
  color: white;
  margin: 0;
  font-size: 2rem;
}

.actions {
  display: flex;
  gap: 10px;
}

.btn {
  padding: 10px 20px;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.3s ease;
}

.btn-primary {
  background: #007bff;
  color: white;
}

.btn-success {
  background: #28a745;
  color: white;
}

.btn-info {
  background: #17a2b8;
  color: white;
}

.btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}

.main {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}

.file-list, .backup-status {
  background: rgba(255, 255, 255, 0.9);
  border-radius: 15px;
  padding: 20px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}

.file-item {
  display: flex;
  justify-content: space-between;
  padding: 10px;
  border-bottom: 1px solid #eee;
}

.status-item {
  display: flex;
  justify-content: space-between;
  padding: 10px;
  border-bottom: 1px solid #eee;
}

.status {
  padding: 4px 8px;
  border-radius: 4px;
  font-size: 0.9rem;
}

.status.success {
  background: #d4edda;
  color: #155724;
}

.status.error {
  background: #f8d7da;
  color: #721c24;
}

.status.info {
  background: #d1ecf1;
  color: #0c5460;
}
</style>

6. 構建配置

創建 package.json 腳本:

{
  "name": "electron-vue-app",
  "version": "1.0.0",
  "main": "main.js",
  "scripts": {
    "dev": "concurrently \"npm run dev:vite\" \"wait-on http://localhost:3000 && electron .\"",
    "dev:vite": "vite",
    "build": "vite build",
    "build:electron": "npm run build && electron-builder",
    "dist": "npm run build && electron-builder --publish=never"
  },
  "build": {
    "appId": "com.example.electron-vue-app",
    "productName": "Electron Vue App",
    "directories": {
      "output": "dist-electron"
    },
    "files": [
      "dist/**/*",
      "main.js",
      "node_modules/**/*"
    ],
    "mac": {
      "category": "public.app-category.developer-tools"
    },
    "win": {
      "target": "nsis"
    },
    "linux": {
      "target": "AppImage"
    }
  }
}

運行效果截圖

electron-vue前端開發桌面應用(一)——入門_#vue.js

應用運行後的效果包括:

  1. 主界面:現代化的漸變背景,半透明毛玻璃效果的頭部區域
  2. 文件管理:左側顯示文件列表,包含文件名、大小、修改日期
  3. 備份狀態:右側實時顯示備份進度和結果
  4. 操作按鈕:三個主要功能按鈕,具有懸停動畫效果
  5. 日誌查看:點擊查看日誌按鈕會打開日誌文件

界面採用響應式設計,支持不同屏幕尺寸,整體風格簡潔現代。

總結

通過這個實戰項目,我們成功地將 Vue 應用打包成了桌面應用,並集成了實用的文件備份功能。關鍵技術點包括:

  • Electron 主進程與渲染進程通信:實現了 Web 技術與系統 API 的無縫集成
  • 自動化腳本:使用 Python 的 shutil、schedule、logging 模塊實現了文件備份和定時任務
  • 現代化 UI:採用 CSS Grid 佈局和毛玻璃效果,提供良好的用户體驗

這個腳本具有很強的擴展性,你可以進一步:

  • 雲端備份:集成 AWS S3、阿里雲 OSS 等雲存儲服務
  • 壓縮打包:使用 zipfile 或 tar 模塊壓縮備份文件
  • 增量備份:只備份修改過的文件,提高效率
  • 加密存儲:使用 cryptography 模塊加密敏感文件

希望這篇文章對你在 Electron + Vue 桌面應用開發方面有所幫助!


作者: 王新焱