半小時上手 Electron

社交魔方平臺是京東的 SNS 活動搭建平臺,其內置了不少模板,每個模板都有一個模板 JSON 用於生成表單,運營同窗、商家配置了這個表單後就能夠生成活動頁面了。模板 JSON 是標準的結構化數據,包含名稱、類型、控件類型、校驗器、默認值等等字段。以往都是採用手寫 JSON 的方式,這是很是低效的,並且容易出錯。針對其結構化數據的特色能夠用 GUI 的方式去編輯,咱們基於 Electron[1] 參考 Github Desktop 客戶端[2] 的架構編寫了一個 JSON 編輯器(參見下圖),經過填寫表單的方式生成 JSON。因此在這裏記錄下這個 Electron 編輯器開發過程當中能夠記錄的點和從 Github Desktop 客戶端代碼中值得學習的點。css

APP

1、關於 Electron

Electron 是由 Github 開發,用 HTML,CSS 和 JavaScript 來構建跨平臺桌面應用程序的一個開源庫。Electron 經過將 Chromium 和 Node.js 合併到同一個運行時環境中,並將其打包爲 Mac,Windows 和 Linux 系統下的應用來實現這一目的。html

上面是來自 Electron 官方的介紹。基於 Electron 平臺,咱們可使用熟悉的前端技術棧來開發桌面應用。Electron 運行 package.json 的 main 腳本的進程被稱爲主進程(如下簡稱 main)。在主進程中運行的腳本經過建立 web 頁面來展現用戶界面(如下簡稱 renderer)。一個 Electron 應用老是有且只有一個主進程。main 用於建立應用,建立瀏覽器窗口,它就是一個完全的 Node 進程,獲取不到 DOM, BOM 這些接口。在 main 建立的瀏覽器窗口中運行的就是 renderer 進程,它既能夠獲取 DOM, BOM 這些接口,也可使用 Node 的 API。兩類進程之間能夠經過 Electron 提供的 IPC 接口通訊。前端

2、開發環境搭建

咱們瞭解到 Electron 分爲兩類進程,main 和 renderer。因此搭建開發環境時不能像普通的前端應用同樣一個 webpack 配置搞定。而且咱們想要實現node

  1. 一鍵啓動開發環境react

  2. 一鍵打包webpack

  3. 一鍵發佈git

那麼就須要兩個 webpack 配置文件。github

一個用於開發環境 -- webpack.dev.tsweb

// webpack.dev.tsconst 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.tssql

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" },}

3、目錄結構

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

3、IPC 通訊

進程間通訊(IPC,InterProcess Communication)是指在不一樣進程之間傳播或交換信息。

Electron 的 main 進程和 renderer 進程的通訊是經過 Electron 提供的 ipcMainipcRenderer 來實現的。

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 發送同步消息。

4、數據持久化及狀態管理

1. 複雜數據持久化

數據持久化可選的方案有不少,好比 electron-store[6]等基於 JSON 文件實現的存儲方案。對於更復雜的應用場景還可使用 lowdb[7]nedb[8]sqlite等。

最初我使用的是 electron-store, 而且一直有一個執念是對磁盤的讀寫只能在 main 進程進行,renderer 進程只負責渲染界面。因此在最初設計的是在 renderer 進程渲染數據或者更新數據的時候都須要經過 IPC 到 main 進程來完成最終的磁盤讀寫。除去讀寫正常的狀況,還要考慮讀寫磁盤的異常,這樣致使數據流異常的繞。並且還須要本身維護 ID 的生成。借鑑了 Desktop 的代碼後,重構了數據持久化部分,也採用了 Dexie[9],它是對瀏覽器標準數據庫 indexedDB 的一個封裝。從它的 Readme 能夠看到它主要解決了 indexedDB 的三個問題:

  1. 不明確的異常處理

  2. 查詢很爛

  3. 代碼複雜

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 | undefinedexport function getBoolean(key: string, defaultValue: boolean): booleanexport 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]

5、功能實現

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 的。

在須要 contextmenuJSX.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() });};

6、日誌

完善的日誌不管是開發環境仍是生產環境都是很是重要的,大體記錄 UI 狀態遷移背後的數據變更,流程的分支走向,能很好的輔助開發。

參考 Desktop,他們的日誌基於日誌庫:winston[13]

在 main 進程和 renderer 進程都提供了全局 log 對象,接口都是一致的。分別是 debug, info, warn, error。在 renderer 進程,簡單的封裝了 window.console 對象上的 debug, info, warn, error 方法,日誌打印到瀏覽器控制檯的時候也經過 IPC 傳遞到 main 進程,由 main 進程統一管理。

main 進程接收了來自 renderer 進程的日誌信息和 main 進程自身的日誌信息。設置了兩個 transportswinston.transports.Consolewinston.transports.DailyRotateFile 分別用於將日誌信息打印在終端控制檯和存儲在磁盤文件。DailyRotateFile 以天爲單位,設置了最多存儲 14 天的上限。

在 main 進程和 renderer 進程啓動時分別引入日誌安裝模塊。由於 log 方法都是暴露在全局,所以只須要在進程啓動時引入一次便可。同時在 TS 環境中還須要添加 log 方法的類型聲明。

7、打包,發佈及更新

開源世界已經有很是完善的打包和發佈的工具 -- 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 ,引導用戶下載最新版本進行手動安裝。

8、其餘

1. devtools

開發 Electron 應用中 renderer 端也是使用 Chrome devtools 來調試的。對於 React, Mobx 這類框的 devtools 擴展也能夠經過 electron-devtools-installer 來安裝。應用窗口建立以後調用electron-devtools-installer 進行 mobxreact 等擴展的安裝。

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

❤️ 看完三件事

若是你以爲這篇內容對你挺有啓發,我想邀請你幫我三個小忙:

  1. 點個「在看」,讓更多的人也能看到這篇內容(喜歡不點在看,都是耍流氓 -_-)

  2. 關注個人博客 https://github.com/SHERlocked93/blog,讓咱們成爲長期關係

  3. 關注公衆號「前端下午茶」,持續爲你推送精選好文,也能夠加我爲好友,隨時聊騷。


本文分享自微信公衆號 - 前端下午茶(qianduanxiawucha)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索