基于 electron-vite 从零到一搭建桌面端应用

1,040 阅读24分钟

在现代桌面应用开发领域,Electron凭借其跨平台特性和Web技术栈的优势,成为了众多开发者的首选框架。而electron-vite作为新一代的构建工具,结合了Vite的极速开发体验和Electron的强大能力,为桌面应用开发带来了全新的可能。本文将基于当前的 multimedia-browser 项目,详细介绍如何使用electron-vite从零开始搭建一个桌面应用,包括项目初始化、配置、开发、测试、构建和部署的全过程,并提供实际项目中的最佳实践和性能优化建议。

1. electron-vite 简介

1.1 什么是electron-vite

electron-vite 是一个专为 Electron 应用打造的构建工具,它基于 Vite 进行定制化开发,为 Electron 应用提供了极速的开发体验和优化的构建输出。

1.2 核心优势

  • 极速开发体验:利用Vite的按需编译和热模块替换(HMR)特性,大幅提升开发效率
  • 分离构建:独立构建主进程、预加载脚本和渲染进程,优化构建输出
  • 现代Web技术栈:支持 Vue、React 等现代前端框架
  • TypeScript支持:内置 TypeScript 支持,提升代码质量和开发体验
  • 优化构建输出:针对 Electron 应用特点进行构建优化

2. 项目初始化与配置

2.1 初始化项目

使用electron-vite初始化一个新项目非常简单,只需要一个命令即可:

# 使用npm
npm create @quick-start/electron@latest

# 使用 yarn
yarn create @quick-start/electron

# 使用 pnpm
pnpm create @quick-start/electron

以当前的multimedia-browser项目为例,我们使用了Vue模板来初始化项目。

2.2 项目结构解析

初始化后的项目结构如下:

├── src/
│   ├── main/         # 主进程代码
│   ├── preload/      # 预加载脚本
│   └── renderer/     # 渲染进程代码
├── electron-vite.config.mjs  # electron-vite配置文件
├── package.json      # 项目依赖和脚本
└── electron-builder.yml      # 打包配置文件

这种结构清晰地分离了Electron的不同进程,便于开发和维护。

2.3 核心配置文件解析

2.3.1 electron-vite.config.mjs

这是electron-vite的核心配置文件,定义了主进程、预加载脚本和渲染进程的构建配置:

import { resolve } from 'path'
import { defineConfig, externalizeDepsPlugin } from 'electron-vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  main: {
    plugins: [externalizeDepsPlugin()]  // 将依赖排除在打包外
  },
  preload: {
    plugins: [externalizeDepsPlugin()]  // 同样排除依赖
  },
  renderer: {
    resolve: {
      alias: {
        '@renderer': resolve('src/renderer/src')  // 定义别名
      }
    },
    plugins: [vue()]  // 使用Vue插件
  }
})

这个配置文件的关键在于:

  1. 对主进程和预加载脚本使用externalizeDepsPlugin插件,将Node.js依赖排除在打包外
  2. 对渲染进程配置Vue插件和路径别名,提升开发体验

2.3.2 package.json

package.json文件定义了项目的依赖和脚本命令:

{
  "name": "multimedia-browser",
  "version": "1.0.0",
  "description": "An Electron application with Vue",
  "main": "./out/main/index.js",
  "scripts": {
    "dev": "electron-vite dev",      // 开发模式
    "build": "electron-vite build",  // 构建应用
    "start": "electron-vite preview", // 预览构建结果
    "build:win": "npm run build && electron-builder --win",  // 构建Windows安装包
    "build:mac": "npm run build && electron-builder --mac",  // 构建macOS安装包
    "build:linux": "npm run build && electron-builder --linux" // 构建Linux安装包
  },
  "dependencies": {
    // 生产依赖
    "@electron-toolkit/preload": "^3.0.2",
    "@electron-toolkit/utils": "^4.0.0",
    "electron-updater": "^6.3.9",
    // Vue相关依赖
    "vue": "^3.5.17",
    "vue-router": "^4.5.1",
    "pinia": "^3.0.3",
    // UI库
    "element-plus": "^2.11.2",
    "@element-plus/icons-vue": "^2.3.2"
  },
  "devDependencies": {
    // 开发依赖
    "electron": "^37.2.3",
    "electron-vite": "^4.0.0",
    "electron-builder": "^25.1.8",
    "vite": "^7.0.5",
    "@vitejs/plugin-vue": "^6.0.0"
  })
}

完成项目初始化和配置后,接下来我们将深入探讨Electron应用的核心组成部分 - 主进程。主进程是Electron应用的控制中心,负责创建窗口和管理应用生命周期。

3. 主进程实现

主进程是Electron应用的核心,负责创建窗口、处理系统事件和管理应用生命周期。在当前项目中,主进程代码位于src/main/index.js

3.1 创建窗口

// 添加必要的导入
import { app, BrowserWindow, protocol, nativeTheme, dialog } from 'electron'
import { join } from 'path'
import { electronApp } from '@electron-toolkit/utils'
import logger from './logger.js'

// 确保应用单例运行
const lock = app.requestSingleInstanceLock()
if (!lock) {
  app.quit()
}

// 开发环境检查
const isDev = process.env.NODE_ENV === 'development'

function createWindow() {
  // 创建浏览器窗口
  const mainWindow = new BrowserWindow({
    width: 900,
    height: 670,
    show: false,
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),
    webPreferences: {
      // 安全最佳实践
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
      disableBlinkFeatures: 'Auxclick',
      // 预加载脚本
      preload: join(__dirname, '../preload/index.js'),
    }
  })

  // 窗口准备好显示时再显示
  mainWindow.on('ready-to-show', () => {
    mainWindow.show()
    // 在开发模式下自动打开开发者工具
    if (isDev) {
      mainWindow.webContents.openDevTools()
    }
  })

  // 错误处理
  mainWindow.on('crashed', () => {
    logger.error('Main window crashed')
    dialog.showMessageBox({
      type: 'error',
      title: '应用崩溃',
      message: '应用程序遇到问题,需要重启。',
      buttons: ['重启', '关闭'],
    }).then(result => {
      if (result.response === 0) {
        createWindow()
      }
    })
  })

  // 加载URL或本地HTML文件
  const loadUrlOrFile = () => {
    if (isDev && process.env['ELECTRON_RENDERER_URL']) {
      mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
        .catch(err => {
          logger.error('Failed to load dev URL:', err)
          dialog.showErrorBox('加载失败', '无法加载开发服务器,请确保开发服务器已启动。')
        })
    } else {
      mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
        .catch(err => {
          logger.error('Failed to load HTML file:', err)
          dialog.showErrorBox('加载失败', '无法加载应用文件,请重新安装应用。')
        })
    }
  }

  loadUrlOrFile()
  return mainWindow
}

3.2 应用生命周期管理

// 当Electron完成初始化并准备创建浏览器窗口时调用
app.whenReady().then(() => {
  try {
    logger.info('App is ready')

    // 注册自定义协议处理本地媒体文件(带安全检查)
    protocol.registerFileProtocol('media-file', (request, callback) => {
      try {
        const url = request.url.replace('media-file://', '')
        const filePath = decodeURIComponent(url)

        // 安全性检查
        if (isSafePath(filePath)) {
          callback(filePath)
        } else {
          logger.warn('Attempted to access unsafe path:', filePath)
          callback({ error: -324 })
        }
      } catch (error) {
        logger.error('Error in file protocol handler:', error)
        callback({ error: -324 })
      }
    })

    // 设置应用用户模型ID
    electronApp.setAppUserModelId('com.electron')

    // 设置应用主题
    nativeTheme.themeSource = 'system'

    // 创建窗口
    const mainWindow = createWindow()

    // 处理macOS特有的激活事件
    app.on('activate', function () {
      if (BrowserWindow.getAllWindows().length === 0) {
        createWindow()
      }
    })
  } catch (error) {
    logger.error('Error during app initialization:', error)
    dialog.showErrorBox('初始化失败', '应用初始化时发生错误。')
  }
})

// 处理窗口关闭事件
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit()
  }
})

// 应用退出前清理
app.on('will-quit', () => {
  logger.info('App is quitting')
  // 执行清理操作
})

// 路径安全性检查函数
function isSafePath(path) {
  // 实现路径安全性检查逻辑
  // 例如,验证路径是否在允许的目录范围内
  return true // 简化版本,实际应用中应该实现更严格的检查
}

// 处理未捕获异常
process.on('uncaughtException', (error) => {
  logger.error('Uncaught exception:', error)
  dialog.showErrorBox('严重错误', '应用发生严重错误,即将退出。')
  app.quit()
})

// 处理Promise拒绝
process.on('unhandledRejection', (reason) => {
  logger.error('Unhandled Promise rejection:', reason)
})

### 3.3 主进程IPC通信实现

主进程通过ipcMain模块监听和处理来自渲染进程的消息:

