Electron + Vue3开源跨平台壁纸工具实战(十五)H5

266 阅读7分钟

fbw_social_preview.png

系列

Electron + Vue3开源跨平台壁纸工具实战(一)

Electron + Vue3开源跨平台壁纸工具实战(二)本地运行

Electron + Vue3开源跨平台壁纸工具实战(三)主进程

Electron + Vue3开源跨平台壁纸工具实战(四)主进程-数据管理(1)

Electron + Vue3开源跨平台壁纸工具实战(五)主进程-数据管理(2)

Electron + Vue3开源跨平台壁纸工具实战(六)子进程服务

Electron + Vue3开源跨平台壁纸工具实战(七)进程通信

Electron + Vue3开源跨平台壁纸工具实战(八)主进程-核心功能

Electron + Vue3开源跨平台壁纸工具实战(九)子进程服务(2)

Electron + Vue3开源跨平台壁纸工具实战(十)渲染进程

Electron + Vue3开源跨平台壁纸工具实战(十一)图片壁纸

源码

省流点我进Github获取源码,欢迎fork、star、PR

H5功能介绍

飞鸟壁纸的H5功能是一个完整的Web应用,包含后端服务和前端界面,通过Electron的子进程机制实现Web服务集成,为用户提供移动端友好的图片浏览体验。

架构概述

H5功能采用前后端分离架构:

  • 后端服务:基于Node.js + Koa + Socket.IO的子进程服务
  • 前端应用:基于Vue 3 + Vant UI的移动端Web应用
  • 通信机制:通过Socket.IO实现实时双向通信

主进程中的H5后端服务

子进程管理

H5服务通过Electron的utilityProcess作为子进程运行,确保与主进程隔离:

// src/main/child_server/ChildServer.mjs
export default class ChildServer {
  constructor(serverName, serverPath) {
    this.#serverName = serverName
    this.#serverPath = serverPath
    this.#child = null
    this.#port2 = null
  }

