用JS开发跨平台桌面应用,从原理到实践

导读

使用Electron开发客户端程序已经有一段时间了,整体感觉还是非常不错的,其中也遇到了一些坑点,本文是从【运行原理】到【实际应用】对Electron进行一次系统性的总结。【多图,长文预警~】

本文所有实例代码均在我的github electron-react上,结合代码阅读文章效果更佳。另外electron-react还可作为使用Electron + React + Mobx + Webpack技术栈的脚手架工程。

一、桌面应用程序

桌面应用程序,又称为 GUI 程序(Graphical User Interface),但是和 GUI 程序也有一些区别。桌面应用程序 将 GUI 程序从GUI 具体为“桌面”,使冷冰冰的像块木头一样的电脑概念更具有 人性化,更生动和富有活力。

我们电脑上使用的各种客户端程序都属于桌面应用程序,近年来WEB和移动端的兴起让桌面程序渐渐暗淡,但是在某些日常功能或者行业应用中桌面应用程序仍然是必不可少的。

传统的桌面应用开发方式,一般是下面两种:

1.1 原生开发

直接将语言编译成可执行文件,直接调用系统API,完成UI绘制等。这类开发技术,有着较高的运行效率,但一般来说,开发速度较慢,技术要求较高,例如:

  • 使用C++ / MFC开发Windows应用
  • 使用Objective-C开发MAC应用

1.2 托管平台

一开始就有本地开发和UI开发。一次编译后,得到中间文件,通过平台或虚机完成二次加载编译或解释运行。运行效率低于原生编译,但平台优化后,其效率也是比较可观的。就开发速度方面,比原生编译技术要快一些。例如:

  • 使用C# / .NET Framework(只能开发Windows应用)
  • Java / Swing

不过,上面两种对前端开发人员太不友好了,基本是前端人员不会涉及的领域,但是在这个【大前端?】的时代,前端开发者正在想方设法涉足各个领域,使用WEB技术开发客户端的方式横空出世。

1.3 WEB开发

使用WEB技术进行开发,利用浏览器引擎完成UI渲染,利用Node.js实现服务器端JS编程并可以调用系统API,可以把它想像成一个套了一个客户端外壳的WEB应用。

在界面上,WEB的强大生态为UI带来了无限可能,并且开发、维护成本相对较低,有WEB开发经验的前端开发者很容易上手进行开发。

本文就来着重介绍使用WEB技术开发客户端程序的技术之一【electron

二、Electron

Electron是由Github开发,用HTML,CSSJavaScript来构建跨平台桌面应用程序的一个开源库。 Electron通过将ChromiumNode.js合并到同一个运行时环境中,并将其打包为Mac,WindowsLinux系统下的应用来实现这一目的。

2.1 使用Electron开发的理由:

  • 1.使用具有强大生态的Web技术进行开发,开发成本低,可扩展性强,更炫酷的UI
  • 2.跨平台,一套代码可打包为Windows、Linux、Mac三套软件,且编译快速
  • 3.可直接在现有Web应用上进行扩展,提供浏览器不具备的能力
  • 4.你是一个前端?‍?~

当然,我们也要认清它的缺点:性能比原生桌面应用要低,最终打包后的应用比原生应用大很多。

2.2 开发体验

兼容性

虽然你还在用WEB技术进行开发,但是你不用再考虑兼容性问题了,你只需要关心你当前使用Electron的版本对应Chrome的版本,一般情况下它已经足够新来让你使用最新的API和语法了,你还可以手动升级Chrome版本。同样的,你也不用考虑不同浏览器带的样式和代码兼容问题。

Node环境

这可能是很多前端开发者曾经梦想过的功能,在WEB界面中使用Node.js提供的强大API,这意味着你在WEB页面直接可以操作文件,调用系统API,甚至操作数据库。当然,除了完整的Node API,你还可以使用额外的几十万个npm模块。

跨域

你可以直接使用Node提供的request模块进行网络请求,这意味着你无需再被跨域所困扰。

强大的扩展性

借助node-ffi,为应用程序提供强大的扩展性(后面的章节会详细介绍)。

2.3 谁在用Electron

现在市面上已经有非常多的应用在使用Electron进行开发了,包括我们熟悉的VS Code客户端、GitHub客户端、Atom客户端等等。印象很深的,去年迅雷在发布迅雷X10.1时的文案:

从迅雷X 10.1版本开始,我们采用Electron软件框架完全重写了迅雷主界面。使用新框架的迅雷X可以完美支持2K、4K等高清显示屏,界面中的文字渲染也更加清晰锐利。从技术层面来说,新框架的界面绘制、事件处理等方面比老框架更加灵活高效,因此界面的流畅度也显著优于老框架的迅雷。至于具体提升有多大?您一试便知。

你可以打开VS Code,点击【帮助】【切换开发人员工具】来调试VS Code客户端的界面。

三、Electron运行原理

Electron 结合了 ChromiumNode.js 和用于调用操作系统本地功能的API

3.1 Chromium

ChromiumGoogle为发展Chrome浏览器而启动的开源项目,Chromium相当于Chrome的工程版或称实验版,新功能会率先在Chromium上实现,待验证后才会应用在Chrome上,故Chrome的功能会相对落后但较稳定。