```javascript
// IPC测试
ipcMain.on('ping', () => console.log('pong'))

// 处理打开目录对话框
ipcMain.handle('open-directory-dialog', async () => {
  const result = await dialog.showOpenDialog({
    properties: ['openDirectory'],
    title: '选择媒体文件目录'
  })
  return result.canceled ? null : result.filePaths[0]
})

// 处理获取目录下的文件
ipcMain.handle('get-files-in-directory', async (_, directoryPath) => {
  try {
    const entries = await fs.readdir(directoryPath, { withFileTypes: true })
    // 过滤文件类型
    const supportedExtensions = {
      images: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'],
      videos: ['mp4', 'avi', 'mov', 'mkv', 'wmv'],
      audio: ['mp3', 'wav', 'ogg', 'flac', 'aac']
    }

    const files = []
    for (const entry of entries) {
      if (!entry.isFile()) continue

      const fileName = entry.name
      const filePath = join(directoryPath, fileName)

      // 获取文件信息并分类
      const stats = await fs.stat(filePath)
      let fileType = 'other'
      const extension = fileName.toLowerCase().split('.').pop()

      for (const [type, extensions] of Object.entries(supportedExtensions)) {
        if (extensions.includes(extension)) {
          fileType = type
          break
        }
      }

      files.push({
        name: fileName,
        path: filePath,
        size: stats.size,
        type: fileType,
        modifiedTime: stats.mtimeMs
      })
    }

    return files
  } catch (error) {
    console.error('读取目录内容失败:', error)
    throw error
  }
})
}

主进程负责应用的核心功能,而预加载脚本则是连接主进程和渲染进程的重要桥梁。接下来我们将详细介绍如何实现安全、高效的预加载脚本。

4. 预加载脚本实现

预加载脚本是连接主进程和渲染进程的桥梁,它可以在渲染进程加载前执行,并有权限访问Node.js API。在当前项目中,预加载脚本位于src/preload/index.js。下面是一个增强版的预加载脚本实现,添加了更完善的错误处理机制和类型定义:

import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import logger from '../main/logger.js'

// 事件通道名称常量定义 - 提高可维护性
const CHANNELS = {
  OPEN_DIRECTORY_DIALOG: 'open-directory-dialog',
  GET_FILES_IN_DIRECTORY: 'get-files-in-directory'
}

// IPC调用错误处理包装器
const safeInvoke = async (channel, ...args) => {
  try {
    return await electronAPI.ipcRenderer.invoke(channel, ...args)
  } catch (error) {
    logger.error(`IPC调用失败 [${channel}]:`, error)
    // 可以根据不同的错误类型进行自定义错误处理
    throw error // 重新抛出错误让渲染进程处理
  }
}

// 参数验证函数
const validateParams = (channel, params) => {
  switch (channel) {
    case CHANNELS.GET_FILES_IN_DIRECTORY:
      // 验证路径参数
      if (typeof params !== 'string' || !params.trim()) {
        throw new Error('无效的目录路径')
      }
      return true
    default:
      return true
  }
}

// 自定义API,用于渲染进程调用
const api = {
  // 打开目录选择对话框
  openDirectory: () => {
    return safeInvoke(CHANNELS.OPEN_DIRECTORY_DIALOG)
  },
  // 获取目录下的文件
  getFilesInDirectory: (path) => {
    validateParams(CHANNELS.GET_FILES_IN_DIRECTORY, path)
    return safeInvoke(CHANNELS.GET_FILES_IN_DIRECTORY, path)
  }
}

// 使用contextBridge将API暴露给渲染进程
if (process.contextIsolated) {
  try {
    // 暴露electronAPI和自定义API
    contextBridge.exposeInMainWorld('electron', {
      ...electronAPI,
      ipcRenderer: electronAPI.ipcRenderer,
      process: {
        versions: process.versions
      }
    })

    contextBridge.exposeInMainWorld('api', api)

    logger.info('预加载脚本初始化成功,API已暴露给渲染进程')
  } catch (error) {
    logger.error('预加载脚本初始化失败:', error)
    console.error(error)
  }
} else {
  // 兼容旧版本Electron (不推荐,仅作为后备方案)
  logger.warn('上下文隔离被禁用,使用兼容性模式')
  window.electron = electronAPI
  window.electron.process = {
    versions: process.versions
  }
  window.api = api
}

// 定义API类型,供TypeScript使用
declare global {
  interface Window {
    electron: {
      ipcRenderer: Electron.IpcRenderer
      process: {
        versions: NodeJS.ProcessVersions
      }
    }
    api: {
      openDirectory: () => Promise<string | null>
      getFilesInDirectory: (path: string) => Promise<Array<{
        name: string
        path: string
        size: number
        type: string
        modifiedTime: number
      }>>
    }
  }
}

这个增强版的预加载脚本实现了以下改进:

  1. 常量管理:使用CHANNELS常量对象管理所有IPC通道名称,提高代码可维护性
  2. 错误处理机制
    • 添加safeInvoke包装函数,捕获并记录所有IPC调用错误
    • 向主进程的logger模块报告错误,便于调试和监控
  3. 参数验证:添加validateParams函数,对传入的参数进行类型和有效性检查
  4. 完善的日志记录:记录关键操作和错误,便于问题排查
  5. TypeScript类型定义:为window.electron和window.api提供完整的类型声明,提升开发体验
  6. 安全性增强:通过严格的类型检查和参数验证,提高应用安全性

这些改进使得预加载脚本更加健壮,错误处理更加完善,同时为TypeScript开发提供了良好的类型支持。

预加载脚本为我们提供了进程间通信的桥梁,现在我们将关注用户界面和交互部分 - 渲染进程。渲染进程负责应用的视觉呈现和用户交互逻辑。

5. 渲染进程实现

渲染进程负责应用的UI渲染和用户交互,在当前项目中,渲染进程基于Vue 3实现,代码位于src/renderer/src/目录。

5.1 应用入口文件

import './assets/main.css'

import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElIcons from '@element-plus/icons-vue'
import i18n from './i18n'

const app = createApp(App)
const pinia = createPinia()

// 注册所有Element Plus图标
for (const name in ElIcons) {
  app.component(name, ElIcons[name])
}

// 初始化主题
import { initializeTheme } from './utils/themeUtils.js'
initializeTheme()

// 安装插件
app.use(router)
app.use(pinia)
app.use(ElementPlus)
app.use(i18n)

// 挂载应用
app.mount('#app')

5.2 路由配置

项目使用Vue Router进行路由管理,配置位于src/renderer/src/router/index.js

import { createRouter, createWebHashHistory } from 'vue-router'

// 定义路由
const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('@renderer/views/HomeView.vue')
  },
  {
    path: '/media',
    name: 'Media',
    component: () => import('@renderer/views/MediaView.vue')
  },
  {
    path: '/settings',
    name: 'Settings',
    component: () => import('@renderer/views/SettingsView.vue')
  }
]

// 创建路由器实例
const router = createRouter({
  history: createWebHashHistory(), // 使用hash模式路由,适合Electron应用
  routes
})

export default router

5.3 状态管理

项目使用Pinia进行状态管理,以媒体模块为例,代码位于src/renderer/src/store/modules/media.js

import { defineStore } from 'pinia'

export const useMediaStore = defineStore('media', {
  state: () => ({
    // 媒体文件列表
    mediaFiles: [],
    // 当前选中的媒体文件
    selectedFile: null,
    // 当前目录路径
    currentPath: '',
    // 媒体文件筛选器
    filter: {
      type: 'all', // 'all', 'images', 'videos', 'audio'
      search: ''
    },
    // 媒体库设置
    settings: {
      viewMode: 'grid', // 'grid', 'list'
      sortBy: 'name', // 'name', 'date', 'size'
      sortOrder: 'asc' // 'asc', 'desc'
    }
  }),

  getters: {
    // 获取筛选后的媒体文件
    filteredMediaFiles: (state) => {
      let files = [...state.mediaFiles]

      // 类型筛选
      if (state.filter.type !== 'all') {
        files = files.filter((file) => file.type.startsWith(state.filter.type))
      }

      // 搜索筛选
      if (state.filter.search) {
        const searchLower = state.filter.search.toLowerCase()
        files = files.filter(
          (file) =>
            file.name.toLowerCase().includes(searchLower) ||
            file.path.toLowerCase().includes(searchLower)
        )
      }

      // 排序
      files.sort((a, b) => {
        let comparison = 0
        switch (state.settings.sortBy) {
          case 'name':
            comparison = a.name.localeCompare(b.name)
            break
          case 'date':
            comparison = a.modifiedTime - b.modifiedTime
            break
          case 'size':
            comparison = a.size - b.size
            break
        }
        return state.settings.sortOrder === 'asc' ? comparison : -comparison
      })

      return files
    }
  },

  actions: {
    // 设置媒体文件列表
    setMediaFiles(files) {
      this.mediaFiles = files
    },
    // 添加单个媒体文件(避免重复)
    addMediaFile(file) {
      const isDuplicate = this.mediaFiles.some(existingFile => existingFile.path === file.path)
      if (!isDuplicate) {
        this.mediaFiles.push(file)
      }
    },
    // 移除媒体文件
    removeMediaFile(filePath) {
      this.mediaFiles = this.mediaFiles.filter((file) => file.path !== filePath)
    },
    // 设置当前选中的文件
    setSelectedFile(file) {
      this.selectedFile = file
    },
    // 设置当前目录路径
    setCurrentPath(path) {
      this.currentPath = path
    },
    // 更新筛选条件
    updateFilter(filter) {
      this.filter = { ...this.filter, ...filter }
    },
    // 更新设置
    updateSettings(settings) {
      this.settings = { ...this.settings, ...settings }
    },
    // 清除所有数据
    clearAll() {
      this.mediaFiles = []
      this.selectedFile = null
      this.currentPath = ''
    }
  }
})

5.4 组件实现

以媒体视图组件为例,代码位于src/renderer/src/views/MediaView.vue,它演示了如何在渲染进程中通过预加载脚本调用主进程的API:

<template>
  <div class="media-view">
    <el-card class="header-card" shadow="never">
      <template #header>
        <div class="card-header">
          <el-icon><Folder /></el-icon>
          <span class="header-title">{{ t('navigation.media') }}</span>
        </div>
      </template>

      <div class="controls">
        <el-input
          v-model="searchTerm"
          :placeholder="t('media.searchPlaceholder')"
          prefix-icon="Search"
          class="search-input"
          @input="handleSearch"
        />

        <el-select
          v-model="selectedType"
          :placeholder="t('media.selectType')"
          class="filter-select"
          @change="handleTypeFilter"
        >
          <el-option :label="t('options.allTypes')" value="all" />
          <el-option :label="t('media.image')" value="images" />
          <el-option :label="t('media.video')" value="videos" />
          <el-option :label="t('media.audio')" value="audio" />
        </el-select>

        <div class="view-controls">
          <el-button
            :type="viewMode === 'grid' ? 'primary' : 'default'"
            icon="Grid"
            circle
            :title="t('options.gridView')"
            @click="setViewMode('grid')"
          />
          <el-button
            :type="viewMode === 'list' ? 'primary' : 'default'"
            icon="List"
            circle
            :title="t('options.listView')"
            @click="setViewMode('list')"
          />
        </div>
      </div>
    </el-card>

    <el-card class="path-card mb-4" shadow="never">
      <div class="current-path">
        <el-breadcrumb separator-class="el-icon-arrow-right">
          <el-breadcrumb-item :to="{ path: '/' }">{{ t('navigation.home') }}</el-breadcrumb-item>
          <el-breadcrumb-item>{{ t('navigation.media') }}</el-breadcrumb-item>
          <el-breadcrumb-item>{{
            currentPath ? currentPath.split('/').pop() : t('media.noPathSelected')
          }}</el-breadcrumb-item>
        </el-breadcrumb>
        <el-button type="primary" :loading="loading" @click="openDirectory">
          <el-icon><FolderOpened /></el-icon>
          {{ t('media.selectDirectory') }}
        </el-button>
      </div>
    </el-card>

    <!-- 其余部分省略 -->
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useMediaStore } from '@renderer/store/modules/media'
import { useI18n } from 'vue-i18n'

const mediaStore = useMediaStore()
const { t } = useI18n()

const loading = ref(false)
const searchTerm = ref('')
const selectedType = ref('all')
const viewMode = computed(() => mediaStore.settings.viewMode)
const filteredMediaFiles = computed(() => mediaStore.filteredMediaFiles)
const selectedFile = computed(() => mediaStore.selectedFile)
const currentPath = computed(() => mediaStore.currentPath)

// 打开目录选择对话框
const openDirectory = async () => {
  try {
    loading.value = true
    // 通过预加载脚本调用主进程API
    const directory = await window.api.openDirectory()
    if (directory) {
      // 获取目录下的文件
      const files = await window.api.getFilesInDirectory(directory)
      // 更新状态
      mediaStore.setMediaFiles(files)
      mediaStore.setCurrentPath(directory)
    }
  } catch (error) {
    console.error('打开目录失败:', error)
  } finally {
    loading.value = false
  }
}

// 选择文件
const selectFile = (file) => {
  mediaStore.setSelectedFile(file)
}

// 处理搜索
const handleSearch = (value) => {
  mediaStore.updateFilter({ search: value })
}

// 处理类型筛选
const handleTypeFilter = (type) => {
  mediaStore.updateFilter({ type })
}

// 设置视图模式
const setViewMode = (mode) => {
  mediaStore.updateSettings({ viewMode: mode })
}

// 获取预览URL
const getPreviewUrl = (file) => {
  // 使用自定义协议加载本地文件
  return `media-file://${encodeURIComponent(file.path)}`
}
</script>