  start({ options, onMessage = () => {} } = {}) {
    const { port1, port2 } = new MessageChannelMain()
    this.#child = utilityProcess.fork(this.#serverPath, options)
    this.#port2 = port2

    // 建立进程间通信
    this.#child.postMessage({ serverName: this.#serverName, event: 'SERVER_FORKED' }, [port1])
    this.postMessage({ serverName: this.#serverName, event: 'SERVER_START' })
  }
}

服务器实现

H5服务器基于Koa框架构建,支持HTTP/HTTPS协议:

// src/main/child_server/h5_server/server.mjs
export default async ({
  port = 8888,
  host = '0.0.0.0',
  useHttps = true,
  dbManager,
  settingManager,
  resourcesManager,
  fileManager,
  logger = () => {},
  postMessage = () => {},
  onStartSuccess = () => {},
  onStartFail = () => {}
} = {}) => {
  // 创建 Koa 服务器
  const app = new Koa()

  // 支持HTTPS自签名证书
  if (useHttps) {
    const certPath = process.env.FBW_CERTS_PATH
    const { key, cert } = generateSelfSignedCert(host)
    const sslOptions = { key, cert }
    httpServer = http2.createSecureServer(sslOptions, app.callback())
  } else {
    httpServer = http.createServer(app.callback())
  }

  // 创建 Socket.IO 服务器
  ioServer = new Server(httpServer, {
    cors: { origin: '*', methods: ['GET', 'POST'] },
    transports: ['websocket', 'polling'],
    pingTimeout: 30000,
    pingInterval: 25000
  })
}

静态资源服务

服务器提供H5前端静态资源服务:

// 处理H5静态资源地址
const staticPath = isDev()
  ? path.resolve(__dirname, '../h5')
  : path.resolve(process.env.FBW_RESOURCES_PATH, './h5')

// 提供静态资源服务
app.use(
  staticServe(staticPath, {
    maxage: 86400000, // 缓存一天
    gzip: true, // 启用gzip压缩
    br: true, // 启用brotli压缩
    setHeaders: (res) => {
      res.setHeader('Access-Control-Allow-Origin', '*')
      res.setHeader('Cache-Control', 'public, max-age=86400')
    }
  })
)

HTTP API接口

提供图片获取等HTTP接口:

// src/main/child_server/h5_server/api/index.mjs
const useApi = (router) => {
  // 图片相关接口
  router.get('/api/images/get', getImage)
}

// src/main/child_server/h5_server/api/images.mjs
export const getImage = async (ctx) => {
  const { filePath, w, h, compressStartSize } = ctx.request.query
  const res = await handleFileResponse({ filePath, w, h, compressStartSize })
  ctx.set(res.headers)
  ctx.status = res.status
  ctx.body = res.data
}

Socket.IO实时通信

通过Socket.IO实现实时双向通信,支持多种操作:

// src/main/child_server/h5_server/socket/index.mjs
export default async function setupSocketIO(
  ioServer,
  { t, dbManager, settingManager, resourcesManager, fileManager, logger, postMessage }
) {
  ioServer.on('connection', (socket) => {
    // 获取设置
    socket.on('getSettingData', async (params, callback) => {
      const res = await settingManager.getSettingData()
      safeCallback(callback, res)
    })

    // 更新设置
    socket.on('h5UpdateSettingData', async (data, callback) => {
      const res = await settingManager.updateSettingData(data)
      if (res.success && res.data) {
        // 向主进程发送设置更新消息
        postMessage({ event: 'H5_SETTING_UPDATED', data: res.data })
        // 广播设置更新给所有客户端
        ioServer.emit('settingUpdated', res)
      }
      safeCallback(callback, res)
    })

    // 搜索图片
    socket.on('searchImages', async (params, callback) => {
      const res = await resourcesManager.search(params)
      safeCallback(callback, res)
    })

    // 收藏相关操作
    socket.on('toggleFavorite', async (id, callback) => {
      const isFavorite = await resourcesManager.checkFavorite(id)
      if (isFavorite) {
        const res = await resourcesManager.removeFavorites(id)
      } else {
        const res = await resourcesManager.addToFavorites(id)
      }
      safeCallback(callback, res)
    })

    // 删除图片
    socket.on('deleteImage', async (item, callback) => {
      const res = await fileManager.deleteFile(item)
      safeCallback(callback, res)
    })
  })
}

进程间通信

H5子进程与主进程通过MessageChannel进行通信:

// src/main/child_server/h5_server/index.mjs
process.parentPort.on('message', (e) => {
  const [port] = e.ports

  port.on('message', async (e) => {
    const { data } = e
    if (data.event === 'SERVER_START') {
      // 初始化各种管理器
      dbManager = DatabaseManager.getInstance(logger)
      settingManager = SettingManager.getInstance(logger, dbManager)
      fileManager = FileManager.getInstance(logger, dbManager, settingManager)
      resourcesManager = ResourcesManager.getInstance(
        logger,
        dbManager,
        settingManager,
        fileManager
      )

      const serverRes = await server({
        dbManager,
        settingManager,
        resourcesManager,
        fileManager,
        logger,
        postMessage,
        onStartSuccess: (url) => {
          port.postMessage({ event: 'SERVER_START::SUCCESS', url })
        },
        onStartFail: (data) => {
          port.postMessage({ event: 'SERVER_START::FAIL', ...data })
        }
      })
    } else if (data.event === 'APP_SETTING_UPDATED') {
      await settingManager.getSettingData()
      ioServer?.emit('settingUpdated', {
        success: true,
        data: settingManager.settingData
      })
    }
  })
})

H5前端应用

技术栈

  • 框架:Vue 3 + Composition API
  • UI组件库:Vant 4(移动端UI)
  • 状态管理:Pinia
  • 构建工具:Vite
  • 国际化:i18next
  • 图标:Iconify

应用入口

// src/h5/main.js
import Vant, { Lazyload } from 'vant'
import 'vant/lib/index.css'
import './assets/main.css'
import App from './App.vue'
import useIconifyIcon from './utils/icons.js'
import useI18n from '@i18n/web.js'

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

app.use(Vant)
app.use(Lazyload)
app.use(pinia)
useIconifyIcon(app)
useI18n(app).mount('#app')

主应用组件

<!-- src/h5/App.vue -->
<script setup>
import UseCommonStore from './stores/commonStore.js'
import UseSettingStore from './stores/settingStore.js'

const commonStore = UseCommonStore()
const settingStore = UseSettingStore()

const { activeTabbar, tabbarVisible } = storeToRefs(commonStore)
const { settingData } = storeToRefs(settingStore)

const tabbarList = [
  { name: 'home', title: '首页', locale: 'h5.tabbar.home', icon: 'ri:home-3-line' },
  { name: 'search', title: '搜索', locale: 'h5.tabbar.search', icon: 'ri:search-line' },
  { name: 'setting', title: '设置', locale: 'h5.tabbar.setting', icon: 'ri:settings-line' }
]

onBeforeMount(async () => {
  // 初始化 Socket.IO 监听
  settingStore.initSocketListeners()
  // 获取设置数据
  await settingStore.getSettingData()
  // 获取资源数据
  await commonStore.getResourceMap()
})
</script>

<template>
  <van-config-provider
    :theme="settingData.themes.dark ? 'dark' : 'light'"
    :theme-vars="themeVars"
    theme-vars-scope="global"
  >
    <div id="app">
      <component :is="currentPage || pageEmpty" ref="pageRef"></component>
      <van-tabbar v-if="tabbarVisible" v-model="activeTabbar" safe-area-inset-bottom>
        <van-tabbar-item v-for="item in tabbarList" :key="item.name" :name="item.name">
          {{ t(item.locale) }}
          <template #icon>
            <IconifyIcon :icon="item.icon" />
          </template>
        </van-tabbar-item>
      </van-tabbar>
    </div>
  </van-config-provider>
</template>

状态管理

通用状态管理
// src/h5/stores/commonStore.js
const UseCommonStore = defineStore('common', {
  state: () => ({
    activeTabbar: 'home',
    tabbarVisible: true,
    resourceMap: JSON.parse(JSON.stringify(defaultResourceMap))
  }),
  actions: {
    setActiveTabbar(name) {
      this.activeTabbar = name
    },
    toggleTabbarVisible() {
      this.tabbarVisible = !this.tabbarVisible
      const tabbarHeight = this.tabbarVisible ? 'var(--van-tabbar-height)' : '0px'
      document.documentElement.style.setProperty('--fbw-tabbar-height', tabbarHeight)
    },
    async getResourceMap() {
      const res = await api.getResourceMap()
      if (res.success) {
        this.resourceMap = Object.assign({}, this.resourceMap, res.data)
      }
      return res
    }
  }
})
设置状态管理
// src/h5/stores/settingStore.js
const UseSettingStore = defineStore('setting', {
  state: () => ({
    settingData: { ...JSON.parse(JSON.stringify(defaultSettingData)) },
    localSetting: { multiDeviceSync: true }
  }),
  actions: {
    async getSettingData() {
      const res = await api.getSettingData()
      if (res.success) {
        this.settingData = Object.assign({}, this.settingData, res.data)
      }
      return res
    },
    async h5UpdateSettingData(data) {
      if (!this.localSetting.multiDeviceSync) {
        this.settingData = Object.assign({}, this.settingData, data)
        return { success: false, message: i18next.t('messages.multiDeviceSyncWarning') }
      }
      const res = await api.h5UpdateSettingData(data)
      if (res.success) {
        this.settingData = Object.assign({}, this.settingData, res.data)
      }
      return res
    },
    initSocketListeners() {
      // 监听多设备设置更新事件
      api.socketInstance.on('settingUpdated', (res) => {
        if (res.success && this.localSetting.multiDeviceSync) {
          this.settingData = Object.assign({}, this.settingData, res.data)
        }
      })
    }
  }
})

API通信层

import { io } from 'socket.io-client'

// 创建 Socket.IO 客户端实例
const socket = io(window.location.origin)

// 包装 Socket.IO 事件为 Promise
const emitAsync = (event, data) => {
  return new Promise((resolve) => {
    socket.emit(event, data, (response) => {
      resolve(response)
    })
  })
}

// 图片相关接口
export const searchImages = async (data) => {
  return await emitAsync('searchImages', data)
}

export const toggleFavorite = async (id) => {
  return await emitAsync('toggleFavorite', id)
}

export const addToFavorites = async (id) => {
  return await emitAsync('addToFavorites', id)
}

export const deleteImage = async (item) => {
  return await emitAsync('deleteImage', item)
}

// 设置接口
export const getSettingData = async () => {
  return await emitAsync('getSettingData')
}

export const h5UpdateSettingData = async (data) => {
  return await emitAsync('h5UpdateSettingData', data)
}

export const getResourceMap = async () => {
  return await emitAsync('getResourceMap')
}

// 导出 socket 实例
export const socketInstance = socket

页面组件

首页组件

首页提供图片浏览、搜索、收藏等功能:

<!-- src/h5/pages/home/index.vue -->
<script setup>
import UseCommonStore from '@h5/stores/commonStore.js'
import UseSettingStore from '@h5/stores/settingStore.js'
import * as api from '@h5/api/index.js'

const commonStore = UseCommonStore()
const settingStore = UseSettingStore()
const settingData = ref(settingStore.settingData)

// 自动切换相关状态
const autoSwitch = reactive({
  switchTimer: null,
  countdownTimer: null,
  countdown: settingData.value.h5SwitchIntervalTime,
  currentIndex: 0,
  imageList: [],
  total: 0
})

// 是否随机
const isRandom = computed(() => {
  return settingData.value.h5SwitchType === 1
})

// 初始化方法
const init = async () => {
  await loadImages()
  if (settingData.value.h5AutoSwitch) {
    startAutoSwitch()
  }
}

// 加载图片
const loadImages = async () => {
  const res = await api.searchImages({
    page: pageInfo.startPage,
    pageSize: pageInfo.pageSize,
    random: isRandom.value
  })
  if (res.success) {
    autoSwitch.imageList = res.data.list
    autoSwitch.total = res.data.total
  }
}

// 自动切换
const startAutoSwitch = () => {
  if (autoSwitch.switchTimer) {
    clearInterval(autoSwitch.switchTimer)
  }
  autoSwitch.switchTimer = setInterval(() => {
    if (isRandom.value) {
      autoSwitch.currentIndex = Math.floor(Math.random() * autoSwitch.imageList.length)
    } else {
      autoSwitch.currentIndex = (autoSwitch.currentIndex + 1) % autoSwitch.imageList.length
    }
  }, settingData.value.h5SwitchIntervalTime * 1000)
}
</script>
设置页面

提供H5相关设置选项:

<!-- src/h5/pages/setting/index.vue -->
<template>
  <div class="setting-page">
    <van-cell-group title="H5设置">
      <van-cell title="自动切换" is-link @click="showAutoSwitchPopup = true">
        <template #value>
          <van-switch v-model="settingData.h5AutoSwitch" @change="onAutoSwitchChange" />
        </template>
      </van-cell>

      <van-cell
        title="切换间隔"
        :value="`${settingData.h5SwitchIntervalTime}秒`"
        is-link
        @click="showIntervalPopup = true"
      />

      <van-cell
        title="切换方式"
        :value="settingData.h5SwitchType === 1 ? '随机' : '顺序'"
        is-link
        @click="showTypePopup = true"
      />

      <van-cell title="屏幕常亮" is-link @click="showWakeLockPopup = true">
        <template #value>
          <van-switch v-model="settingData.h5WeekScreen" @change="onWakeLockChange" />
        </template>
      </van-cell>

      <van-cell title="震动反馈" is-link @click="showVibrationPopup = true">
        <template #value>
          <van-switch v-model="settingData.h5Vibration" @change="onVibrationChange" />
        </template>
      </van-cell>
    </van-cell-group>
  </div>
</template>

Electron中的Web服务集成

服务启动流程

  1. 主进程启动H5服务
// src/main/store/index.mjs
handleH5ServerStart(maxRetries = 3, retryInterval = 2000) {
  this.h5Server?.start({
    options: { env: process.env },
    onMessage: async ({ data }) => {
      switch (data.event) {
        case 'SERVER_START::SUCCESS': {
          this.h5ServerUrl = data.url
          global.logger.info(`H5服务器启动成功: ${this.h5ServerUrl}`)

          // 发送消息到主窗口
          if (global.FBW.mainWindow.win) {
            global.FBW.sendCommonData(global.FBW.mainWindow.win)
            global.FBW.sendMsg(global.FBW.mainWindow.win, {
              type: 'success',
              message: t('messages.h5ServerStartSuccess')
            })
          }
          break
        }
        case 'H5_SETTING_UPDATED': {
          await this.settingManager.getSettingData()
          this.sendSettingDataUpdate()
          break
        }
      }
    }
  })
}
  1. 子进程初始化

    • 创建Koa服务器
    • 配置HTTPS/HTTP协议
    • 设置Socket.IO服务
    • 挂载静态资源
    • 注册API路由
  2. 服务就绪

    • 返回服务URL给主进程
    • 主进程通知渲染进程
    • 用户可通过URL访问H5应用

进程间通信机制

  • 主进程 ↔ 子进程:通过MessageChannel进行通信
  • 子进程 ↔ H5前端:通过Socket.IO进行实时通信
  • 主进程 ↔ 渲染进程:通过IPC进行通信

多设备同步

H5应用支持多设备设置同步:

// 设置更新时广播给所有客户端
socket.on('h5UpdateSettingData', async (data, callback) => {
  const res = await settingManager.updateSettingData(data)
  if (res.success && res.data) {
    // 向主进程发送设置更新消息
    postMessage({ event: 'H5_SETTING_UPDATED', data: res.data })
    // 广播设置更新给所有客户端
    ioServer.emit('settingUpdated', res)
  }
  safeCallback(callback, res)
})

// 客户端监听设置更新
api.socketInstance.on('settingUpdated', (res) => {
  if (res.success && this.localSetting.multiDeviceSync) {
    this.settingData = Object.assign({}, this.settingData, res.data)
  }
})

构建和部署

H5前端通过Vite构建:

// h5.vite.config.mjs
export default defineConfig({
  root: resolve(__dirname, 'src/h5'),
  base: './',
  build: {
    emptyOutDir: true,
    outDir: '../../out/h5',
    assetsDir: 'assets',
    rollupOptions: {
      input: resolve(__dirname, 'src/h5/index.html')
    }
  }
})

构建后的静态资源会被复制到Electron应用的resources目录,由H5服务器提供服务。

功能特性

移动端优化

  • 响应式设计,适配各种移动设备
  • 触摸手势支持(滑动、长按等)
  • 屏幕常亮功能
  • 震动反馈

实时通信

  • Socket.IO实现实时双向通信
  • 多设备设置同步
  • 实时状态更新

性能优化

  • 图片懒加载
  • 静态资源缓存
  • Gzip/Brotli压缩
  • 连接池管理

安全性

  • HTTPS自签名证书
  • CORS跨域配置
  • 输入验证和过滤

H5功能为飞鸟壁纸提供了完整的移动端Web体验,通过Electron的子进程机制实现了稳定的Web服务集成,支持多设备同步和实时通信,为用户提供了便捷的图片浏览和管理功能。