ChromiumElectron提供强大的UI能力,可以在不考虑兼容性的情况下开发界面。

3.2 Node.js

Node.js是一个让JavaScript运行在服务端的开发平台,Node使用事件驱动,非阻塞I/O模型而得以轻量和高效。

单单靠Chromium是不能具备直接操作原生GUI能力的,Electron内集成了Nodejs,这让其在开发界面的同时也有了操作系统底层API的能力,Nodejs 中常用的 Path、fs、Crypto 等模块在 Electron 可以直接使用。

3.3 系统API

为了提供原生系统的GUI支持,Electron内置了原生应用程序接口,对调用一些系统功能,如调用系统通知、打开系统文件夹提供支持。

在开发模式上,Electron在调用系统API和绘制界面上是分离开发的,下面我们来看看Electron关于进程如何划分。

3.4 主进程

Electron区分了两种进程:主进程和渲染进程,两者各自负责自己的职能。

Electron 运行package.jsonmain 脚本的进程被称为主进程。一个 Electron 应用总是有且只有一个主进程。

职责:

  • 创建渲染进程(可多个)
  • 控制了应用生命周期(启动、退出APP以及对APP做一些事件监听)
  • 调用系统底层功能、调用原生资源

可调用的API:

  • Node.js API
  • Electron提供的主进程API(包括一些系统功能和Electron附加功能)

3.5 渲染进程

由于 Electron 使用了 Chromium 来展示 web 页面,所以 Chromium 的多进程架构也被使用到。 每个Electron 中的 web页面运行在它自己的渲染进程中。

主进程使用 BrowserWindow 实例创建页面。 每个 BrowserWindow 实例都在自己的渲染进程里运行页面。 当一个 BrowserWindow 实例被销毁后,相应的渲染进程也会被终止。

你可以把渲染进程想像成一个浏览器窗口,它能存在多个并且相互独立,不过和浏览器不同的是,它能调用Node API

职责:

  • HTMLCSS渲染界面
  • JavaScript做一些界面交互

可调用的API:

  • DOM API
  • Node.js API
  • Electron提供的渲染进程API

四、Electron基础

4.1 Electron API

在上面的章节我们提到,渲染进和主进程分别可调用的Electron API。所有ElectronAPI都被指派给一种进程类型。 许多API只能被用于主进程中,有些API又只能被用于渲染进程,又有一些主进程和渲染进程中都可以使用。

你可以通过如下方式获取Electron API

const { BrowserWindow, ... } = require('electron')

下面是一些常用的Electron API

在后面的章节我们会选择其中常用的模块进行详细介绍。

4.2 使用 Node.js 的 API

你可以同时在Electron的主进程和渲染进程使用Node.js API,)所有在Node.js可以使用的API,在Electron中同样可以使用。

import {shell} from 'electron';
import os from 'os';

document.getElementById('btn').addEventListener('click', () => { 
  shell.showItemInFolder(os.homedir());
})

有一个非常重要的提示: 原生Node.js模块 (即指,需要编译源码过后才能被使用的模块) 需要在编译后才能和Electron一起使用。

4.3 进程通信

主进程和渲染进程虽然拥有不同的职责,然是他们也需要相互协作,互相通讯。

例如:在web页面管理原生GUI资源是很危险的,会很容易泄露资源。所以在web页面,不允许直接调用原生GUI相关的API。渲染进程如果想要进行原生的GUI操作,就必须和主进程通讯,请求主进程来完成这些操作。

4.4 渲染进程向主进程通信

ipcRenderer 是一个 EventEmitter 的实例。 你可以使用它提供的一些方法,从渲染进程发送同步或异步的消息到主进程。 也可以接收主进程回复的消息。

在渲染进程引入ipcRenderer

import { ipcRenderer } from 'electron';

异步发送:

通过 channel 发送同步消息到主进程,可以携带任意参数。

在内部,参数会被序列化为 JSON,因此参数对象上的函数和原型链不会被发送。

ipcRenderer.send('sync-render', '我是来自渲染进程的异步消息');

同步发送:

 const msg = ipcRenderer.sendSync('async-render', '我是来自渲染进程的同步消息');

注意: 发送同步消息将会阻塞整个渲染进程,直到收到主进程的响应。

主进程监听消息:

ipcMain模块是EventEmitter类的一个实例。 当在主进程中使用时,它处理从渲染器进程(网页)发送出来的异步和同步信息。 从渲染器进程发送的消息将被发送到该模块。

ipcMain.on:监听 channel,当接收到新的消息时 listener 会以 listener(event, args...) 的形式被调用。

  ipcMain.on('sync-render', (event, data) => {
    console.log(data);
  });

4.5 主进程向渲染进程通信

在主进程中可以通过BrowserWindowwebContents向渲染进程发送消息,所以,在发送消息前你必须先找到对应渲染进程的BrowserWindow对象。:

const mainWindow = BrowserWindow.fromId(global.mainId);
 mainWindow.webContents.send('main-msg', `ConardLi]`)

根据消息来源发送:

