Electron最佳实践 | 打造Electron桌面小程序

5,770 阅读6分钟

前言

尽管小程序已深入我们生活的方方面面,但在很多企业应用、游戏领域或者 app 中仍然存在着大量的移动 H5 应用,这也是大部分前端开发人员所从事的工作内容之一。作为 H5 应用开发者我们都知道如何利用浏览器的移动仿真模式来调试我们的移动应用。那么,基于 Chromium 内核的 Electron 也应该具有这一能力。这就是接下来我们要探讨和打造的桌面小程序。

打造 Electron 桌面小程序有何益处:

  • 实现移动和桌面多端通用融合,这是很多企业应用所需要的
  • 节省了大量开发和维护成本,对于小型应用尤其适合

在开始详细介绍前,我们先来看看效果:

mini-pro.gif

导读

  • 思路
  • 创建项目
  • 实现
    • 创建小程序窗口
    • 实现主控制栏
    • 创建小程序容器
    • 小程序API设计
  • 安全性建议
  • 拓展

思路

先看下图:

0220413191524.png

我们将小程序分为两部分:

  • 顶部控制栏:
    • 实现小程序可拖拽移动
    • 控制小程序路由导航
    • 控制小程序最小化/关闭等
  • 小程序容器:
    • 加载小程序应用
    • 支持移动模式操作
    • 为小程序提供API

技术实现思路:

  • 通过 BrowserWindow 创建小程序主窗口,实现顶部通用控制栏
  • 通过 BrowserView 创建小程序容器,并附加到小程序主窗口
  • 通过 preload 脚本桥接两个渲染进程通讯
  • 为小程序容器开启移动仿真模式,适应移动应用
  • 为小程序应用设计 API(如主题切换,登录认证,访问文件系统等)

创建项目

本项目通过 create-electron 创建, 选择 Vue3 + TypeScript 模板。

npm init @quick-start/electron

项目主体目录结构如下

electron-micro-app
├──build
├──src
|  ├──main
|  |  ├──index.ts
|  |  └──micro-app.ts
|  ├──preload
|  |  ├──index.ts
|  |  └──view.ts
|  └──renderer
|     ├──src
|     └──index.html
|     └──view.html
├──electron-builder.yml
├──electron.vite.config.js
├──package.json
├──tsconfig.json
├──tsconfig.node.json
└──tsconfig.web.json

实现

创建小程序窗口

在主进程(main)中创建小程序窗口:

export function createMicroAppWindow(urlstring): void {
  const win new BrowserWindow({
    backgroundColor'#FFFFFF',
    width380,
    height640,
    resizablefalse,
    maximizablefalse,
    fullscreenablefalse,
    autoHideMenuBartrue,
    framefalse,
    showfalse,
    titleBarStyle'hidden',
    webPreferences: {
      preload: path.join(__dirname, '../preload/index.js'),
      spellcheckfalse,
      webviewTagfalse
    }
  })

  win.on('ready-to-show', () => {
    win.show()
  })
  
  // ...
}

主要代码解读:

  • frame:falsetitleBarStyle: 'hidden':创建无边窗口,即无标题栏
  • resizable: falsemaximizable: falsefullscreenable: false:小程序窗口不可改变大小、最大化和全屏
  • show: falseready-to-show 事件配合,优雅打开小程序窗口,避免白屏等待

创建小程序主窗口 preload 脚本,用于向小程序主窗口渲染进程暴露 ipcRenderer 等 API :

import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'

if (process.contextIsolated) {
  try {
    contextBridge.exposeInMainWorld('electron', electronAPI)
  } catch (error) {
    console.error(error)
  }
} else {
  // @ts-ignore (define in dts)
  window.electron = electronAPI
}

主要代码解读:

  • @electron-toolkit/preload 开发库,用于向渲染(renderer)进程暴露 ipcRendererwebFrameprocess 等 API。
  • 在渲染进程中使用如: window.electron.ipcRenderer.send('electron:say', 'hello')
  • 用法详见:github.com/alex8088/el…

基于无边窗口(frameless window),我们需要为渲染进程提供窗口管理 API(最小化,关闭):

import { optimizer } from '@electron-toolkit/utils'

app.whenReady().then(() => {
  optimizer.registerFramelessWindowIpc()

  // ...
})

主要代码解读:

  • @electron-toolkit/utils 开发库,用于主进程,并为主进程提供一系列实用API。
  • registerFramelessWindowIpc 用于注册无边窗口管理API,渲染进程通过调用 ipcRenderer 触发窗口管理事件(win:invoke),如:调用 ipcRenderer.send('win:invoke', 'close') 关闭窗口
  • 更多API用法详见:github.com/alex8088/el…

实现小程序主控制栏

20220413191640.png

如上图,主控制栏由 Vue3 实现,下面是主要的实现代码:

// TitleBar.vue
<script setup lang="ts">
import { ref } from 'vue'
import useIpcRendererOn from '../hooks/useIpcRendererOn'

const canGoback = ref(true)

const handleClick = (actionstring): void => {
  window.electron.ipcRenderer.send('win:invoke', action)
}

const handleClick2 = (): void => {
  window.electron.ipcRenderer.send('micro-app:go-back')
}

useIpcRendererOn('app:can-go-back'(_, arg: boolean) => {
  canGoback.value = arg
})
</script>

