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

在现代桌面应用开发领域,Electron凭借其跨平台特性和Web技术栈的优势,成为了众多开发者的首选框架。而electron-vite作为新一代的构建工具,结合了Vite的极速开发体验和Electron的强大能力,为桌面应用开发带来了全新的可能。本文将基于当前的 multimedia-browser 项目,详细介绍如何使用electron-vite从零开始搭建一个桌面应用。

一、electron-vite 简介

1.1 什么是electron-vite

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

1.2 核心优势

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

二、项目初始化与配置

2.1 初始化项目

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

bash 复制代码
# 使用npm
npm create electron-vite@latest my-app -- --template vue

# 使用yarn
yarn create electron-vite my-app --template vue

# 使用pnpm
pnpm create electron-vite my-app -- --template vue

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

2.2 项目结构解析

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

bash 复制代码
├── 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的核心配置文件,定义了主进程、预加载脚本和渲染进程的构建配置:

javascript 复制代码
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文件定义了项目的依赖和脚本命令:

javascript 复制代码
{
  "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应用的核心,负责创建窗口、处理系统事件和管理应用生命周期。在当前项目中,主进程代码位于src/main/index.js

3.1 创建窗口

javascript 复制代码
function createWindow() {
  // 创建浏览器窗口
  const mainWindow = new BrowserWindow({
    width: 900,
    height: 670,
    show: false,
    autoHideMenuBar: true,
    ...(process.platform === 'linux' ? { icon } : {}),
    webPreferences: {
      preload: join(__dirname, '../preload/index.js'),
      sandbox: false,
      // 放宽安全限制以允许加载本地文件
      webSecurity: false,
      allowRunningInsecureContent: true,
      // 启用Node.js集成
      nodeIntegration: true,
      contextIsolation: false
    }
  })

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

  // 加载URL或本地HTML文件
  if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
    mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
  } else {
    mainWindow.loadFile(join(__dirname, '../renderer/index.html'))
  }
}

3.2 应用生命周期管理