5.5 主题系统实现

在现代桌面应用中,主题切换功能是提升用户体验的重要部分。本项目实现了一个完整的主题系统,支持浅色模式、深色模式和跟随系统模式。

主题工具函数位于src/renderer/src/utils/themeUtils.js

// 主题相关工具函数

/**
 * 获取当前应使用的主题类名
 * @param {string} theme - 主题设置值 ('light', 'dark', 'system')
 * @returns {string} - 'light-theme' 或 'dark-theme'
 */
export const getThemeClass = (theme = null) => {
  // 如果没有提供主题,则从localStorage获取或使用默认值
  const currentTheme = theme || localStorage.getItem('theme') || 'light'

  // 跟随系统模式
  if (currentTheme === 'system') {
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark-theme' : 'light-theme'
  }

  // 手动选择模式
  return currentTheme === 'dark' ? 'dark-theme' : 'light-theme'
}

/**
 * 应用主题到根元素
 * @param {string} theme - 可选,指定要应用的主题
 */
export const applyTheme = (theme = null) => {
  const root = document.documentElement
  const themeClass = getThemeClass(theme)

  // 移除所有主题类
  root.classList.remove('light-theme', 'dark-theme')
  // 添加当前主题类
  root.classList.add(themeClass)

  console.log('应用主题:', themeClass)
}

/**
 * 保存主题设置并应用
 * @param {string} theme - 要保存的主题值
 */
export const saveAndApplyTheme = (theme) => {
  localStorage.setItem('theme', theme)
  applyTheme(theme)
}

/**
 * 初始化主题(应用启动时使用)
 */
export const initializeTheme = () => {
  applyTheme()
}

/**
 * 创建系统主题变化的事件监听器
 * @param {Function} callback - 当系统主题变化且当前设置为系统模式时触发的回调
 * @returns {Function} - 返回一个清理函数,用于移除事件监听器
 */
export const setupSystemThemeListener = (callback = null) => {
  const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')

  // 定义处理函数
  const handleSystemThemeChange = () => {
    const currentTheme = localStorage.getItem('theme') || 'light'
    if (currentTheme === 'system') {
      if (callback) {
        callback()
      } else {
        applyTheme()
      }
    }
  }

  // 添加事件监听器
  mediaQuery.addEventListener('change', handleSystemThemeChange)

  // 返回清理函数
  return () => {
    mediaQuery.removeEventListener('change', handleSystemThemeChange)
  }
}

主题系统的使用示例可以在应用的主入口文件中看到:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import './assets/styles/main.scss'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
import { initializeTheme } from './utils/themeUtils'

const app = createApp(App)

// 注册Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
  app.component(key, component)
}

// 初始化主题
initializeTheme()

app.use(createPinia())
app.use(ElementPlus)
app.use(router)
app.use(i18n)

app.mount('#app')

5.6 国际化实现

为了支持全球化用户,本项目使用vue-i18n实现了完整的国际化功能。国际化配置位于src/renderer/src/i18n/index.js

import { createI18n } from 'vue-i18n'

// 导入语言包
import zhCN from './zh-CN.js'
import zhTW from './zh-TW.js'
import enUS from './en-US.js'

// 获取存储的语言设置,如果没有则使用默认语言
const storedLanguage = localStorage.getItem('language') || 'zh-CN'

// 创建i18n实例
const i18n = createI18n({
  legacy: false, // 使用 Composition API 模式
  locale: storedLanguage, // 设置当前语言
  fallbackLocale: 'zh-CN', // 设置回退语言
  messages: {
    'zh-CN': zhCN,
    'zh-TW': zhTW,
    'en-US': enUS
  }
})

export default i18n

语言包示例(中文):

export default {
  app: {
    name: '多媒体浏览器',
    description: '一个基于Electron和Vue.js的跨平台媒体浏览应用'
  },
  navigation: {
    home: '首页',
    media: '媒体浏览',
    settings: '设置'
  },
  settings: {
    title: '应用设置',
    display: '显示设置',
    media: '媒体设置',
    interface: '界面设置',
    about: '关于应用',
    defaultViewMode: '默认视图模式',
    defaultSortBy: '默认排序方式',
    defaultSortOrder: '默认排序顺序',
    defaultMediaType: '默认媒体类型',
    showFileExtensions: '显示文件扩展名',
    showHiddenFiles: '显示隐藏文件',
    theme: '主题',
    language: '语言',
    appName: '应用名称',
    version: '版本',
    description: '描述',
    license: '许可证',
    save: '保存设置',
    reset: '重置为默认值',
    confirmReset: '确定要重置所有设置为默认值吗?',
    settingsSaved: '设置已保存!'
  },
  options: {
    gridView: '网格视图',
    listView: '列表视图',
    sortByName: '按名称排序',
    sortByDate: '按日期排序',
    sortBySize: '按大小排序',
    ascending: '升序',
    descending: '降序',
    allTypes: '所有类型',
    imagesOnly: '仅图片',
    videosOnly: '仅视频',
    audioOnly: '仅音频',
    lightTheme: '浅色模式',
    darkTheme: '深色模式',
    systemTheme: '跟随系统',
    simplifiedChinese: '简体中文',
    traditionalChinese: '繁体中文',
    english: 'English'
  },
  media: {
    searchPlaceholder: '搜索媒体文件...',
    selectType: '选择媒体类型',
    image: '图片',
    video: '视频',
    audio: '音频',
    noPathSelected: '未选择路径',
    selectDirectory: '选择目录'
  }
}

在组件中使用国际化:

<template>
  <div class="example">
    <h1>{{ t('app.name') }}</h1>
    <p>{{ t('app.description') }}</p>
  </div>
</template>

<script setup>
import { useI18n } from 'vue-i18n'

const { t } = useI18n()
</script>

5.7 设置页面实现

设置页面集成了主题切换、语言选择等功能,是用户自定义应用行为的中心。以下是设置页面的核心实现:

<template>
  <div class="settings-view">
    <el-card class="header-card" shadow="never">
      <template #header>
        <div class="card-header">
          <el-icon><Setting /></el-icon>
          <span class="header-title">{{ t('settings.title') }}</span>
        </div>
      </template>
    </el-card>

    <el-card shadow="never" class="mt-4">
      <el-form>
        <!-- 显示设置部分 -->
        <el-form-item label="" prop="section-title">
          <el-divider content-position="left">
            <el-text type="primary" size="large">
              <strong>{{ t('settings.display') }}</strong>
            </el-text>
          </el-divider>
        </el-form-item>

        <!-- 视图模式、排序方式等设置项 -->
        <el-form-item :label="t('settings.defaultViewMode')">
          <el-select v-model="defaultViewMode" style="width: 100%" @change="updateDefaultViewMode">
            <el-option :label="t('options.gridView')" value="grid" />
            <el-option :label="t('options.listView')" value="list" />
          </el-select>
        </el-form-item>

        <!-- 媒体设置部分 -->
        <el-form-item label="" prop="section-title">
          <el-divider content-position="left">
            <el-text type="primary" size="large">
              <strong>{{ t('settings.media') }}</strong>
            </el-text>
          </el-divider>
        </el-form-item>

        <!-- 默认媒体类型等设置项 -->
        <el-form-item :label="t('settings.defaultMediaType')">
          <el-select v-model="defaultMediaType" style="width: 100%" @change="updateDefaultMediaType">
            <el-option :label="t('options.allTypes')" value="all" />
            <el-option :label="t('options.imagesOnly')" value="images" />
            <el-option :label="t('options.videosOnly')" value="videos" />
            <el-option :label="t('options.audioOnly')" value="audio" />
          </el-select>
        </el-form-item>

        <!-- 界面设置部分 -->
        <el-form-item label="" prop="section-title">
          <el-divider content-position="left">
            <el-text type="primary" size="large">
              <strong>{{ t('settings.interface') }}</strong>
            </el-text>
          </el-divider>
        </el-form-item>

        <!-- 主题设置 -->
        <el-form-item :label="t('settings.theme')">
          <el-select v-model="theme" style="width: 100%" @change="updateTheme">
            <el-option :label="t('options.lightTheme')" value="light" />
            <el-option :label="t('options.darkTheme')" value="dark" />
            <el-option :label="t('options.systemTheme')" value="system" />
          </el-select>
        </el-form-item>

        <!-- 语言设置 -->
        <el-form-item :label="t('settings.language')">
          <el-select v-model="language" style="width: 100%" @change="updateLanguage">
            <el-option :label="t('options.simplifiedChinese')" value="zh-CN" />
            <el-option :label="t('options.traditionalChinese')" value="zh-TW" />
            <el-option :label="t('options.english')" value="en-US" />
          </el-select>
        </el-form-item>

        <!-- 关于应用部分 -->
        <el-form-item label="" prop="section-title">
          <el-divider content-position="left">
            <el-text type="primary" size="large">
              <strong>{{ t('settings.about') }}</strong>
            </el-text>
          </el-divider>
        </el-form-item>

        <!-- 应用信息 -->
        <el-form-item>
          <el-card class="about-card">
            <el-descriptions :column="1" border>
              <el-descriptions-item :label="t('settings.appName')">{{
                t('app.name')
              }}</el-descriptions-item>
              <el-descriptions-item :label="t('settings.version')">1.0.0</el-descriptions-item>
              <el-descriptions-item :label="t('settings.description')">{{
                t('app.description')
              }}</el-descriptions-item>
              <el-descriptions-item :label="t('settings.license')">
                MIT License
              </el-descriptions-item>
            </el-descriptions>
          </el-card>
        </el-form-item>
      </el-form>
    </el-card>

    <!-- 操作按钮 -->
    <div class="actions">
      <el-button type="primary" icon="Save" @click="saveSettings">{{
        t('settings.save')
      }}</el-button>
      <el-button type="default" icon="RefreshRight" @click="resetSettings">{{
        t('settings.reset')
      }}</el-button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMediaStore } from '../store/modules/media.js'