<template>
  <div class="title-bar">
    <div class="nav">
      <div class="go-back" :class="{ hide: !canGoback }" title="Go back" @click="handleClick2"></div>
    </div>
    <div id="title" class="title"></div>
    <div class="opr">
      <div class="opr-area">
        <a id="min" class="btn btn-min" title="Minimize" @click="handleClick('min')"><span></span></a>
        <a id="close" class="btn btn-close" title="Maximize" @click="handleClick('close')"><span></span></a>
      </div>
    </div>
  </div>
</template>

<style>
.title-bar {
  -webkit-app-region: drag;
  /*...*/
}
// ...
</style>

主要代码解读:

  • 基于主窗口 preload 脚本提供 ipcRenderer API 实现窗口最小化和关闭
  • 监听小程序应用路由变化,动态展示回退按钮
  • 为控制栏指定 webkit-app-region: drag CSS,实现窗口可拖拽

创建小程序容器

回到主进程中创建小程序容器并嵌入到小程序主窗口中:

const createView = (hostWin: BrowserWindow, url: string): void => {
  const view new BrowserView({
    webPreferences: {
      backgroundThrottlingfalse,
      v8CacheOptions'none',
      preload: path.join(__dirname, '../preload/view.js')
    }
  })
  hostWin.focus()
  hostWin.setBrowserView(view)
  view.setBounds({ x0, y45, width380, height595 })
  view.webContents.loadURL(url)

  // ...
}

主要代码解读:

  • 通过 BrowserView 创建子窗口,并通过 setBounds 方法指定子窗口附加在主窗口的位置,y: 45 即是主窗口控制栏的高度位置

让小程序容器支持移动模式,这是实现整个桌面小程序的关键:

  view.webContents.on('dom-ready', () => {
    view.webContents.focus()
    // Mobile emulation
    view.webContents.enableDeviceEmulation({
      screenPosition'mobile',
      screenSize: { width380, height595 },
      viewPosition: { x0, y0 },
      viewSize: { width380, height595 },
      scale1,
      deviceScaleFactor0
    })
  })

主要代码解读:

  • 通过 webContents.enableDeviceEmulation 方法将小程序容器调整为 mobile 仿真模式

小程序路由控制实现:

  view.webContents.on('did-navigate-in-page', () => {
    if (hostWin) {
      hostWin.webContents.send('app:can-go-back', view.webContents.canGoBack())
    }
  })

  ipcMain.on('micro-app:go-back'async (event) => {
    const win = BrowserWindow.fromWebContents(event.sender)
    if (win) {
      const view = win.getBrowserViews()[0]
      view?.webContents.goBack()
    }
  })

主要代码解读:

  • 通常 H5 应用都是基于单页应用开发的,因此借助 did-navigate-in-page 来监视小程序应用页内导航,并通过 IPC 通知主窗口控制栏调整 UI 变化
  • 监听主控制栏后退事件,并实现小程序应用路由后退处理

小程序 API 设计

桌面小程序除了能承载 H5 应用程序外,还应该能够为 H5 应用程序拓展更多的能力,比如:

  • 打开新的小程序
  • 实现登录认证
  • 主题切换
  • ...

下面以“主题切换”为例,设计小程序的 API 。

创建小程序容器 preload 脚本,为小程序应用提供 API :

import { contextBridge, ipcRenderer } from 'electron'

const api = {
  changeThemeColor: (colorstring): void => {
    ipcRenderer.send('app:change-theme-color', color)
  }
}

if (process.contextIsolated) {
  try {
    contextBridge.exposeInMainWorld('app', api)
  } catch (error) {
    console.error(error)
  }
} else {
  // @ts-ignore (define in dts)
  window.app = api
}

在主进程中实现主题切换处理:

ipcMain.on('app:change-theme-color', async (_, color) => {
    let css = ''
    if (color) {
      css = `body { --theme-color: ${color} };`
    } else {
      css = `body { --theme-color: #fff };`
    }
    hostWin.webContents.insertCSS(css)
  })

主要代码解读:

  • 在主窗口渲染进程中,基于 css variables 来设计主题 UI
  • 在主进程中通过 webContents.insertCSS 来覆盖主题,切换主窗口主题,以配合所加载的 H5 应用程序主题设计

在 H5 应用程序中调用小程序 API :

window.app.changeThemeColor('#4a7fed')

安全性建议

桌面小程序通常加载的是远程 Web 内容,因此我们应该格外注意其安全问题。因为 Electron 不仅仅是浏览器,它还提供了很多原生功能比如:文件系统访问、 Shell 等等。如果开发者忽略这些安全问题,可能带来的危害是无法想象的。

  • 禁用 nodeIntegration 并开启 contextIsolation

拓展

上文中我们讲到了小程序的 API 设计,我们仅仅考虑的是桌面端,但在企业实际应用中,H5应用程序还会运行在移动 app 上,我们需要考虑两者的兼容问题。

如果移动 app 提供的 API 也能像 Electron 一样将 API 暴露在全局 window 上,那么兼容性问题也就不存在。

但现实很多 app 与 H5 应用的调用方式都是通过自定义协议拦截方式实现的,而 H5 应用端则通过 location.href 发起请求调用。

基于这种方式的 API 设计,在 Electron 中也同样能够实现,即可通过监听 will-navigate 事件来拦截处理。

结语

由于篇幅原因,本文并未展示所有代码,完整的示例代码已经开源至 Github ,各位感兴趣的小伙伴可以前往参考研究。

github.com/alex8088/el…