Electron實踐筆記
- 2019 年 12 月 24 日
- 筆記
社交魔方平台是京東的 SNS 活動搭建平台,其內置了很多模板,每一個模板都有一個模板 JSON 用於生成表單,運營同學、商家配置了這個表單後就可以生成活動頁面了。模板 JSON 是標準的結構化數據,包含名稱、類型、控制項類型、校驗器、默認值等等欄位。以往都是採用手寫 JSON 的方式,這是非常低效的,而且容易出錯。針對其結構化數據的特點可以用 GUI 的方式去編輯,我們基於 Electron[1] 參考 Github Desktop 客戶端[2] 的架構編寫了一個 JSON 編輯器(參見下圖),通過填寫表單的方式生成 JSON。所以在這裡記錄下這個 Electron 編輯器開發過程中可以記錄的點和從 Github Desktop 客戶端程式碼中值得學習的點。

APP
一、關於 Electron
Electron 是由 Github 開發,用 HTML,CSS 和 JavaScript 來構建跨平台桌面應用程式的一個開源庫。Electron 通過將 Chromium 和 Node.js 合併到同一個運行時環境中,並將其打包為 Mac,Windows 和 Linux 系統下的應用來實現這一目的。
上面是來自 Electron 官方的介紹。基於 Electron 平台,我們可以使用熟悉的前端技術棧來開發桌面應用。Electron 運行 package.json 的 main 腳本的進程被稱為主進程(以下簡稱 main)。在主進程中運行的腳本通過創建 web 頁面來展示用戶介面(以下簡稱 renderer)。一個 Electron 應用總是有且只有一個主進程。main 用於創建應用,創建瀏覽器窗口,它就是一個徹底的 Node 進程,獲取不到 DOM, BOM 這些介面。在 main 創建的瀏覽器窗口中運行的就是 renderer 進程,它既可以獲取 DOM, BOM 這些介面,也可以使用 Node 的 API。兩類進程之間可以通過 Electron 提供的 IPC 介面通訊。
二、開發環境搭建
我們了解到 Electron 分為兩類進程,main 和 renderer。所以搭建開發環境時不能像普通的前端應用一樣一個 webpack 配置搞定。並且我們想要實現
- 一鍵啟動開發環境
- 一鍵打包
- 一鍵發布
那麼就需要兩個 webpack 配置文件。
一個用於開發環境 — webpack.dev.ts
。
// webpack.dev.ts const mainConfig = merge({}, base.mainConfig, config, { watch: true }) const rendererConfig = merge({}, base.rendererConfig, config, { module: { rules: [ { test: /.css$/, use: ['style-loader', 'css-loader'], }, { test: /.styl$/, use: ['style-loader', 'css-loader', 'stylus-loader'], } ] }, devServer: { contentBase: path.join(__dirname, base.outputDir), port: 8000, hot: true, inline: true, historyApiFallback: true, writeToDisk: true }, }) module.exports = [rendererConfig, mainConfig]
另一個用於生產環境 — webpack.prod.ts
。
const config: webpack.Configuration = { mode: 'production', devtool: 'source-map', } const mainConfig = merge({}, base.mainConfig, config) const rendererConfig = merge({}, base.rendererConfig, config, { module: { rules: [ { test: /.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader'], }, { test: /.styl$/, use: [MiniCssExtractPlugin.loader, 'css-loader', 'stylus-loader'], } ] }, plugins: [ new MiniCssExtractPlugin({ filename: 'renderer.css' }), new BundleAnalyzerPlugin({ analyzerMode: 'static', openAnalyzer: false, reportFilename: 'renderer.report.html', }), ], }) module.exports = [mainConfig, rendererConfig]
這裡參考了 Desktop 使用 Typescript 編寫 webpack 配置文件。配合 interface 可以實現 webpack 配置文件的編輯器自動補全。具體使用方法可參考 webpack 文檔 https://webpack.js.org/configuration/configuration-languages/#typescript
每一個配置文件導出一個數組,分別是 main, renderer 的配置對象。
使用 webpack-dev-server 啟動能實現 renderer 的熱更新,main 則是使用 webpack 的 watch 模式。
{ "compile:dev": "webpack-dev-server --config scripts/webpack.dev.ts" }
使用 nodemon[3] 監聽 main 編譯後的產物,nodemon 監聽到改動則重新運行 electron .
重啟應用,這樣間接實現了 main 的 livereload。
Nodemon is a utility that will monitor for any changes in your source and automatically restart your server.
{ "app": "electron .", "app:watch": "nodemon --watch 'dest/main.js' --exec npm run app", }
這樣就實現了一鍵啟動開發環境,且能夠監聽程式碼變化,重新啟動應用。
Tips: 開源社區有更好的 electron-webpack[4], HMR for both renderer and main processes
生產環境則使用 webpack 順序編譯 main 和 renderer。編譯完成後使用 electron-builder[5] 打包。這樣就實現了一鍵打包。
由於工具鏈的缺失實現不了一鍵發布,就只能打包後手動發布了(後面詳細說明)。
下面就是完整的 scripts。
{ "scripts": { "start": "run-p -c compile:dev typecheck:watch app:watch", "dist": "npm run compile:prod && electron-builder build --win --mac", "compile:dev": "webpack-dev-server --config scripts/webpack.dev.ts", "compile:prod": "npm run clean && webpack --config scripts/webpack.prod.ts", "app": "electron .", "app:watch": "nodemon --watch 'dest/main.js' --exec npm run app", "clean": "rimraf dest dist", "typecheck": "tsc --noEmit", "typecheck:watch": "tsc --noEmit --watch", "lint": "eslint src --ext .ts,.js --fix", "release:patch": "standard-version --release-as patch && git push --follow-tags origin master && npm run dist", "release:minor": "standard-version --release-as minor && git push --follow-tags origin master && npm run dist", "release:major": "standard-version --release-as major && git push --follow-tags origin master && npm run dist", "repush": "git push --follow-tags origin master && npm run dist" }, }
三、目錄結構
1. 項目目錄結構
src ├── lib │ ├── cube │ ├── databases │ ├── enviroment │ ├── files │ ├── local-storage │ ├── log │ ├── shell │ ├── stores │ ├── update │ ├── validator │ └── watcher ├── main │ ├── app-window.ts │ ├── event-bus.ts │ ├── index.ts │ ├── keyboard │ └── menu ├── models │ ├── popup.ts │ └── project.ts └── renderer ├── App.tsx ├── assets ├── components ├── index.html ├── index.tsx ├── pages └── types
在目錄結構上模仿了 Desktop。main 目錄存放 main 進程相關程式碼,包括應用入口,窗口創建,菜單,快捷鍵等等;而 renderer 目錄則是整個 UI 渲染層的程式碼。lib 目錄則是一些和 UI 無關也和 main 無強相關的業務邏輯程式碼。models 則存放一些領域模型。
2. CSS 規範
在這個 React 中項目中沒有使用 css-modules 這類方案。而是使用 BEM 這類能形成命名空間的規範來實現模組化,這樣做的好處是能夠比較好的對樣式進行覆蓋。
在文件的組織方式上採用一個獨立的 React 組件搭配一個獨立的樣式文件,這樣在重構的時候,我們想要修改一個組件的樣式只需要找到對應的樣式文件進行修改即可,提高重構的效率。
stylesheets ├── common.styl ├── components │ ├── editor.styl │ ├── empty-guide.styl │ ├── find-in-page.styl │ ├── reindex.styl │ ├── sidebar.styl │ ├── source-viewer.styl │ └── upload.styl ├── index.styl └── reset.styl
三、IPC 通訊
進程間通訊(IPC,InterProcess Communication)是指在不同進程之間傳播或交換資訊。
Electron 的 main 進程和 renderer 進程的通訊是通過 Electron 提供的 ipcMain
和 ipcRenderer
來實現的。
1. main 端
在 main 中向某一個窗口 renderer 發送消息可以使用 window.webContents.send
。在 main 端監聽 renderer 消息可以使用 ipcMain.on
。
// 在主進程中. const { ipcMain } = require('electron') ipcMain.on('asynchronous-message', (event, arg) => { console.log(arg) // prints "ping" event.reply('asynchronous-reply', 'pong') }) ipcMain.on('synchronous-message', (event, arg) => { console.log(arg) // prints "ping" event.returnValue = 'pong' })
2. renderer 端
回復同步消息可以使用 event.returnValue
。同步消息的返回值可以直接讀取。回復非同步消息可以使用 event.reply
。那麼在 renderer 就要監聽回復的 channel 得到返回值。
//在渲染器進程 (網頁) 中。 const { ipcRenderer } = require('electron') console.log(ipcRenderer.sendSync('synchronous-message', 'ping')) // prints "pong" ipcRenderer.on('asynchronous-reply', (event, arg) => { console.log(arg) // prints "pong" }) ipcRenderer.send('asynchronous-message', 'ping')
可以看到 renderer 可以使用 ipcRenderer.send
向主進程發送非同步消息。用 ipcRenderer.sendSync
發送同步消息。
四、數據持久化及狀態管理
1. 複雜數據持久化
數據持久化可選的方案有很多,比如 electron-store[6]等基於 JSON 文件實現的存儲方案。對於更複雜的應用場景還可以使用 lowdb[7],nedb[8] ,sqlite
等。
最初我使用的是 electron-store
, 並且一直有一個執念是對磁碟的讀寫只能在 main 進程進行,renderer 進程只負責渲染介面。所以在最初設計的是在 renderer 進程渲染數據或者更新數據的時候都需要通過 IPC 到 main 進程來完成最終的磁碟讀寫。除去讀寫正常的情況,還要考慮讀寫磁碟的異常,這樣導致數據流異常的繞。而且還需要自己維護 ID 的生成。借鑒了 Desktop 的程式碼後,重構了數據持久化部分,也採用了 Dexie[9],它是對瀏覽器標準資料庫 indexedDB 的一個封裝。從它的 Readme 可以看到它主要解決了 indexedDB 的三個問題:
- 不明確的異常處理
- 查詢很爛
- 程式碼複雜
import Dexie from 'dexie'; export interface IDatabaseProject { id?: number; name: string; filePath: string; } export class ProjectsDatabase extends Dexie { public projects: Dexie.Table<IDatabaseProject, number>; constructor() { super('ProjectsDatabase'); this.version(1).stores({ projects: '++id,&name,&filePath', }); this.projects = this.table('projects'); } }
繼承 Dexie 來實現我們自己的資料庫類,在構造函數中聲明資料庫的版本,表的 schema 等等。具體可以參考 Dexie 官方文檔[10]。
2. 簡單數據持久化
一些 UI 狀態的標誌位存儲(比如某個彈窗是否顯示過),我們一般會把這種標誌位存儲到 localStorage
中。在查看 Desktop 的源碼過程中,發現他們對 number
, boolean
類型的數據的 get, set 進行了簡單的封裝。使用起來非常方便,這裡貼一下對於 boolean
型數據的處理。
export function getBoolean(key: string): boolean | undefined export function getBoolean(key: string, defaultValue: boolean): boolean export function getBoolean( key: string, defaultValue?: boolean ): boolean | undefined { const value = localStorage.getItem(key) if (value === null) { return defaultValue } if (value === '1' || value === 'true') { return true } if (value === '0' || value === 'false') { return false } return defaultValue } export function setBoolean(key: string, value: boolean) { localStorage.setItem(key, value ? '1' : '0') }
源碼詳見[11]
五、功能實現
1. 磁碟/編輯器版本實時同步
一般情況下,在編輯器中我們編輯的內容其實是編輯器讀取磁碟文件到記憶體中的副本。所以說如果磁碟的文件發生了改動,比如 Git 切換分支造成文件變動,抑或是刪除了磁碟文件,重命名等等都會造成記憶體版本和磁碟版本的不一致,即磁碟版本領先於記憶體版本,這個時候就可能產生衝突。解決這個問題很簡單,可以使用 fs.watch/watchFile 監聽當前編輯的文件,一旦發生變化,就重新讀取磁碟版本,更新記憶體版本來實現同步。但是 fs.watch 這個 API 在工程上不是可以開箱即用的,有許多兼容問題和一些 bug。比如說
Node.js fs.watch:
- Doesn't report filenames on MacOS.
- Doesn't report events at all when using editors like Sublime on MacOS.
- Often reports events twice.
- Emits most changes as rename.
- Does not provide an easy way to recursively watch file trees.
Node.js fs.watchFile:
- Almost as bad at event handling.
- Also does not provide any recursive watching.
- Results in high CPU utilization.
上面列舉的點來自 chokidar[12],它是一個 Node 模組,提供了開箱可用的監聽文件變化的能力。只需要監聽 add
, unlink
, change
等事件讀取最新版本的文本到編輯器就可以實現磁碟/編輯器版本的同步了。
2. Context-Menu
Desktop 的 contextmenu
(右鍵菜單)的實現基於原生 IPC 的,比較繞。
首先我們需要知道的是 Menu
類是 main process only
的。
在需要 contextmenu
的 JSX.Element
上綁定 onContextMenu
事件。構造對象數組 Array<MenuItem>
, 並且為每個 MenuItem 對象綁定觸發事件,再通過 IPC 將對象傳遞至 main 進程,值得一提的是這個時候將 MenuItem 數組賦值給了一個全局對象,暫存起來。在 main 進程構造出真正的 MenuItem 實例,綁定 MenuItem 的點擊事件,觸發 MenuItem 點擊事件的時候記錄 MenuItem 的 序列號 index,再將 index 通過 event.sender.send 將 index 傳遞到 renderer 進程。renderer 進程拿到 index 之後根據之前保存的全局對象取出單個 MenuItem, 執行綁定的事件。
onContextMenu => showContextualMenu (暫存MenuItems,ipcRenderer.send) => icpMain => menu.popup() => MenuItem.onClick(index) => event.sernder.send(index) => MenuItem.action()
所以在我的應用中使用了 remote 對象屏蔽上述複雜的 IPC 通訊。在 renderer 進程完成 Menu 的構造展示和事件的綁定觸發。
import { remote } from 'electron'; const { MenuItem, dialog, getCurrentWindow, Menu } = remote; const onContextMenu = (project: Project) => { const menu = new Menu(); const menus = [ new MenuItem({ label: '在終端中打開', visible: __DARWIN__, click() { const accessor = new FileAccessor(project.filePath); accessor.openInTerminal(); }, }), new MenuItem({ label: '在 vscode 中打開', click() { const accessor = new FileAccessor(project.filePath); accessor.openInVscode(); }, }), ]; menus.forEach(menu.append); menu.popup({ window: getCurrentWindow() }); };
六、日誌
完善的日誌不論是開發環境還是生產環境都是非常重要的,大致記錄 UI 狀態遷移背後的數據變動,流程的分支走向,能很好的輔助開發。
參考 Desktop,他們的日誌基於日誌庫:winston[13]。
在 main 進程和 renderer 進程都提供了全局 log 對象,介面都是一致的。分別是 debug
, info
, warn
, error
。在 renderer 進程,簡單的封裝了 window.console
對象上的 debug
, info
, warn
, error
方法,日誌列印到瀏覽器控制台的時候也通過 IPC 傳遞到 main 進程,由 main 進程統一管理。
main 進程接收了來自 renderer 進程的日誌資訊和 main 進程自身的日誌資訊。設置了兩個 transports
。winston.transports.Console
和 winston.transports.DailyRotateFile
分別用於將日誌資訊列印在終端控制台和存儲在磁碟文件。DailyRotateFile 以天為單位,設置了最多存儲 14 天的上限。
在 main 進程和 renderer 進程啟動時分別引入日誌安裝模組。因為 log 方法都是暴露在全局,因此只需要在進程啟動時引入一次即可。同時在 TS 環境中還需要添加 log 方法的類型聲明。
七、打包,發布及更新
開源世界已經有非常完善的打包和發布的工具 — electron-builder[14]。它集多平台打包,簽名,自動更新,發布到 Github 等平台等等功能於一身。
鑒於這個工具只能在內網使用,不能發布到 Github 而且也沒有沒有蘋果開發者工具無法進行簽名,只能利用 electron-builder
在本機打包,發布的話只能使用手動打包上傳了,用戶也只能手動下載安裝包覆蓋安裝,不能像 VSCODE 這樣實現自動更新。
既然不能自動更新,那麼新版本下發後,如何通知到用戶去下載新版本安裝包更新呢?從用戶這一端來看,在應用每次啟動的時候可以做一次請求,查詢是否有版本更新,或者是在應用菜單欄提供入口,讓用戶手動觸發更新查詢。查詢到服務端的最新版本後,使用 sermver[15] 比較本機版本是否低於伺服器版本,如果是就下發通知給用戶,提示用戶去下載更新。
在有限的條件下怎麼實現這個功能呢?
實現這個功能必需的三個元素:服務端標識著最新版本的可讀文件;託管各個版本安裝包的雲空間;應用程式碼中的更新邏輯。
服務端標識著最新版本的可讀文件:每次打包時都會更新 package.json
,所以我們直接把 package.json
上傳到某個不帶鑒權的 CDN 就可以,更新的時候就請求這個文件。
託管各個版本安裝包的雲空間:這個可以使用雲盤,雲盤可以生成分享鏈接,把這個鏈接手動拷貝到 Gitlab 該版本的 tag 的 Notes 中。
應用程式碼中的更新邏輯:
import got from 'got'; import semver from 'semver'; import { app, remote, BrowserWindow } from 'electron'; const realApp = app || remote.app; const currentVersion = realApp.getVersion(); export async function checkForUpdates(window: BrowserWindow, silent: boolean = false) { const url = `http://yourcdn/package.json?t=${Date.now()}`; try { const response = await got(url); const pkg = JSON.parse(response.body); log.debug('檢查更新,雲端版本:', pkg.version); log.debug('當前版本', currentVersion); if (semver.lt(currentVersion, pkg.version)) { window.webContents.send('update-available', pkg.version); } else { window.webContents.send('update-not-available', silent); } } catch (error) { window.webContents.send('update-error', silent); } }
分別在應用主進程啟動、用戶點擊應用菜單檢查更新
時調用這個方法,從而通知 UI 進程下發通知。我們期望應用主進程啟動時的更新是在失敗或者無更新時是靜默的,不用打擾用戶,所以在 IPC 管道可以提供一個 silent
參數。檢測到更新後就可以通知用戶,用戶點擊更新後就可以跳轉到最新版本的 Gitlab tags ,引導用戶下載最新版本進行手動安裝。
八、其他
1. devtools
開發 Electron 應用中 renderer 端也是使用 Chrome devtools 來調試的。對於 React, Mobx 這類框的 devtools 擴展也可以通過 electron-devtools-installer
來安裝。應用窗口創建之後調用electron-devtools-installer
進行 mobx
、react
等擴展的安裝。
const { default: installExtension, MOBX_DEVTOOLS, REACT_DEVELOPER_TOOLS } = require('electron-devtools-installer'); const extensions = [REACT_DEVELOPER_TOOLS, MOBX_DEVTOOLS]; for (const extension of extensions) { try { installExtension(extension); } catch (e) { // log.error(e); } }
2. 保持窗口大小
對於桌面應用,一個常見的需求就是關閉後重新打開,需要恢復到上次打開時的窗口大小,位置。實現這個比較簡單,監聽窗口的 resize 事件,把窗口資訊記錄到當前用戶的應用數據文件夾, 即 app.getPath(appData)
。下次啟動應用創建窗口時讀取這個文件設置窗口資訊即可。開源社區已經有對這個功能封裝好的庫:electron-window-state[16]
const windowStateKeeper = require('electron-window-state'); let win; app.on('ready', function () { let mainWindowState = windowStateKeeper({ defaultWidth: 1000, defaultHeight: 800 }); win = new BrowserWindow({ 'x': mainWindowState.x, 'y': mainWindowState.y, 'width': mainWindowState.width, 'height': mainWindowState.height }); mainWindowState.manage(win); });
只需要提供預設窗口大小,剩餘的事情 electron-window-state
都幫我們搞定了。
參考資料
[1]
Electron: http://electronjs.org
[2]
Github Desktop 客戶端: https://github.com/desktop/desktop
[3]
nodemon: https://nodemon.io
[4]
electron-webpack: https://github.com/electron-userland/electron-webpack
[5]
electron-builder: https://www.electron.build
[6]
electron-store: https://github.com/sindresorhus/electron-store#readme
[7]
lowdb: https://github.com/typicode/lowdb
[8]
nedb: https://github.com/louischatriot/nedb
[9]
Dexie: https://github.com/dfahlander/Dexie.js
[10]
Dexie 官方文檔: https://dexie.org/docs/
[11]
源碼詳見: https://github.com/desktop/desktop/blob/development/app/src/lib/local-storage.ts
[12]
chokidar: https://github.com/paulmillr/chokidar
[13]
winston: https://github.com/winstonjs/winston#readme
[14]
electron-builder: https://www.electron.build
[15]
sermver: https://www.npmjs.com/package/semver
[16]
electron-window-state: https://github.com/mawie81/electron-window-state#readme