import { applyTheme, saveAndApplyTheme, setupSystemThemeListener } from '../utils/themeUtils.js'

const { t, locale } = useI18n()
const mediaStore = useMediaStore()

// 设置状态
const defaultViewMode = ref('grid')
const defaultSortBy = ref('name')
const defaultSortOrder = ref('asc')
const defaultMediaType = ref('all')
const showFileExtensions = ref(false)
const showHiddenFiles = ref(false)
const theme = ref('light')
const language = ref('zh-CN')

// 用于移除事件监听器的清理函数
let cleanupSystemThemeListener = null

// 从store加载设置
const loadSettings = () => {
  const storeSettings = mediaStore.settings

  defaultViewMode.value = storeSettings.viewMode || 'grid'
  defaultSortBy.value = storeSettings.sortBy || 'name'
  defaultSortOrder.value = storeSettings.sortOrder || 'asc'
  defaultMediaType.value = mediaStore.filter.type || 'all'

  // 从localStorage加载其他设置
  showFileExtensions.value = localStorage.getItem('showFileExtensions') === 'true'
  showHiddenFiles.value = localStorage.getItem('showHiddenFiles') === 'true'
  theme.value = localStorage.getItem('theme') || 'light'
  language.value = localStorage.getItem('language') || 'zh-CN'
}

// 更新设置的方法
const updateDefaultViewMode = () => {
  mediaStore.updateSettings({ viewMode: defaultViewMode.value })
}

const updateDefaultSortBy = () => {
  mediaStore.updateSettings({ sortBy: defaultSortBy.value })
}

const updateDefaultSortOrder = () => {
  mediaStore.updateSettings({ sortOrder: defaultSortOrder.value })
}

const updateDefaultMediaType = () => {
  mediaStore.updateFilter({ type: defaultMediaType.value })
}

const updateShowFileExtensions = () => {
  localStorage.setItem('showFileExtensions', showFileExtensions.value)
}

const updateShowHiddenFiles = () => {
  localStorage.setItem('showHiddenFiles', showHiddenFiles.value)
}

const updateTheme = () => {
  saveAndApplyTheme(theme.value)
}

const updateLanguage = () => {
  localStorage.setItem('language', language.value)
  // 更新i18n的locale
  locale.value = language.value
}

// 保存所有设置
const saveSettings = () => {
  // 已经通过update方法保存了大部分设置
  // 显示保存成功的提示
  alert(t('settings.settingsSaved'))
}

// 重置为默认值
const resetSettings = () => {
  if (confirm(t('settings.confirmReset'))) {
    // 重置store设置
    mediaStore.updateSettings({
      viewMode: 'grid',
      sortBy: 'name',
      sortOrder: 'asc'
    })

    mediaStore.updateFilter({
      type: 'all'
    })

    // 重置localStorage设置
    localStorage.setItem('showFileExtensions', 'false')
    localStorage.setItem('showHiddenFiles', 'false')
    localStorage.setItem('theme', 'light')
    localStorage.setItem('language', 'zh-CN')

    // 重新加载设置
    loadSettings()
  }
}

// 组件挂载时加载设置
onMounted(() => {
  loadSettings()
  applyTheme(theme.value)

  // 设置系统主题变化的事件监听器
  cleanupSystemThemeListener = setupSystemThemeListener()
})

// 组件卸载时清理事件监听器
onUnmounted(() => {
  if (cleanupSystemThemeListener) {
    cleanupSystemThemeListener()
  }
})
</script>

<style scoped>
/* 样式部分省略 */
</style>

渲染进程负责用户界面的呈现,而应用通常还需要与外部服务器通信。接下来我们将探讨在Electron应用中如何实现安全、高效的网络请求。

6. 网络请求实现

在Electron应用中,网络请求是与后端服务交互的重要方式。由于Electron结合了Node.js和Chromium,你可以在不同进程中使用不同的方式发起网络请求。

6.1 在渲染进程中发起网络请求

在渲染进程中,你可以使用标准的Web API(如fetch、XMLHttpRequest)或第三方库(如axios)来发起网络请求。

6.1.1 使用axios发起请求

// 在渲染进程中使用axios
import axios from 'axios'

// 创建axios实例
const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json'
  }
})

// 添加请求拦截器
apiClient.interceptors.request.use(
  config => {
    // 可以在这里添加认证token等
    const token = localStorage.getItem('authToken')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// 添加响应拦截器
apiClient.interceptors.response.use(
  response => {
    return response.data
  },
  error => {
    // 统一错误处理
    console.error('API请求错误:', error)
    // 显示错误提示
    ElMessage.error(`请求失败: ${error.response?.data?.message || '未知错误'}`)
    return Promise.reject(error)
  }
)

// 发送GET请求
async function fetchData() {
  try {
    const data = await apiClient.get('/data')
    console.log('获取数据成功:', data)
    return data
  } catch (error) {
    console.error('获取数据失败:', error)
    throw error
  }
}

// 发送POST请求
async function submitData(formData) {
  try {
    const response = await apiClient.post('/submit', formData)
    console.log('提交数据成功:', response)
    return response
  } catch (error) {
    console.error('提交数据失败:', error)
    throw error
  }
}

6.1.2 使用fetch API

// 在渲染进程中使用fetch API

// 配置默认选项
const defaultOptions = {
  headers: {
    'Content-Type': 'application/json'
  },
  credentials: 'include' // 包含cookies
}

// 封装fetch请求
async function fetchApi(url, options = {}) {
  try {
    // 合并选项
    const mergedOptions = { ...defaultOptions, ...options }

    // 添加认证token
    const token = localStorage.getItem('authToken')
    if (token) {
      mergedOptions.headers = {
        ...mergedOptions.headers,
        Authorization: `Bearer ${token}`
      }
    }

    // 发送请求
    const response = await fetch(url, mergedOptions)

    // 检查响应状态
    if (!response.ok) {
      throw new Error(`HTTP错误! 状态码: ${response.status}`)
    }

    // 解析JSON响应
    const data = await response.json()
    return data
  } catch (error) {
    console.error('请求错误:', error)
    ElMessage.error(`请求失败: ${error.message}`)
    throw error
  }
}

// 发送GET请求
async function getData() {
  return fetchApi('https://api.example.com/data')
}

// 发送POST请求
async function postData(data) {
  return fetchApi('https://api.example.com/submit', {
    method: 'POST',
    body: JSON.stringify(data)
  })
}

6.2 在主进程中发起网络请求

在主进程中,你可以使用Node.js的httphttps模块,或者也可以使用axios等第三方库。

6.2.1 使用Node.js原生模块

// 在主进程中使用http/https模块
const https = require('https')
const { app } = require('electron')

// 封装https请求
function httpsRequest(url, options = {}, data = null) {
  return new Promise((resolve, reject) => {
    // 解析URL
    const parsedUrl = new URL(url)

    // 合并请求选项
    const requestOptions = {
      hostname: parsedUrl.hostname,
      port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
      path: parsedUrl.pathname + parsedUrl.search,
      method: options.method || 'GET',
      headers: {
        ...options.headers,
        'Content-Type': 'application/json'
      }
    }

    // 发送请求
    const req = https.request(requestOptions, (res) => {
      let responseData = ''

      // 接收数据
      res.on('data', (chunk) => {
        responseData += chunk
      })

      // 完成请求
      res.on('end', () => {
        try {
          // 尝试解析JSON
          if (res.headers['content-type']?.includes('application/json')) {
            responseData = JSON.parse(responseData)
          }

          // 检查响应状态
          if (res.statusCode >= 200 && res.statusCode < 300) {
            resolve(responseData)
          } else {
            reject(new Error(`请求失败: ${res.statusCode} ${responseData}`))
          }
        } catch (error) {
          reject(new Error(`响应解析错误: ${error.message}`))
        }
      })
    })

    // 处理错误
    req.on('error', (error) => {
      reject(new Error(`请求错误: ${error.message}`))
    })

    // 发送数据(如果有)
    if (data) {
      req.write(JSON.stringify(data))
    }

    // 结束请求
    req.end()
  })
}

// 使用示例
async function fetchFromMainProcess() {
  try {
    const response = await httpsRequest('https://api.example.com/data')
    console.log('主进程请求成功:', response)
    return response
  } catch (error) {
    console.error('主进程请求失败:', error)
    throw error
  }
}

6.2.2 使用axios

// 在主进程中使用axios
const axios = require('axios')

// 创建axios实例
const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000
})