javascript 复制代码
// 当Electron完成初始化并准备创建浏览器窗口时调用
app.whenReady().then(() => {
  // 注册自定义协议处理本地媒体文件
  protocol.registerFileProtocol('media-file', (request, callback) => {
    const url = request.url.replace('media-file://', '')
    const filePath = decodeURIComponent(url)
    callback(filePath)
  })

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

  // 创建窗口
  createWindow()

  // 处理macOS特有的激活事件
  app.on('activate', function () {
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

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

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
  }
})

四、预加载脚本实现

预加载脚本是连接主进程和渲染进程的桥梁,它可以在渲染进程加载前执行,并有权限访问Node.js API。在当前项目中,预加载脚本位于src/preload/index.js

javascript 复制代码
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'

// 自定义API,用于渲染进程调用
const api = {
  // 打开目录选择对话框
  openDirectory: () => {
    return electronAPI.ipcRenderer.invoke('open-directory-dialog')
  },
  // 获取目录下的文件
  getFilesInDirectory: (path) => {
    return electronAPI.ipcRenderer.invoke('get-files-in-directory', path)
  }
}

// 使用contextBridge将API暴露给渲染进程
if (process.contextIsolated) {
  try {
    contextBridge.exposeInMainWorld('electron', {
      ...electronAPI,
      ipcRenderer: electronAPI.ipcRenderer,
      process: {
        versions: process.versions
      }
    })
    contextBridge.exposeInMainWorld('api', api)
  } catch (error) {
    console.error(error)
  }
} else {
  // 兼容旧版本Electron
  window.electron = electronAPI
  window.electron.process = {
    versions: process.versions
  }
  window.api = api
}

五、渲染进程实现

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

5.1 应用入口文件

javascript 复制代码
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

javascript 复制代码
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

javascript 复制代码
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:

vue 复制代码
<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

javascript 复制代码
// 主题相关工具函数

/**
 * 获取当前应使用的主题类名
 * @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)
  }
}

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

javascript 复制代码
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

javascript 复制代码
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

语言包示例(中文):

javascript 复制代码
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: '选择目录'
  }
}

在组件中使用国际化:

vue 复制代码
<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 设置页面实现

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

vue 复制代码
<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>

六、项目构建与打包

6.1 开发模式

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

bash 复制代码
npm run dev

6.2 构建应用

构建生产版本的应用:

bash 复制代码
npm run build

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

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

6.3 打包为安装包

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

bash 复制代码
# 打包为Windows安装包
npm run build:win

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

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

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

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-vite 项目优化建议

7.1 性能优化

  • 代码分割:利用Vite的代码分割功能,减小初始加载体积
  • 延迟加载:对非首屏内容使用动态导入进行延迟加载
  • 图片优化:使用适当的图片格式和大小,考虑使用WebP等现代格式
  • 缓存策略:合理设置缓存策略,提升重复访问性能
  • 资源预加载:使用预加载策略加载关键资源,提高应用响应速度

7.2 安全优化

  • 内容安全策略(CSP):配置合适的CSP,防止XSS攻击
  • 上下文隔离:启用上下文隔离,增强安全性
  • 权限控制:最小化应用所需权限
  • 依赖管理:定期更新依赖,修复已知漏洞
  • 自定义协议:使用自定义协议加载本地文件,替代不安全的file://协议

以下是一个实现自定义协议的示例(在主进程中):

javascript 复制代码
// 注册自定义协议
protocol.registerFileProtocol('media-file', (request, callback) => {
  const url = request.url.replace('media-file://', '')
  // 解码URL
  const decodedUrl = decodeURIComponent(url)
  // 验证路径安全性
  if (isSafePath(decodedUrl)) {
    callback(decodedUrl)
  } else {
    callback({ error: -324 })
  }
})

// 路径安全性检查函数
function isSafePath(path) {
  // 实现路径安全性检查逻辑
  // 例如,验证路径是否在允许的目录范围内
  return true
}

7.3 用户体验优化

  • 启动性能:优化应用启动速度,考虑使用Splash Screen
  • 响应性能:优化长时间运行的任务,避免阻塞UI
  • 错误处理:添加友好的错误提示和恢复机制
  • 主题支持:提供浅色/深色主题支持
  • 国际化:支持多语言,满足全球用户需求
  • 设置持久化:使用localStorage或其他存储方式保存用户设置
  • 键盘快捷键:为常用功能添加键盘快捷键

以下是实现键盘快捷键的示例:

javascript 复制代码
// 在主进程中注册全局快捷键
globalShortcut.register('CommandOrControl+O', () => {
  // 打开文件对话框
  dialog.showOpenDialog(mainWindow, {
    properties: ['openDirectory']
  }).then(result => {
    if (!result.canceled && result.filePaths.length > 0) {
      // 发送目录路径到渲染进程
      mainWindow.webContents.send('open-directory', result.filePaths[0])
    }
  })
})

八、总结

electron-vite为Electron应用开发带来了全新的体验,结合了Vite的极速开发体验和Electron的跨平台能力。通过本文的介绍,我们详细了解了如何使用electron-vite从零开始搭建一个桌面应用,包括项目初始化、配置、主进程、预加载脚本和渲染进程的实现,以及如何进行构建和打包。

特别地,我们深入探讨了两个提升现代桌面应用体验的重要功能:主题系统和国际化支持。主题系统通过localStorage保存用户偏好,并支持跟随系统设置的动态切换;国际化则通过vue-i18n实现,支持多语言切换和设置持久化。

此外,我们还学习了如何通过自定义协议安全地加载本地文件、如何实现键盘快捷键提升操作效率,以及如何通过Pinia进行状态管理。

通过electron-vite,开发者可以充分利用现代Web技术栈的优势,同时享受桌面应用的原生体验。electron-vite不仅仅是一个构建工具,它更是一个完整的开发解决方案,可以帮助开发者快速构建高性能、高质量的桌面应用。

随着Web技术和Electron生态的不断发展,electron-vite也在持续演进,为桌面应用开发带来更多可能性和更优秀的开发体验。

相关推荐
ze_juejin2 小时前
createComponent的environmentInjector详解
前端
ze_juejin2 小时前
CSS backdrop-filter 属性详解
前端
不爱编程的小方2 小时前
响应式布局
前端·css3
前端康师傅2 小时前
JavaScript 函数高级用法
前端·javascript
战场小包2 小时前
弟弟想看恐龙,用文心快码3.5S快速打造恐龙乐园,让弟弟看个够
前端·three.js·文心快码
咚咚锵咚咚锵2 小时前
DrissionPage的学习
前端·python·学习
huabuyu2 小时前
将 Markdown 转为 AST:实现思路与实战解析
前端
喂完待续2 小时前
【Big Data】Amazon S3 专为从任何位置检索任意数量的数据而构建的对象存储
大数据·云原生·架构·big data·对象存储·amazon s3·序列晋升
前端Hardy2 小时前
惊艳同事的 Canvas 事件流程图,这篇教会你
前端·javascript·css