ipcMain接受消息的回调函数中,通过第一个参数event的属性sender可以拿到消息来源渲染进程的webContents对象,我们可以直接用此对象回应消息。

  ipcMain.on('sync-render', (event, data) => {
    console.log(data);
    event.sender.send('main-msg', '主进程收到了渲染进程的【异步】消息!')
  });

渲染进程监听:

ipcRenderer.on:监听 channel, 当新消息到达,将通过listener(event, args...)调用 listener

ipcRenderer.on('main-msg', (event, msg) => {
    console.log(msg);
})

4.6 通信原理

ipcMainipcRenderer 都是 EventEmitter 类的一个实例。EventEmitter 类是 NodeJS 事件的基础,它由 NodeJS 中的 events 模块导出。

EventEmitter 的核心就是事件触发与事件监听器功能的封装。它实现了事件模型需要的接口, 包括 addListener,removeListener, emit 及其它工具方法. 同原生 JavaScript 事件类似, 采用了发布/订阅(观察者)的方式, 使用内部 _events 列表来记录注册的事件处理器。

我们通过 ipcMainipcRendereron、send 进行监听和发送消息都是 EventEmitter 定义的相关接口。

4.7 remote

remote 模块为渲染进程(web页面)和主进程通信(IPC)提供了一种简单方法。 使用 remote 模块, 你可以调用 main 进程对象的方法, 而不必显式发送进程间消息, 类似于 JavaRMI

import { remote } from 'electron';

remote.dialog.showErrorBox('主进程才有的dialog模块', '我是使用remote调用的')

但实际上,我们在调用远程对象的方法、函数或者通过远程构造函数创建一个新的对象,实际上都是在发送一个同步的进程间消息。

在上面通过 remote 模块调用 dialog 的例子里。我们在渲染进程中创建的 dialog 对象其实并不在我们的渲染进程中,它只是让主进程创建了一个 dialog 对象,并返回了这个相对应的远程对象给了渲染进程。

4.8 渲染进程间通信

Electron并没有提供渲染进程之间相互通信的方式,我们可以在主进程中建立一个消息中转站。

渲染进程之间通信首先发送消息到主进程,主进程的中转站接收到消息后根据条件进行分发。

4.9 渲染进程数据共享

在两个渲染进程间共享数据最简单的方法是使用浏览器中已经实现的HTML5 API。 其中比较好的方案是用Storage APIlocalStorage,sessionStorage 或者 IndexedDB。

就像在浏览器中使用一样,这种存储相当于在应用程序中永久存储了一部分数据。有时你并不需要这样的存储,只需要在当前应用程序的生命周期内进行一些数据的共享。这时你可以用 Electron 内的 IPC 机制实现。

将数据存在主进程的某个全局变量中,然后在多个渲染进程中使用 remote 模块来访问它。

在主进程中初始化全局变量:

global.mainId = ...;
global.device = {...};
global.__dirname = __dirname;
global.myField = { name: 'ConardLi' };

在渲染进程中读取:

import { ipcRenderer, remote } from 'electron';

const { getGlobal } = remote;

const mainId = getGlobal('mainId')
const dirname = getGlobal('__dirname')
const deviecMac = getGlobal('device').mac;

在渲染进程中改变:

getGlobal('myField').name = 'code秘密花园';

多个渲染进程共享同一个主进程的全局变量,这样即可达到渲染进程数据共享和传递的效果。

五、窗口

5.1 BrowserWindow

主进程模块BrowserWindow用于创建和控制浏览器窗口。

  mainWindow = new BrowserWindow({
    width: 1000,
    height: 800,
    // ...
  });
  mainWindow.loadURL('http://www.conardli.top/');

你可以在这里查看它所有的构造参数。

5.2 无框窗口

无框窗口是没有镶边的窗口,窗口的部分(如工具栏)不属于网页的一部分。

BrowserWindow的构造参数中,将frame设置为false可以指定窗口为无边框窗口,将工具栏隐藏后,就会产生两个问题:

  • 1.窗口控制按钮(最小化、全屏、关闭按钮)会被隐藏
  • 2.无法拖拽移动窗口

可以通过指定titleBarStyle选项来再将工具栏按钮显示出来,将其设置为hidden表示返回一个隐藏标题栏的全尺寸内容窗口,在左上角仍然有标准的窗口控制按钮。

new BrowserWindow({
    width: 200,
    height: 200,
    titleBarStyle: 'hidden',
    frame: false
  });

5.3 窗口拖拽

默认情况下, 无边框窗口是不可拖拽的。我们可以在界面中通过CSS属性-webkit-app-region: drag手动制定拖拽区域。

在无框窗口中, 拖动行为可能与选择文本冲突,可以通过设定-webkit-user-select: none;禁用文本选择:

.header {
  -webkit-user-select: none;
  -webkit-app-region: drag;
}

相反的,在可拖拽区域内部设置 -webkit-app-region: no-drag则可以指定特定不可拖拽区域。

5.4 透明窗口

通过将transparent选项设置为true, 还可以使无框窗口透明:

new BrowserWindow({
    transparent: true,
    frame: false
  });

5.5 Webview

使用 webview 标签在Electron 应用中嵌入 “外来” 内容。外来内容包含在 webview 容器中。 应用中的嵌入页面可以控制外来内容的布局和重绘。