// 添加请求拦截器
apiClient.interceptors.request.use(
  config => {
    // 添加认证token
    const token = getAuthTokenFromSecureStorage() // 从安全存储中获取token
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// 添加响应拦截器
apiClient.interceptors.response.use(
  response => {
    return response.data
  },
  error => {
    // 统一错误处理
    console.error('主进程API请求错误:', error)
    return Promise.reject(error)
  }
)

// 从安全存储中获取token
function getAuthTokenFromSecureStorage() {
  // 实际项目中,可能会使用keytar等库安全存储凭证
  // 这里仅作示例
  return global.authToken
}

6.3 通过IPC在进程间共享网络请求

在Electron应用中,你可以通过IPC在主进程和渲染进程之间共享网络请求逻辑。这种方式特别适合处理需要在多个渲染进程中共用的API请求,或者需要访问敏感信息(如API密钥)的请求。

6.3.1 主进程中实现API请求服务

// src/main/apiService.js
const axios = require('axios')
const { ipcMain } = require('electron')

// 创建axios实例
const apiClient = axios.create({
  baseURL: 'https://api.example.com',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json',
    'X-API-KEY': process.env.API_KEY // 敏感信息从环境变量获取
  }
})

// 设置拦截器
apiClient.interceptors.request.use(config => {
  console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`)
  return config
})

apiClient.interceptors.response.use(
  response => {
    console.log(`[API Response] ${response.config?.url} - ${response.status}`)
    return response.data
  },
  error => {
    console.error(`[API Error] ${error.config?.url} - ${error.message}`)
    throw error
  }
)

// 定义API方法
const apiService = {
  async fetchData(params) {
    return apiClient.get('/data', { params })
  },

  async submitData(data) {
    return apiClient.post('/submit', data)
  },

  async updateItem(id, data) {
    return apiClient.put(`/items/${id}`, data)
  },

  async deleteItem(id) {
    return apiClient.delete(`/items/${id}`)
  }
}

// 注册IPC处理程序
function registerApiHandlers() {
  ipcMain.handle('api:fetchData', async (event, params) => {
    try {
      return await apiService.fetchData(params)
    } catch (error) {
      // 处理错误并返回给渲染进程
      return {
        error: {
          message: error.message,
          code: error.response?.status || 'UNKNOWN_ERROR'
        }
      }
    }
  })

  ipcMain.handle('api:submitData', async (event, data) => {
    try {
      return await apiService.submitData(data)
    } catch (error) {
      return {
        error: {
          message: error.message,
          code: error.response?.status || 'UNKNOWN_ERROR'
        }
      }
    }
  })

  ipcMain.handle('api:updateItem', async (event, { id, data }) => {
    try {
      return await apiService.updateItem(id, data)
    } catch (error) {
      return {
        error: {
          message: error.message,
          code: error.response?.status || 'UNKNOWN_ERROR'
        }
      }
    }
  })

  ipcMain.handle('api:deleteItem', async (event, id) => {
    try {
      return await apiService.deleteItem(id)
    } catch (error) {
      return {
        error: {
          message: error.message,
          code: error.response?.status || 'UNKNOWN_ERROR'
        }
      }
    }
  })
}

module.exports = { registerApiHandlers }

然后在主进程入口文件中注册这些处理程序:

// src/main/index.js
const { app, BrowserWindow } = require('electron')
const { registerApiHandlers } = require('./apiService')

function createWindow() {
  // 创建窗口代码...
}

app.whenReady().then(() => {
  createWindow()

  // 注册API处理程序
  registerApiHandlers()

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

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

6.3.2 渲染进程中调用API服务

在渲染进程中,通过预加载脚本暴露的IPC API来调用主进程中的API服务:

// src/preload/index.js
const { contextBridge, ipcRenderer } = require('electron')

// 定义API接口
export const api = {
  fetchData: (params) => ipcRenderer.invoke('api:fetchData', params),
  submitData: (data) => ipcRenderer.invoke('api:submitData', data),
  updateItem: ({ id, data }) => ipcRenderer.invoke('api:updateItem', { id, data }),
  deleteItem: (id) => ipcRenderer.invoke('api:deleteItem', id)
}

// 暴露API到渲染进程
contextBridge.exposeInMainWorld('api', api)

在Vue组件中使用:

<template>
  <div class="api-example">
    <el-button @click="fetchData">获取数据</el-button>
    <el-button @click="submitData">提交数据</el-button>

    <el-table v-if="items.length > 0" :data="items">
      <el-table-column prop="id" label="ID" />
      <el-table-column prop="name" label="名称" />
      <el-table-column prop="value" label="值" />
      <el-table-column label="操作">
        <template #default="{ row }">
          <el-button size="small" @click="updateItem(row)">更新</el-button>
          <el-button size="small" type="danger" @click="deleteItem(row.id)">删除</el-button>
        </template>
      </el-table-column>
    </el-table>

    <div v-else class="no-data">暂无数据</div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'

const items = ref([])
const loading = ref(false)

// 获取数据
async function fetchData() {
  loading.value = true
  try {
    const result = await window.api.fetchData({ page: 1, limit: 10 })

    // 检查是否有错误
    if (result.error) {
      ElMessage.error(result.error.message)
      return
    }

    items.value = result.data || []
  } catch (error) {
    console.error('获取数据失败:', error)
    ElMessage.error('获取数据失败')
  } finally {
    loading.value = false
  }
}

// 提交数据
async function submitData() {
  loading.value = true
  try {
    const newItem = {
      name: '新项',
      value: Math.random().toString(36).substr(2, 9)
    }

    const result = await window.api.submitData(newItem)

    if (result.error) {
      ElMessage.error(result.error.message)
      return
    }

    ElMessage.success('提交成功')
    fetchData() // 重新获取数据
  } catch (error) {
    console.error('提交数据失败:', error)
    ElMessage.error('提交数据失败')
  } finally {
    loading.value = false
  }
}

// 更新数据
async function updateItem(item) {
  loading.value = true
  try {
    const updatedItem = {
      ...item,
      value: Math.random().toString(36).substr(2, 9)
    }

    const result = await window.api.updateItem({ id: item.id, data: updatedItem })

    if (result.error) {
      ElMessage.error(result.error.message)
      return
    }

    ElMessage.success('更新成功')
    fetchData() // 重新获取数据
  } catch (error) {
    console.error('更新数据失败:', error)
    ElMessage.error('更新数据失败')
  } finally {
    loading.value = false
  }
}

// 删除数据
async function deleteItem(id) {
  loading.value = true
  try {
    const result = await window.api.deleteItem(id)

    if (result.error) {
      ElMessage.error(result.error.message)
      return
    }

    ElMessage.success('删除成功')
    fetchData() // 重新获取数据
  } catch (error) {
    console.error('删除数据失败:', error)
    ElMessage.error('删除数据失败')
  } finally {
    loading.value = false
  }
}

// 组件挂载时获取数据
fetchData()
</script>

6.4 网络请求的最佳实践

6.4.1 错误处理

  • 统一错误处理:在主进程和渲染进程中都实现统一的错误处理机制
  • 用户友好提示:对于用户可见的错误,提供清晰、友好的错误信息
  • 错误日志:记录详细的错误日志,便于调试和问题追踪
  • 重试机制:对临时性网络错误实现自动重试机制

6.4.2 安全考虑

  • 敏感信息保护:API密钥等敏感信息应存储在主进程中,避免在渲染进程中暴露
  • HTTPS使用:所有网络请求都应使用HTTPS
  • 证书验证:确保正确验证SSL证书,防止中间人攻击
  • 请求超时:设置合理的请求超时,避免长时间等待

6.4.3 性能优化

  • 请求缓存:对频繁请求且数据变化不频繁的接口实现缓存
  • 请求合并:合并相似请求,减少网络往返
  • 请求节流和防抖:在用户输入场景中使用节流或防抖,避免发送过多请求
  • 批量操作:支持批量创建、更新或删除操作,减少API调用次数

6.4.4 调试工具

  • 请求日志:记录所有API请求和响应的详细信息
  • 开发工具集成:在开发环境中集成网络请求调试工具
  • Mock数据:在开发过程中使用Mock数据,减少对真实API的依赖

通过合理使用这些技术和最佳实践,你可以在Electron应用中构建稳定、高效、安全的网络请求系统。

网络请求系统是应用功能的重要组成部分,但要确保整个应用的质量和稳定性,我们还需要建立完善的测试体系。接下来我们将介绍Electron应用的测试策略。

7. 测试策略

在Electron应用开发中,测试是确保应用质量和稳定性的关键环节。一个完善的测试策略应包含单元测试、集成测试和端到端测试。

7.1 测试框架选择

对于Electron + Vue应用,推荐以下测试工具组合:

# 安装测试依赖
npm install --save-dev vitest @vue/test-utils electron-mocha spectron

7.2 单元测试实现

7.2.1 渲染进程组件测试

使用Vitest和Vue Test Utils测试Vue组件:

// tests/components/MediaView.spec.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import MediaView from '@renderer/views/MediaView.vue'
import { createPinia } from 'pinia'
import { useMediaStore } from '@renderer/store/modules/media'

// Mock全局API
Object.defineProperty(window, 'api', {
  value: {
    openDirectory: vi.fn(),
    getFilesInDirectory: vi.fn()
  },
  writable: true
})

describe('MediaView组件测试', () => {
  let wrapper
  let store

  beforeEach(() => {
    const pinia = createPinia()
    store = useMediaStore(pinia)

    // 重置store状态
    store.$reset()
    store.setMediaFiles([
      { name: 'test1.jpg', path: '/path/to/test1.jpg', type: 'images', size: 1024, modifiedTime: Date.now() }
    ])

    wrapper = mount(MediaView, {
      global: {
        plugins: [pinia],
        stubs: {
          'el-card': true,
          'el-input': true,
          'el-select': true,
          'el-button': true,
          'el-breadcrumb': true,
          'el-breadcrumb-item': true
        }
      }
    })
  })

  it('组件应该正确渲染', () => {
    expect(wrapper.exists()).toBeTruthy()
    expect(wrapper.text()).toContain('媒体浏览')
  })

  it('点击选择目录按钮应该调用api.openDirectory', async () => {
    const button = wrapper.find('button')
    await button.trigger('click')
    expect(window.api.openDirectory).toHaveBeenCalled()
  })

  it('搜索功能应该更新store的筛选条件', async () => {
    const input = wrapper.find('input')
    await input.setValue('test')
    expect(store.filter.search).toBe('test')
  })
})

7.2.2 主进程模块测试

使用electron-mocha测试主进程模块:

// tests/main/security.spec.js
const { describe, it, beforeEach, afterEach } = require('mocha')
const { expect } = require('chai')
const { BrowserWindow } = require('electron')
const { setupSecurity } = require('../../../electron/main/security')

describe('安全设置测试', () => {
  let win

  beforeEach(() => {
    win = new BrowserWindow({ show: false })
  })

  afterEach(() => {
    if (win) {
      win.destroy()
    }
  })

  it('setupSecurity应该正确配置安全选项', () => {
    setupSecurity()
    // 验证安全设置是否生效
    expect(win.webContents.session.webRequest.onHeadersReceived).to.be.a('function')
  })
})

7.3 集成测试

测试渲染进程与主进程之间的IPC通信:

// tests/integration/ipc.spec.js
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ipcMain, ipcRenderer } from 'electron'
import { createIpcHandlers } from '../../electron/main/events'

describe('IPC通信测试', () => {
  beforeEach(() => {
    // 清除所有IPC监听器
    ipcMain.removeAllListeners()
    // 创建测试窗口
    const testWindow = { webContents: { on: vi.fn() } }
    // 初始化IPC处理器
    createIpcHandlers(testWindow)
  })

  it('主题切换IPC应该正常工作', async () => {
    // Mock ipcMain.handle
    const mockHandle = vi.spyOn(ipcMain, 'handle')

    // 发送IPC请求
    const result = await ipcRenderer.invoke('theme:set-theme', 'dark')

    // 验证处理函数被调用
    expect(mockHandle).toHaveBeenCalledWith('theme:set-theme', expect.any(Function))
    expect(result).toBeUndefined()
  })
})

7.4 端到端测试

使用Spectron进行端到端测试,模拟用户操作:

// tests/e2e/app.spec.js
const { Application } = require('spectron')
const { expect } = require('chai')
const path = require('path')

describe('应用端到端测试', function () {
  this.timeout(10000) // 增加超时时间

  let app

  beforeEach(async () => {
    app = new Application({
      path: path.join(__dirname, '../../node_modules/.bin/electron'),
      args: [path.join(__dirname, '../../')]
    })
    await app.start()
  })

  afterEach(async () => {
    if (app && app.isRunning()) {
      await app.stop()
    }
  })

  it('应用应该成功启动并显示主窗口', async () => {
    const isVisible = await app.browserWindow.isVisible()
    expect(isVisible).to.be.true
  })

  it('应该能够导航到设置页面', async () => {
    // 点击设置菜单
    await app.client.click('[data-test-id="settings-menu"]')

    // 检查URL是否包含settings
    const url = await app.client.getUrl()
    expect(url).to.include('settings')
  })

  it('应该能够切换主题', async () => {
    // 导航到设置页面
    await app.client.click('[data-test-id="settings-menu"]')

    // 选择深色主题
    await app.client.selectByValue('[data-test-id="theme-select"]', 'dark')

    // 检查主题是否已应用
    const className = await app.client.getHTML('body')
    expect(className).to.include('dark-theme')
  })
})

7.5 测试脚本配置

在package.json中添加测试脚本:

{
  "scripts": {
    "test": "npm run test:unit && npm run test:integration",
    "test:unit": "vitest run tests/unit",
    "test:integration": "vitest run tests/integration",
    "test:e2e": "mocha tests/e2e --timeout 20000",
    "test:main": "electron-mocha tests/main"
  }
}

7.6 测试最佳实践

  1. 测试覆盖率监控:使用nyc或Vitest的覆盖率报告监控测试覆盖率

    npm install --save-dev nyc
    
  2. CI/CD集成:在持续集成流程中自动运行测试

    # .github/workflows/test.yml示例
    name: Tests
    on: [push, pull_request]
    jobs:
      test:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v3
          - uses: actions/setup-node@v3
            with:
              node-version: '16'
          - run: npm install
          - run: npm test
          - run: npm run test:e2e
    
  3. 模拟依赖:在测试中使用mock隔离外部依赖,确保测试的可靠性

  4. 测试环境一致性:使用Docker容器或其他工具确保测试环境的一致性

  5. 自动化回归测试:定期运行完整的测试套件,防止功能回归

通过实施这些测试策略,可以显著提高Electron应用的质量和稳定性,减少生产环境中的bug,提升用户体验。

确保应用质量后,接下来需要考虑如何将应用构建并打包为可分发的安装包。我们将介绍Electron应用的构建和打包流程。

8. 项目构建与打包

8.1 开发模式

在开发模式下,electron-vite 提供了热模块替换功能,可以极大地提升开发效率:

npm run dev

8.2 构建应用

构建生产版本的应用:

npm run build

构建过程会执行以下步骤:

  1. 清理dist目录
  2. 使用electron-vite构建主进程、预加载脚本和渲染进程
  3. 优化资源文件
  4. 生成可执行文件的基础文件结构

8.3 打包为安装包

electron-vite结合electron-builder可以将应用打包为各个平台的安装包:

# 打包为Windows安装包
npm run build:win

# 打包为macOS安装包
npm run build:mac

# 打包为Linux安装包
npm run build:linux

打包配置在package.json中定义:

{
  "build": {
    "appId": "com.example.multimedia-browser",
    "productName": "多媒体浏览器",
    "copyright": "Copyright © 2023 example.com",
    "directories": {
      "output": "dist"
    },
    "files": [
      "dist-electron",
      "dist"
    ],
    "win": {
      "target": [
        "nsis",
        "zip"
      ],
      "icon": "public/icon.ico"
    },
    "mac": {
      "target": [
        "dmg",
        "zip"
      ],
      "icon": "public/icon.icns",
      "hardenedRuntime": true,
      "entitlements": "build/entitlements.mac.plist",
      "entitlementsInherit": "build/entitlements.mac.plist"
    },
    "linux": {
      "target": [
        "AppImage",
        "deb",
        "rpm"
      ],
      "icon": "public"
    }
  }
}

完成应用的构建和打包后,我们还需要关注应用的稳定性和可维护性。接下来我们将探讨Electron应用中的错误处理和调试技巧,帮助开发者快速定位和解决问题。

9. 错误处理与调试

在Electron应用开发中,完善的错误处理和调试机制对于提高应用稳定性和开发效率至关重要。本章节将详细介绍Electron应用中的错误处理策略和调试技巧。

9.1 主进程错误处理

主进程作为应用的核心,需要捕获和处理各种可能的错误,确保应用不会意外崩溃。

// 在主进程入口文件中添加全局错误处理器

// 捕获未处理的异常
process.on('uncaughtException', (error) => {
  console.error('未捕获的异常:', error)
  // 记录错误日志
  logger.error('未捕获的异常:', error)
  // 显示错误对话框
  dialog.showErrorBox('应用错误', `应用遇到问题:\n${error.message}`)
  // 可以选择重启应用或执行其他恢复操作
})

// 捕获Promise拒绝
process.on('unhandledRejection', (reason) => {
  console.error('未处理的Promise拒绝:', reason)
  logger.error('未处理的Promise拒绝:', reason)
})

9.2 渲染进程错误处理

渲染进程中需要处理DOM错误、JavaScript错误以及IPC通信错误。

// 在渲染进程入口文件中添加错误处理器

// 捕获JavaScript错误
window.addEventListener('error', (event) => {
  console.error('JavaScript错误:', event.error)
  // 发送错误信息到主进程进行日志记录
  window.electron.ipcRenderer.send('log-error', {
    type: 'javascript',
    error: event.error.message,
    stack: event.error.stack,
    url: event.filename,
    line: event.lineno,
    column: event.colno
  })
})

// 捕获资源加载错误
window.addEventListener('error', (event) => {
  if (event.target && (event.target.tagName === 'IMG' || event.target.tagName === 'SCRIPT')) {
    console.error('资源加载错误:', event.target.src || event.target.href)
    window.electron.ipcRenderer.send('log-error', {
      type: 'resource',
      url: event.target.src || event.target.href
    })
  }
}, true)

// 捕获Promise拒绝
window.addEventListener('unhandledrejection', (event) => {
  console.error('未处理的Promise拒绝:', event.reason)
  window.electron.ipcRenderer.send('log-error', {
    type: 'promise',
    error: event.reason?.message || String(event.reason),
    stack: event.reason?.stack
  })
})

9.3 IPC通信错误处理

IPC通信错误可能发生在主进程和渲染进程之间,需要妥善处理以避免应用不稳定。

// 主进程中处理IPC错误
ipcMain.handle('some-channel', async (event, ...args) => {
  try {
    // 执行操作
    return await someOperation(...args)
  } catch (error) {
    console.error('IPC处理错误:', error)
    logger.error('IPC处理错误:', error)
    // 可以选择返回错误信息给渲染进程
    throw error
  }
})

// 渲染进程中处理IPC错误
async function callMainProcessMethod() {
  try {
    const result = await window.electron.ipcRenderer.invoke('some-channel', params)
    return result
  } catch (error) {
    console.error('调用主进程方法失败:', error)
    // 显示友好的错误提示
    ElMessage.error(`操作失败: ${error.message || '未知错误'}`)
    // 可以记录错误或执行恢复操作
    throw error
  }
}

9.4 调试技巧

Electron应用提供了多种调试工具和技巧,帮助开发者快速定位和解决问题。

9.4.1 渲染进程调试

  • 使用Chrome开发者工具:在开发模式下,可以通过mainWindow.webContents.openDevTools()自动打开开发者工具
  • 设置断点:在代码中使用debugger语句设置断点
  • 日志记录:使用console.logconsole.error等方法记录日志信息

9.4.2 主进程调试

  • 使用VSCode调试:配置VSCode的launch.json文件,支持主进程调试
  • 使用Node.js调试器:通过--inspect参数启用Node.js调试器
    electron --inspect=5858 .
    
  • 远程调试:通过Chrome浏览器的chrome://inspect页面连接到Node.js调试器

9.4.3 日志系统实现

实现一个完整的日志系统,可以帮助跟踪应用运行状态和错误信息:

// logger.js
const fs = require('fs')
const path = require('path')
const { app } = require('electron')

// 确保日志目录存在
const logDir = path.join(app.getPath('userData'), 'logs')
if (!fs.existsSync(logDir)) {
  fs.mkdirSync(logDir, { recursive: true })
}

// 获取当前日期作为日志文件名
const getLogFileName = () => {
  const now = new Date()
  const year = now.getFullYear()
  const month = String(now.getMonth() + 1).padStart(2, '0')
  const day = String(now.getDate()).padStart(2, '0')
  return `${year}-${month}-${day}.log`
}

// 写入日志到文件
const writeLogToFile = (level, message, error) => {
  const timestamp = new Date().toISOString()
  let logMessage = `[${timestamp}] [${level}] ${message}\n`

  if (error) {
    logMessage += `Error: ${error.message}\nStack: ${error.stack || 'No stack trace available'}\n`
  }

  const logFilePath = path.join(logDir, getLogFileName())

  fs.appendFile(logFilePath, logMessage, (err) => {
    if (err) {
      console.error('写入日志文件失败:', err)
    }
  })
}

// 日志对象
exports.logger = {
  info: (message) => {
    console.log(`[INFO] ${message}`)
    writeLogToFile('INFO', message)
  },
  warn: (message, error = null) => {
    console.warn(`[WARN] ${message}`, error)
    writeLogToFile('WARN', message, error)
  },
  error: (message, error = null) => {
    console.error(`[ERROR] ${message}`, error)
    writeLogToFile('ERROR', message, error)
  },
  debug: (message) => {
    if (process.env.NODE_ENV === 'development') {
      console.debug(`[DEBUG] ${message}`)
      writeLogToFile('DEBUG', message)
    }
  }
}

9.5 错误恢复策略

实现错误恢复机制,让应用在遇到问题时能够自动恢复或提供用户选择恢复方式:

// 主进程中实现应用重启功能
function restartApp() {
  const { execPath } = process
  const args = process.argv.slice(1)

  // 退出当前进程
  app.exit(0)

  // 启动新进程
  require('child_process').spawn(execPath, args, {
    detached: true,
    stdio: 'ignore'
  })
}

// 错误对话框选项
function showErrorWithRestart(error, message = '应用遇到问题,是否重启?') {
  dialog.showMessageBox({
    type: 'error',
    title: '应用错误',
    message: message,
    detail: error.message,
    buttons: ['重启应用', '忽略']
  }).then(result => {
    if (result.response === 0) {
      restartApp()
    }
  })
}

确保应用稳定性后,我们还需要关注应用的性能表现。良好的性能是提升用户体验的关键因素,接下来我们将介绍Electron应用的性能优化策略。

10. 性能优化

性能优化是Electron应用开发中的重要环节,直接影响用户体验。本章节将介绍几种关键的性能优化策略。

10.1 渲染进程性能优化

渲染进程负责UI渲染,其性能直接影响界面响应速度和动画流畅度。

10.1.1 懒加载组件

使用Vue的路由懒加载和组件懒加载功能,减少应用初始加载时间:

// 路由懒加载
const routes = [
  {
    path: '/home',
    name: 'Home',
    component: () => import('../views/Home.vue')
  },
  {
    path: '/settings',
    name: 'Settings',
    component: () => import('../views/Settings.vue')
  }
]

// 组件懒加载
const HeavyComponent = () => import('./HeavyComponent.vue')

10.1.2 使用Web Workers

将耗时操作放在Web Workers中执行,避免阻塞主线程:

// 创建Worker
const worker = new Worker(new URL('../workers/dataProcessor.js', import.meta.url))

// 发送数据到Worker
worker.postMessage({ type: 'PROCESS', data: largeDataset })

// 接收Worker处理结果
worker.onmessage = (event) => {
  const { type, result } = event.data
  if (type === 'PROCESS_COMPLETE') {
    // 处理结果
    updateUI(result)
  }
}

// dataProcessor.js
self.onmessage = (event) => {
  const { type, data } = event.data
  if (type === 'PROCESS') {
    // 执行耗时操作
    const result = complexProcessing(data)
    // 返回结果
    self.postMessage({ type: 'PROCESS_COMPLETE', result })
  }
}

10.1.3 避免内存泄漏

在组件卸载时清理事件监听器、定时器和异步操作:

// Vue组件中的清理工作
export default {
  data() {
    return {
      timer: null,
      worker: null
    }
  },
  mounted() {
    this.timer = setInterval(() => this.checkStatus(), 1000)

    // 设置事件监听
    window.addEventListener('resize', this.handleResize)

    // 创建Worker
    this.worker = new Worker('worker.js')
  },
  beforeUnmount() {
    // 清理定时器
    if (this.timer) {
      clearInterval(this.timer)
      this.timer = null
    }

    // 移除事件监听
    window.removeEventListener('resize', this.handleResize)

    // 终止Worker
    if (this.worker) {
      this.worker.terminate()
      this.worker = null
    }
  }
}

10.2 主进程性能优化

主进程负责应用生命周期管理和系统集成,其性能影响应用的整体稳定性。

10.2.1 避免阻塞主进程

将耗时操作放在子进程中执行,避免阻塞主进程事件循环:

// 主进程中创建子进程执行耗时操作
const { fork } = require('child_process')

function performHeavyTask(params) {
  return new Promise((resolve, reject) => {
    const child = fork('./scripts/heavyTask.js')

    // 发送参数到子进程
    child.send({ params })

    // 接收结果
    child.on('message', (result) => {
      resolve(result)
      child.kill()
    })

    // 处理错误
    child.on('error', (error) => {
      reject(error)
    })

    // 处理退出
    child.on('exit', (code) => {
      if (code !== 0) {
        reject(new Error(`子进程退出,退出码: ${code}`))
      }
    })
  })
}

// heavyTask.js
process.on('message', ({ params }) => {
  try {
    // 执行耗时操作
    const result = complexOperation(params)
    // 返回结果
    process.send(result)
    // 退出子进程
    process.exit(0)
  } catch (error) {
    console.error('执行任务时出错:', error)
    process.exit(1)
  }
})

10.2.2 优化IPC通信

减少IPC通信频率,合并相关数据,使用批量操作:

// 优化前:频繁发送IPC消息
for (const item of items) {
  mainWindow.webContents.send('update-item', item)
}

// 优化后:批量发送IPC消息
mainWindow.webContents.send('update-items', items)

// 使用invoke-respond模式代替多次send
async function processDataInMainProcess(data) {
  return ipcMain.handle('process-data', (event, data) => {
    // 处理数据
    return processedData
  })
}

10.3 资源管理优化

合理管理应用资源,减少内存占用和磁盘空间消耗。

10.3.1 内存管理

监控和限制内存使用,及时释放不再需要的资源:

// 监控内存使用
function monitorMemoryUsage() {
  const { memoryUsage } = process
  const usage = memoryUsage()

  // 转换为MB
  const rss = (usage.rss / 1024 / 1024).toFixed(2)
  const heapTotal = (usage.heapTotal / 1024 / 1024).toFixed(2)
  const heapUsed = (usage.heapUsed / 1024 / 1024).toFixed(2)

  console.log(`内存使用: RSS=${rss}MB, HeapTotal=${heapTotal}MB, HeapUsed=${heapUsed}MB`)

  // 如果内存使用超过阈值,执行清理操作
  if (usage.heapUsed > MAX_HEAP_SIZE) {
    performMemoryCleanup()
  }
}

// 定期监控
setInterval(monitorMemoryUsage, 60000)

10.3.2 文件系统操作优化

减少同步文件操作,合理缓存文件内容,避免重复读取:

// 使用缓存减少文件读取
const fileCache = new Map()
const CACHE_TTL = 60000 // 缓存1分钟

async function readFileWithCache(filePath) {
  const now = Date.now()
  const cached = fileCache.get(filePath)

  // 检查缓存是否有效
  if (cached && (now - cached.timestamp) < CACHE_TTL) {
    return cached.content
  }

  // 读取文件
  try {
    const content = await fs.promises.readFile(filePath, 'utf8')
    // 更新缓存
    fileCache.set(filePath, { content, timestamp: now })
    return content
  } catch (error) {
    console.error('读取文件失败:', error)
    throw error
  }
}

// 定期清理过期缓存
function cleanupCache() {
  const now = Date.now()
  for (const [key, value] of fileCache.entries()) {
    if (now - value.timestamp > CACHE_TTL) {
      fileCache.delete(key)
    }
  }
}

10.4 构建优化

通过构建优化减小应用体积,提高启动速度。

10.4.1 代码分割和Tree Shaking

利用Vite的代码分割和Tree Shaking功能,移除未使用的代码:

// vite.config.js 配置优化
import { defineConfig } from 'vite'
import electron from 'vite-plugin-electron'

export default defineConfig({
  build: {
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true
      }
    },
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor': ['vue', 'vue-router', 'pinia'],
          'ui': ['element-plus'],
          'utils': ['lodash-es']
        }
      }
    }
  },
  plugins: [
    electron({
      main: {
        entry: 'src/main/index.js'
      },
      preload: {
        input: {
          preload: 'src/preload/index.js'
        }
      }
    })
  ]
})

10.4.2 资源压缩和优化

压缩图片、字体等资源,使用适当的资源格式:

// 使用vite-plugin-imagemin压缩图片
import { defineConfig } from 'vite'
import electron from 'vite-plugin-electron'
import imagemin from 'vite-plugin-imagemin'

export default defineConfig({
  plugins: [
    electron({
      main: {
        entry: 'src/main/index.js'
      }
    }),
    imagemin({
      gifsicle: {
        optimizationLevel: 7
      },
      optipng: {
        optimizationLevel: 7
      },
      mozjpeg: {
        quality: 80
      },
      pngquant: {
        quality: [0.8, 0.9],
        speed: 4
      },
      svgo: {
        plugins: [
          {
            name: 'removeViewBox'
          },
          {
            name: 'removeEmptyAttrs',
            active: false
          }
        ]
      }
    })
  ]
})

除了性能之外,安全性也是Electron应用开发中的重要考量因素。接下来我们将探讨Electron应用的安全最佳实践,确保应用和用户数据的安全。

11. 安全考虑

在Electron应用开发中,安全性是一个重要的考量因素。本章节将介绍Electron应用的安全最佳实践。

11.1 主进程安全

11.1.1 禁用危险功能

禁用或限制使用具有安全风险的API:

// 禁用Node.js集成在渲染进程中
const mainWindow = new BrowserWindow({
  width: 800,
  height: 600,
  webPreferences: {
    nodeIntegration: false,
    contextIsolation: true,
    enableRemoteModule: false,
    sandbox: true,
    preload: path.join(__dirname, 'preload.js')
  }
})

11.1.2 应用签名和验证

确保应用经过签名,防止未授权修改:

// 使用electron-builder配置签名
// electron-builder.json
{
  "appId": "com.yourapp.id",
  "mac": {
    "category": "public.app-category.utilities",
    "hardenedRuntime": true,
    "gatekeeperAssess": false,
    "entitlements": "build/entitlements.mac.plist",
    "entitlementsInherit": "build/entitlements.mac.plist"
  },
  "win": {
    "target": ["nsis", "portable"],
    "certificateSubjectName": "Your Company Name"
  }
}

11.2 渲染进程安全

11.2.1 内容安全策略(CSP)

配置内容安全策略,限制资源加载和脚本执行:

// 设置CSP
mainWindow.webContents.session.webRequest.onHeadersReceived((details, callback) => {
  callback({
    responseHeaders: {
      ...details.responseHeaders,
      'Content-Security-Policy': ["default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'"]
    }
  })
})

11.2.2 安全的IPC通信

实现安全的IPC通信机制,验证消息来源和内容:

// 主进程中验证IPC通信
ipcMain.handle('secure-action', (event, data) => {
  // 验证消息来源
  if (event.senderFrame.url !== new URL('index.html', `file://${__dirname}`).href) {
    throw new Error('未授权的来源')
  }

  // 验证数据格式
  if (!validateDataFormat(data)) {
    throw new Error('无效的数据格式')
  }

  // 执行操作
  return performSecureAction(data)
})

