用JS開發跨平臺桌面應用,從原理到實踐

ConardLi發表於2019-06-10

導讀

使用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

Chromium Google 為發展 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.json main 指令碼的程式被稱為主程式。一個 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/a...

我們這裡只管關心兩個,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列印寫過一個DEMOelectron-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 ,Linuxso

node-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://github.com/ConardLi/e...

小結

希望你閱讀本篇文章後可以達到以下幾點:

  • 瞭解Electron的基本執行原理
  • 掌握Electron開發的核心基礎知識
  • 瞭解Electron關於彈框、列印、保護、打包等功能的基本使用

文中如有錯誤,歡迎在評論區指正,如果這篇文章幫助到了你,歡迎點贊和關注。

想閱讀更多優質文章、可關注我的github部落格,你的star✨、點贊和關注是我持續創作的動力!

推薦關注我的微信公眾號【code祕密花園】,每天推送高質量文章,我們一起交流成長。

圖片描述

關注公眾號後回覆【加群】拉你進入優質前端交流群。

相關文章