iframe 不同, webview 在与应用程序不同的进程中运行。它与您的网页没有相同的权限, 应用程序和嵌入内容之间的所有交互都将是异步的。

六、对话框

dialog 模块提供了api来展示原生的系统对话框,例如打开文件框,alert框,所以web应用可以给用户带来跟系统应用相同的体验。

注意:dialog是主进程模块,想要在渲染进程调用可以使用remote

6.1 错误提示

dialog.showErrorBox用于显示一个显示错误消息的模态对话框。

 remote.dialog.showErrorBox('错误', '这是一个错误弹框!')

6.2 对话框

dialog.showErrorBox用于调用系统对话框,可以为指定几种不同的类型: “none“, “info“, “error“, “question” 或者 “warning“。

在 Windows 上, “question” 与”info”显示相同的图标, 除非你使用了 “icon” 选项设置图标。 在 macOS 上, “warning” 和 “error” 显示相同的警告图标

remote.dialog.showMessageBox({
  type: 'info',
  title: '提示信息',
  message: '这是一个对话弹框!',
  buttons: ['确定', '取消']
}, (index) => {
  this.setState({ dialogMessage: `【你点击了${index ? '取消' : '确定'}!!】` })
})

6.3 文件框

dialog.showOpenDialog用于打开或选择系统目录。

remote.dialog.showOpenDialog({
  properties: ['openDirectory', 'openFile']
}, (data) => {
  this.setState({ filePath: `【选择路径:${data[0]}】 ` })
})

6.4 信息框

这里推荐直接使用HTML5 API,它只能在渲染器进程中使用。

let options = {
  title: '信息框标题',
  body: '我是一条信息~~~',
}
let myNotification = new window.Notification(options.title, options)
myNotification.onclick = () => {
  this.setState({ message: '【你点击了信息框!!】' })
}

七、系统

7.1 获取系统信息

通过remote获取到主进程的process对象,可以获取到当前应用的各个版本信息:

  • process.versions.electronelectron版本信息
  • process.versions.chromechrome版本信息
  • process.versions.nodenode版本信息
  • process.versions.v8v8版本信息

获取当前应用根目录:

remote.app.getAppPath()

使用nodeos模块获取当前系统根目录:

os.homedir();

7.2 复制粘贴

Electron提供的clipboard在渲染进程和主进程都可使用,用于在系统剪贴板上执行复制和粘贴操作。

以纯文本的形式写入剪贴板:

clipboard.writeText(text[, type])

以纯文本的形式获取剪贴板的内容:

clipboard.readText([type])

7.3 截图

desktopCapturer用于从桌面捕获音频和视频的媒体源的信息。它只能在渲染进程中被调用。

下面的代码是一个获取屏幕截图并保存的实例:

  getImg = () => {
    this.setState({ imgMsg: '正在截取屏幕...' })
    const thumbSize = this.determineScreenShotSize()
    let options = { types: ['screen'], thumbnailSize: thumbSize }
    desktopCapturer.getSources(options, (error, sources) => {
      if (error) return console.log(error)
      sources.forEach((source) => {
        if (source.name === 'Entire screen' || source.name === 'Screen 1') {
          const screenshotPath = path.join(os.tmpdir(), 'screenshot.png')
          fs.writeFile(screenshotPath, source.thumbnail.toPNG(), (error) => {
            if (error) return console.log(error)
            shell.openExternal(`file://${screenshotPath}`)
            this.setState({ imgMsg: `截图保存到: ${screenshotPath}` })
          })
        }
      })
    })
  }

  determineScreenShotSize = () => {
    const screenSize = screen.getPrimaryDisplay().workAreaSize
    const maxDimension = Math.max(screenSize.width, screenSize.height)
    return {
      width: maxDimension * window.devicePixelRatio,
      height: maxDimension * window.devicePixelRatio
    }
  }

八、菜单

应用程序的菜单可以帮助我们快捷的到达某一功能,而不借助客户端的界面资源,一般菜单分为两种:

  • 应用程序菜单:位于应用程序顶部,在全局范围内都能使用
  • 上下文菜单:可自定义任意页面显示,自定义调用,如右键菜单

Electron为我们提供了Menu模块用于创建本机应用程序菜单和上下文菜单,它是一个主进程模块。

你可以通过Menu的静态方法buildFromTemplate(template),使用自定义菜单模版来构造一个菜单对象。

template是一个MenuItem的数组,我们来看看MenuItem的几个重要参数:

  • label:菜单显示的文字
  • click:点击菜单后的事件处理函数
  • role:系统预定义的菜单,例如copy(复制)、paste(粘贴)、minimize(最小化)…
  • enabled:指示是否启用该项目,此属性可以动态更改
  • submenu:子菜单,也是一个MenuItem的数组

推荐:最好指定role与标准角色相匹配的任何菜单项,而不是尝试手动实现click函数中的行为。内置role行为将提供最佳的本地体验。

下面的实例是一个简单的额菜单template