11.3 数据安全

11.3.1 敏感数据存储

安全存储用户凭据和敏感数据:

// 使用keytar存储凭据
const keytar = require('keytar')

async function saveCredentials(username, password) {
  try {
    await keytar.setPassword('YourAppName', username, password)
    return true
  } catch (error) {
    console.error('保存凭据失败:', error)
    return false
  }
}

async function getCredentials(username) {
  try {
    return await keytar.getPassword('YourAppName', username)
  } catch (error) {
    console.error('获取凭据失败:', error)
    return null
  }
}

11.3.2 数据加密

加密敏感数据,防止未授权访问:

// 使用crypto模块加密数据
const crypto = require('crypto')

function encryptData(data, key) {
  const iv = crypto.randomBytes(16)
  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
  let encrypted = cipher.update(JSON.stringify(data), 'utf8', 'hex')
  encrypted += cipher.final('hex')
  const authTag = cipher.getAuthTag().toString('hex')

  return {
    iv: iv.toString('hex'),
    encrypted: encrypted,
    authTag: authTag
  }
}

function decryptData(encryptedData, key) {
  const { iv, encrypted, authTag } = encryptedData
  const decipher = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'hex'))
  decipher.setAuthTag(Buffer.from(authTag, 'hex'))

  let decrypted = decipher.update(encrypted, 'hex', 'utf8')
  decrypted += decipher.final('utf8')

  return JSON.parse(decrypted)
}

11.4 更新安全

确保应用更新过程的安全性,防止恶意更新:

// 使用electron-updater配置签名验证
// electron-builder.json
{
  "appId": "com.yourapp.id",
  "publish": [
    {
      "provider": "github",
      "owner": "yourusername",
      "repo": "yourapp"
    }
  ],
  "win": {
    "verifyUpdateCodeSignature": true
  },
  "mac": {
    "sign": "Developer ID Application: Your Name (TEAM_ID)"
  }
}

// 主进程中检查更新
const { autoUpdater } = require('electron-updater')

function setupAutoUpdater() {
  // 配置更新服务器
  autoUpdater.setFeedURL({
    provider: 'github',
    owner: 'yourusername',
    repo: 'yourapp'
  })

  // 检查更新
  autoUpdater.checkForUpdatesAndNotify()

  // 监听更新事件
  autoUpdater.on('update-downloaded', (info) => {
    // 提示用户安装更新
    dialog.showMessageBox({
      type: 'info',
      title: '更新可用',
      message: '新版本已下载完成,是否立即安装?',
      buttons: ['是', '否'],
      defaultId: 0
    }).then(result => {
      if (result.response === 0) {
        autoUpdater.quitAndInstall()
      }
    })
  })
}

12. CI/CD配置

持续集成和持续部署(CI/CD)可以自动化构建、测试和发布流程,提高开发效率和代码质量。本章节将介绍如何为Electron应用配置CI/CD。