const template = [
  {
    label: '文件',
    submenu: [
      {
        label: '新建文件',
        click: function () {
          dialog.showMessageBox({
            type: 'info',
            message: '嘿!',
            detail: '你点击了新建文件!',
          })
        }
      }
    ]
  },
  {
    label: '编辑',
    submenu: [{
      label: '剪切',
      role: 'cut'
    }, {
      label: '复制',
      role: 'copy'
    }, {
      label: '粘贴',
      role: 'paste'
    }]
  },
  {
    label: '最小化',
    role: 'minimize'
  }
]

8.1 应用程序菜单

使用Menu的静态方法setApplicationMenu,可创建一个应用程序菜单,在 WindowsLinux 上,menu将被设置为每个窗口的顶层菜单。

注意:必须在模块ready事件后调用此 API app。

我们可以根据应用程序不同的的生命周期,不同的系统对菜单做不同的处理。

app.on('ready', function () {
  const menu = Menu.buildFromTemplate(template)
  Menu.setApplicationMenu(menu)
})

app.on('browser-window-created', function () {
  let reopenMenuItem = findReopenMenuItem()
  if (reopenMenuItem) reopenMenuItem.enabled = false
})

app.on('window-all-closed', function () {
  let reopenMenuItem = findReopenMenuItem()
  if (reopenMenuItem) reopenMenuItem.enabled = true
})

if (process.platform === 'win32') {
  const helpMenu = template[template.length - 1].submenu
  addUpdateMenuItems(helpMenu, 0)
}

8.2 上下文菜单

使用Menu的实例方法menu.popup可自定义弹出上下文菜单。

    let m = Menu.buildFromTemplate(template)
    document.getElementById('menuDemoContainer').addEventListener('contextmenu', (e) => {
      e.preventDefault()
      m.popup({ window: remote.getCurrentWindow() })
    })

8.3 快捷键

在菜单选项中,我们可以指定一个accelerator属性来指定操作的快捷键:

  {
    label: '最小化',
    accelerator: 'CmdOrCtrl+M',
    role: 'minimize'
  }

另外,我们还可以使用globalShortcut来注册全局快捷键。

    globalShortcut.register('CommandOrControl+N', () => {
      dialog.showMessageBox({
        type: 'info',
        message: '嘿!',
        detail: '你触发了手动注册的快捷键.',
      })
    })

CommandOrControl代表在macOS上为Command键,以及在Linux和Windows上为Control键。

九、打印

很多情况下程序中使用的打印都是用户无感知的。并且想要灵活的控制打印内容,往往需要借助打印机给我们提供的api再进行开发,这种开发方式非常繁琐,并且开发难度较大。第一次在业务中用到Electron其实就是用到它的打印功能,这里就多介绍一些。

Electron提供的打印api可以非常灵活的控制打印设置的显示,并且可以通过html来书写打印内容。Electron提供了两种方式进行打印,一种是直接调用打印机打印,一种是打印到pdf

并且有两种对象可以调用打印:

  • 通过windowwebcontent对象,使用此种方式需要单独开出一个打印的窗口,可以将该窗口隐藏,但是通信调用相对复杂。
  • 使用页面的webview元素调用打印,可以将webview隐藏在调用的页面中,通信方式比较简单。

上面两种方式同时拥有printprintToPdf方法。

9.1 调用系统打印

contents.print([options], [callback])

打印配置(options)中只有简单的三个配置:

  • silent:打印时是否不展示打印配置(是否静默打印)
  • printBackground:是否打印背景
  • deviceName:打印机设备名称

首先要将我们使用的打印机名称配置好,并且要在调用打印前首先要判断打印机是否可用。

使用webContentsgetPrinters方法可获取当前设备已经配置的打印机列表,注意配置过不是可用,只是在此设备上安装过驱动。

通过getPrinters获取到的打印机对象:https://electronjs.org/docs/api/structures/printer-info

我们这里只管关心两个,namestatusstatus0时表示打印机可用。

print的第二个参数callback是用于判断打印任务是否发出的回调,而不是打印任务完成后的回调。所以一般打印任务发出,回调函数即会调用并返回参数true。这个回调并不能判断打印是否真的成功了。

    if (this.state.curretnPrinter) {
      mainWindow.webContents.print({
        silent: silent, printBackground: true, deviceName: this.state.curretnPrinter
      }, () => { })
    } else {
      remote.dialog.showErrorBox('错误', '请先选择一个打印机!')
    }

9.2 打印到PDF

printToPdf的用法基本和print相同,但是print的配置项非常少,而printToPdf则扩展了很多属性。这里翻了一下源码发现还有很多没有被贴进api的,大概有三十几个包括可以对打印的margin,打印页眉页脚等进行配置。

contents.printToPDF(options, callback)

callback函数在打印失败或打印成功后调用,可获取打印失败信息或包含PDF数据的缓冲区。

    const pdfPath = path.join(os.tmpdir(), 'webviewPrint.pdf');
    const webview = document.getElementById('printWebview');
    const renderHtml = '我是被临时插入webview的内容...';
    webview.executeJavaScript('document.documentElement.innerHTML =`' + renderHtml + '`;');
    webview.printToPDF({}, (err, data) => {
      console.log(err, data);
      fs.writeFile(pdfPath, data, (error) => {
        if (error) throw error
        shell.openExternal(`file://${pdfPath}`)
        this.setState({ webviewPdfPath: pdfPath })
      });
    });

这个例子中的打印是使用webview完成的,通过调用executeJavaScript方法可动态向webview插入打印内容。

9.3 两种打印方案的选择

上面提到,使用webviewwebcontent都可以调用打印功能,使用webcontent打印,首先要有一个打印窗口,这个窗口不能随时打印随时创建,比较耗费性能。可以将它在程序运行时启动好,并做好事件监听。

此过程需和调用打印的进行做好通信,大致过程如下:

可见通信非常繁琐,使用webview进行打印可实现同样的效果但是通信方式会变得简单,因为渲染进程和webview通信不需要经过主进程,通过如下方式即可:

  const webview = document.querySelector('webview')
  webview.addEventListener('ipc-message', (event) => {
    console.log(event.channel)
  })
  webview.send('ping')const {ipcRenderer} = require('electron')
  ipcRenderer.on('ping', () => {
    ipcRenderer.sendToHost('pong')
  })

之前专门为ELectron打印写过一个DEMO:electron-print-demo有兴趣可以clone下来看一下。

9.4 打印功能封装

下面是几个针对常用打印功能的工具函数封装。

/**
 * 获取系统打印机列表
 */
export function getPrinters() {
  let printers = [];
  try {
    const contents = remote.getCurrentWindow().webContents;
    printers = contents.getPrinters();
  } catch (e) {
    console.error('getPrintersError', e);
  }
  return printers;
}
/**
 * 获取系统默认打印机
 */
export function getDefaultPrinter() {
  return getPrinters().find(element => element.isDefault);
}
/**
 * 检测是否安装了某个打印驱动
 */
export function checkDriver(driverMame) {
  return getPrinters().find(element => (element.options["printer-make-and-model"] || '').includes(driverMame));
}
/**
 * 根据打印机名称获取打印机对象
 */
export function getPrinterByName(name) {
  return getPrinters().find(element => element.name === name);
}

十、程序保护

10.1 崩溃

崩溃监控是每个客户端程序必备的保护功能,当程序崩溃时我们一般期望做到两件事:

  • 1.上传崩溃日志,及时报警
  • 2.监控程序崩溃,提示用户重启程序

electron为我们提供给了crashReporter来帮助我们记录崩溃日志,我们可以通过crashReporter.start来创建一个崩溃报告器:

const { crashReporter } = require('electron')
crashReporter.start({
  productName: 'YourName',
  companyName: 'YourCompany',
  submitURL: 'https://your-domain.com/url-to-submit',
  uploadToServer: true
})

当程序发生崩溃时,崩溃报日志将被储存在临时文件夹中名为YourName Crashes的文件文件夹中。submitURL用于指定你的崩溃日志上传服务器。 在启动崩溃报告器之前,您可以通过调用app.setPath('temp', 'my/custom/temp')API来自定义这些临时文件的保存路径。你还可以通过crashReporter.getLastCrashReport()来获取上次崩溃报告的日期和ID

我们可以通过webContentscrashed来监听渲染进程的崩溃,另外经测试有些主进程的崩溃也会触发该事件。所以我们可以根据主window是否被销毁来判断进行不同的重启逻辑,下面使整个崩溃监控的逻辑:

import { BrowserWindow, crashReporter, dialog } from 'electron';
// 开启进程崩溃记录
crashReporter.start({
  productName: 'electron-react',
  companyName: 'ConardLi',
  submitURL: 'http://xxx.com',  // 上传崩溃日志的接口
  uploadToServer: false
});
function reloadWindow(mainWin) {
  if (mainWin.isDestroyed()) {
    app.relaunch();
    app.exit(0);
  } else {
    // 销毁其他窗口
    BrowserWindow.getAllWindows().forEach((w) => {
      if (w.id !== mainWin.id) w.destroy();
    });
    const options = {
      type: 'info',
      title: '渲染器进程崩溃',
      message: '这个进程已经崩溃.',
      buttons: ['重载', '关闭']
    }
    dialog.showMessageBox(options, (index) => {
      if (index === 0) mainWin.reload();
      else mainWin.close();
    })
  }
}
export default function () {
  const mainWindow = BrowserWindow.fromId(global.mainId);
  mainWindow.webContents.on('crashed', () => {
    const errorMessage = crashReporter.getLastCrashReport();
    console.error('程序崩溃了!', errorMessage); // 可单独上传日志
    reloadWindow(mainWindow);
  });
}

10.2 最小化到托盘

有的时候我们并不想让用户通过点关闭按钮的时候就关闭程序,而是把程序最小化到托盘,在托盘上做真正的退出操作。

首先要监听窗口的关闭事件,阻止用户关闭操作的默认行为,将窗口隐藏。

function checkQuit(mainWindow, event) {
  const options = {
    type: 'info',
    title: '关闭确认',
    message: '确认要最小化程序到托盘吗?',
    buttons: ['确认', '关闭程序']
  };
  dialog.showMessageBox(options, index => {
    if (index === 0) {
      event.preventDefault();
      mainWindow.hide();
    } else {
      mainWindow = null;
      app.exit(0);
    }
  });
}
function handleQuit() {
  const mainWindow = BrowserWindow.fromId(global.mainId);
  mainWindow.on('close', event => {
    event.preventDefault();
    checkQuit(mainWindow, event);
  });
}

这时程序就再也找不到了,任务托盘中也没有我们的程序,所以我们要先创建好任务托盘,并做好事件监听。

windows平台使用ico文件可以达到更好的效果

export default function createTray() {
  const mainWindow = BrowserWindow.fromId(global.mainId);
  const iconName = process.platform === 'win32' ? 'icon.ico' : 'icon.png'
  tray = new Tray(path.join(global.__dirname, iconName));
  const contextMenu = Menu.buildFromTemplate([
    {
      label: '显示主界面', click: () => {
        mainWindow.show();
        mainWindow.setSkipTaskbar(false);
      }
    },
    {
      label: '退出', click: () => {
        mainWindow.destroy();
        app.quit();
      }
    },
  ])
  tray.setToolTip('electron-react');
  tray.setContextMenu(contextMenu);
}

十一、扩展能力

在很多情况下,你的应用程序要和外部设备进行交互,一般情况下厂商会为你提供硬件设备的开发包,这些开发包基本上都是通过C++ 编写,在使用electron开发的情况下,我们并不具备直接调用C++代码的能力,我们可以利用node-ffi来实现这一功能。

node-ffi提供了一组强大的工具,用于在Node.js环境中使用纯JavaScript调用动态链接库接口。它可以用来为库构建接口绑定,而不需要使用任何C++代码。

注意node-ffi并不能直接调用C++代码,你需要将C++代码编译为动态链接库:在 Windows下是 Dll ,在 Mac OS下是 dylib ,Linuxsonode-ffi 加载 Library是有限制的,只能处理 C风格的 Library

下面是一个简单的实例:

const ffi = require('ffi');
const ref = require('ref');
const SHORT_CODE = ref.refType('short');


const DLL = new ffi.Library('test.dll', {
    Test_CPP_Method: ['int', ['string',SHORT_CODE]], 
  })

testCppMethod(str: String, num: number): void {
  try {
    const result: any = DLL.Test_CPP_Method(str, num);
    return result;
  } catch (error) {
    console.log('调用失败~',error);
  }
}

this.testCppMethod('ConardLi',123);

上面的代码中,我们用ffi包装C++接口生成的动态链接库test.dll,并使用ref进行一些类型映射。

使用JavaScript调用这些映射方法时,推荐使用TypeScript来约定参数类型,因为弱类型的JavaScript在调用强类型语言的接口时可能会带来意想不到的风险。

借助这一能力,前端开发工程师也可以在IOT领域一展身手了?~

十二、环境选择

一般情况下,我们的应用程序可能运行在多套环境下(productionbetauatmokedevelopment…),不同的开发环境可能对应不同的后端接口或者其他配置,我们可以在客户端程序中内置一个简单的环境选择功能来帮助我们更高效的开发。

具体策略如下:

  • 在开发环境中,我们直接进入环境选择页面,读取到选择的环境后进行响应的重定向操作
  • 在菜单保留环境选择入口,以便在开发过程中切换
const envList = ["moke", "beta", "development", "production"];
exports.envList = envList;
const urlBeta = 'https://wwww.xxx-beta.com';
const urlDev = 'https://wwww.xxx-dev.com';
const urlProp = 'https://wwww.xxx-prop.com';
const urlMoke = 'https://wwww.xxx-moke.com';
const path = require('path');
const pkg = require(path.resolve(global.__dirname, 'package.json'));
const build = pkg['build-config'];
exports.handleEnv = {
  build,
  currentEnv: 'moke',
  setEnv: function (env) {
    this.currentEnv = env
  },
  getUrl: function () {
    console.log('env:', build.env);
    if (build.env === 'production' || this.currentEnv === 'production') {
      return urlProp;
    } else if (this.currentEnv === 'moke') {
      return urlMoke;
    } else if (this.currentEnv === 'development') {
      return urlDev;
    } else if (this.currentEnv === "beta") {
      return urlBeta;
    }
  },
  isDebugger: function () {
    return build.env === 'development'
  }
}

十三、打包

最后也是最重要的一步,将写好的代码打包成可运行的.app.exe可执行文件。

这里我把打包氛围两部分来做,渲染进程打包和主进程打包。

13.1 渲染进程打包和升级

一般情况下,我们的大部分业务逻辑代码是在渲染进程完成的,在大部分情况下我们仅仅需要对渲染进程进行更新和升级而不需要改动主进程代码,我们渲染进程的打包实际上和一般的web项目打包没有太大差别,使用webpack打包即可。

这里我说说渲染进程单独打包的好处:

打包完成的htmljs文件,我们一般要上传到我们的前端静态资源服务器下,然后告知服务端我们的渲染进程有代码更新,这里可以说成渲染进程单独的升级。

注意,和壳的升级不同,渲染进程的升级仅仅是静态资源服务器上htmljs文件的更新,而不需要重新下载更新客户端,这样我们每次启动程序的时候检测到离线包有更新,即可直接刷新读取最新版本的静态资源文件,即使在程序运行过程中要强制更新,我们的程序只需要强制刷新页面读取最新的静态资源即可,这样的升级对用户是非常友好的。

这里注意,一旦我们这样配置,就意味着渲染进程和主进程打包升级的完全分离,我们在启动主窗口时读取的文件就不应该再是本地文件,而是打包完成后放在静态资源服务器的文件。

为了方便开发,这里我们可以区分本地和线上加载不同的文件:

function getVersion (mac,current){
  // 根据设备mac和当前版本获取最新版本
}
export default function () {
  if (build.env === 'production') {
    const version = getVersion (mac,current);
    return 'https://www.xxxserver.html/electron-react/index_'+version+'.html';
  }
  return url.format({
    protocol: 'file:',
    pathname: path.join(__dirname, 'env/environment.html'),
    slashes: true,
    query: { debugger: build.env === "development" }
  });
}

具体的webpack配置这里就不再贴出,可以到我的github electron-react/scripts目录下查看。

这里需要注意,在开发环境下我们可以结合webpackdevServerelectron命令来启动app

  devServer: {
    contentBase: './assets/',
    historyApiFallback: true,
    hot: true,
    port: PORT,
    noInfo: false,
    stats: {
      colors: true,
    },
    setup() {
      spawn(
        'electron',
        ['.'],
        {
          shell: true,
          stdio: 'inherit',
        }
      )
        .on('close', () => process.exit(0))
        .on('error', e => console.error(e));
    },
  },//...

13.2 主进程打包

主进程,即将整个程序打包成可运行的客户端程序,常用的打包方案一般有两种,electron-packagerelectron-builder

electron-packager在打包配置上我觉得有些繁琐,而且它只能将应用直接打包为可执行程序。

这里我推荐使用electron-builder,它不仅拥有方便的配置 protocol 的功能、内置的 Auto Update、简单的配置 package.json 便能完成整个打包工作,用户体验非常不错。而且electron-builder不仅能直接将应用打包成exe app等可执行程序,还能打包成msi dmg等安装包格式。

你可以在package.json方便的进行各种配置:

  "build": {
    "productName": "electron-react", // app中文名称
    "appId": "electron-react",// app标识
    "directories": { // 打包后输出的文件夹
      "buildResources": "resources",
      "output": "dist/"
    }
    "files": [ // 打包后依然保留的源文件
      "main_process/",
      "render_process/",
    ],
    "mac": { // mac打包配置
      "target": "dmg",
      "icon": "icon.ico"
    },
    "win": { // windows打包配置
      "target": "nsis",
      "icon": "icon.ico"
    },
    "dmg": { // dmg文件打包配置
      "artifactName": "electron_react.dmg",
      "contents": [
        {
          "type": "link",
          "path": "/Applications",
          "x": 410,
          "y": 150
        },
        {
          "type": "file",
          "x": 130,
          "y": 150
        }
      ]
    },
    "nsis": { // nsis文件打包配置
      "oneClick": false,
      "allowToChangeInstallationDirectory": true,
      "shortcutName": "electron-react"
    },
  }

执行electron-builder打包命令时,可指定参数进行打包。

  --mac, -m, -o, --macos   macOS打包
  --linux, -l              Linux打包
  --win, -w, --windows     Windows打包
  --mwl                    同时为macOS,Windows和Linux打包
  --x64                    x64 (64位安装包)
  --ia32                   ia32(32位安装包)

关于主进程的更新你可以使用electron-builder自带的Auto Update模块,在electron-react也实现了手动更新的模块,由于篇幅原因这里就不再赘述,如果有兴趣可以到我的github查看main下的update模块。

13.3 打包优化

electron-builder打包出来的App要比相同功能的原生客户端应用体积大很多,即使是空的应用,体积也要在100mb以上。原因有很多:

第一点;为了达到跨平台的效果,每个Electron应用都包含了整个V8引擎和Chromium内核。

第二点:打包时会将整个node_modules打包进去,大家都知道一个应用的node_module体积是非常庞大的,这也是使得Electron应用打包后的体积较大的原因。

第一点我们无法改变,我们可以从第二点对应用体积进行优化:Electron在打包时只会将denpendencies的依赖打包进去,而不会将 devDependencies 中的依赖进行打包。所以我们应尽可能的减少denpendencies中的依赖。在上面的进程中,我们使用webpack对渲染进程进行打包,所以渲染进程的依赖全部都可以移入devDependencies

另外,我们还可以使用双packajson.json的方式来进行优化,把只在开发环境中使用到的依赖放在整个项目的根目录的package.json下,将与平台相关的或者运行时需要的依赖装在app目录下。具体详见two-package-structure。

参考

  • https://electronjs.org/docs
  • http://jlord.us/essential-electron/
  • https://imweb.io/topic/5b9f500cc2ec8e6772f34d79
  • https://www.jianshu.com/p/1ece6fd7a80c
  • https://zhuanlan.zhihu.com/p/52991793

本项目源码地址:https://github.com/ConardLi/electron-react

from: https://cloud.tencent.com/developer/article/1446636

发表评论

电子邮件地址不会被公开。 必填项已用*标注