12.1 GitHub Actions配置

使用GitHub Actions自动构建和发布应用:

# .github/workflows/build.yml
name: Build and Release

on:
  push:
    tags: ['v*.*.*']

jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [windows-latest, macos-latest, ubuntu-latest]

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '16.x'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Build for Windows
        if: matrix.os == 'windows-latest'
        run: npm run build:win
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Build for macOS
        if: matrix.os == 'macos-latest'
        run: npm run build:mac
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          APPLE_ID: ${{ secrets.APPLE_ID }}
          APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
          CSC_LINK: ${{ secrets.CSC_LINK }}
          CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}

      - name: Build for Linux
        if: matrix.os == 'ubuntu-latest'
        run: npm run build:linux
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: Upload artifacts
        uses: actions/upload-artifact@v3
        with:
          name: ${{ matrix.os }}-builds
          path: dist/**

  release:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Download all artifacts
        uses: actions/download-artifact@v3
        with:
          path: dist

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v1
        with:
          files: dist/**/*
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

12.2 测试自动化

配置自动化测试流程,确保每次提交都通过测试:

# .github/workflows/test.yml
name: Run Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '16.x'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run lint
        run: npm run lint

      - name: Run unit tests
        run: npm run test:unit

      - name: Run integration tests
        run: npm run test:integration

      - name: Upload test coverage
        uses: codecov/codecov-action@v3
        with:
          file: ./coverage/lcov.info

12.3 自动化发布策略

配置不同环境的发布策略,如开发、测试和生产环境:

// electron-builder.config.js
const { name, version } = require('./package.json')

module.exports = {
  appId: 'com.yourapp.id',
  productName: name,
  version: version,
  directories: {
    output: 'dist',
    buildResources: 'build'
  },
  // 根据环境配置不同的发布策略
  publish: [
    {
      provider: 'github',
      owner: 'yourusername',
      repo: 'yourapp',
      // 开发版本使用prerelease
      prerelease: process.env.NODE_ENV === 'development'
    }
  ],
  // 其他配置...
}

12.4 环境变量管理

在CI/CD流程中管理环境变量,确保敏感信息的安全:

# 在GitHub Actions中使用环境变量
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # 其他步骤...

      - name: Build application
        run: npm run build
        env:
          # 公开环境变量
          NODE_ENV: production
          API_URL: https://api.example.com
          # 敏感环境变量从GitHub Secrets中获取
          API_KEY: ${{ secrets.API_KEY }}
          ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}

13. 项目经验与最佳实践

基于实际项目开发经验,以下是一些Electron应用开发的最佳实践建议。

13.1 架构设计

13.1.1 主进程和渲染进程分离

清晰分离主进程和渲染进程的职责,避免紧耦合:

  • 主进程: 负责应用生命周期管理、窗口管理、系统集成、文件操作等
  • 渲染进程: 负责UI渲染、用户交互、业务逻辑等
  • 预加载脚本: 作为桥梁,提供安全的IPC通信和API访问

13.1.2 模块化设计

采用模块化设计,提高代码复用性和可维护性:

src/
├── main/                  # 主进程代码
│   ├── modules/           # 主进程模块
│   │   ├── windowManager.js
│   │   ├── appUpdater.js
│   │   └── ipcHandlers.js
│   └── index.js           # 主进程入口
├── preload/               # 预加载脚本
│   ├── modules/           # 预加载模块
│   │   ├── apiBridge.js
│   │   └── eventEmitter.js
│   └── index.js           # 预加载入口
└── renderer/              # 渲染进程代码
    ├── assets/            # 静态资源
    ├── components/        # 组件
    ├── views/             # 页面
    ├── store/             # 状态管理
    ├── router/            # 路由配置
    └── main.js            # 渲染进程入口

13.2 跨平台兼容性

确保应用在不同平台上表现一致,处理平台差异:

// 处理平台差异
function getPlatformSpecificPath(basePath) {
  const platform = process.platform

  switch (platform) {
    case 'win32':
      return path.join(basePath, 'windows')
    case 'darwin':
      return path.join(basePath, 'macos')
    case 'linux':
      return path.join(basePath, 'linux')
    default:
      return basePath
  }
}

// 适配不同平台的快捷键
function getPlatformShortcuts() {
  const isMac = process.platform === 'darwin'

  return {
    save: isMac ? 'CmdOrCtrl+S' : 'Ctrl+S',
    open: isMac ? 'CmdOrCtrl+O' : 'Ctrl+O',
    undo: isMac ? 'CmdOrCtrl+Z' : 'Ctrl+Z'
  }
}

13.3 性能监控与分析

实施性能监控和分析,及时发现和解决性能问题:

// 使用performance API监控渲染性能
function monitorRenderPerformance() {
  if (typeof window !== 'undefined' && window.performance) {
    const perf = window.performance

    // 监控帧率
    let lastTime = perf.now()
    let frames = 0

    function tick() {
      frames++
      const now = perf.now()

      if (now - lastTime >= 1000) {
        const fps = Math.round((frames * 1000) / (now - lastTime))
        console.log(`FPS: ${fps}`)

        // 如果帧率过低,记录警告
        if (fps < 30) {
          console.warn(`性能警告: 帧率过低 (${fps} FPS)`)
          // 可以发送到分析服务
          reportLowPerformance(fps)
        }

        frames = 0
        lastTime = now
      }

      requestAnimationFrame(tick)
    }

    tick()
  }
}

13.4 用户体验优化

优化用户体验,提供流畅、直观的界面:

  • 响应式设计: 确保应用在不同屏幕尺寸下都能正常显示
  • 键盘快捷键: 提供常用操作的键盘快捷键
  • 拖放支持: 实现文件拖放功能,提高操作效率
  • 进度指示: 对于耗时操作,显示进度条或加载指示器
  • 错误反馈: 提供清晰、友好的错误提示

13.5 常见陷阱和解决方案

在Electron应用开发中,有一些常见的陷阱需要避免:

13.5.1 内存泄漏

问题: Electron应用容易出现内存泄漏,特别是在频繁创建和销毁窗口时。

解决方案:

  • 及时清理事件监听器
  • 避免循环引用
  • 使用弱引用存储回调函数
  • 定期监控内存使用情况

13.5.2 白屏问题

问题: 应用启动时出现白屏或渲染延迟。

解决方案:

  • 使用启动屏幕(Splash Screen)
  • 优化初始渲染性能
  • 延迟加载非关键资源
  • 预加载必要数据

13.5.3 打包体积过大

问题: Electron应用打包后体积通常较大,影响下载和安装体验。

解决方案:

  • 使用asar打包减少文件数量
  • 仅包含必要的依赖
  • 使用代码分割和懒加载
  • 优化构建配置,移除调试信息

13.5.4 多窗口状态同步

问题: 多窗口应用中,窗口之间的状态同步变得复杂。

解决方案:

  • 使用事件总线或发布-订阅模式
  • 将共享状态存储在主进程中
  • 实现窗口间的IPC通信协议
  • 使用Redux或Pinia等状态管理库

13.6 团队协作建议

在团队开发Electron应用时,以下建议可以提高协作效率:

  • 代码规范: 使用ESLint和Prettier统一代码风格
  • 分支策略: 采用Git Flow或GitHub Flow分支管理
  • 代码审查: 建立代码审查流程,确保代码质量
  • 文档管理: 维护API文档和开发文档
  • 版本控制: 遵循语义化版本控制规范
  • 自动化测试: 编写单元测试和集成测试,确保功能正常

通过上述章节的学习,我们已经掌握了Electron应用开发的各个关键环节,从基础架构到高级特性都有了全面的了解。最后,让我们对整个开发过程进行总结,并展望未来的发展方向。

14. 总结与展望

14.1 主要收获

通过本指南,我们学习了如何使用electron-vite从零开始构建一个完整的Electron桌面应用,包括:

  • 项目初始化和配置
  • 主进程、预加载脚本和渲染进程的实现
  • UI组件和布局设计
  • 状态管理和路由配置
  • 构建、打包和部署
  • 测试、错误处理和调试
  • 性能优化和安全考虑
  • CI/CD配置和最佳实践

14.2 未来发展方向

Electron技术在不断发展,未来可以关注以下方向:

  • 性能优化: 探索更高效的渲染技术和内存管理策略
  • WebAssembly集成: 利用WebAssembly提升性能密集型任务的执行效率
  • 跨平台一致性: 进一步完善不同平台的用户体验
  • 云服务集成: 结合云服务提供更丰富的功能
  • PWA支持: 考虑支持渐进式Web应用特性
  • AI功能集成: 集成AI功能,提供智能辅助

14.3 学习资源

为了深入学习Electron开发,推荐以下资源: