2019 年最後一發,談談這半年 Electron 應用開發和優化心得。乾貨也挺多,但願能給你帶來一點啓發。javascript
下半年能夠拿出來講一說的項目,估計就是咱們用 Electron 重構了一個桌面端應用。這個應用相似於釘釘
或者企業微信
,主要功能有即時通訊、語音/視頻、會議,基本功能和交互體驗和 PC 端微信差很少(其實就是模仿),具體細節就不展開了, 這些對本文不重要。以下圖html
文章大綱前端
緣由也很簡單: 咱們的應用要兼容多個平臺,原生開發效率低,咱們沒有資源。java
說了跟白說同樣,大部分選擇 Electron 框架的動機都是差很少的,無非就是窮,尤爲是在夾縫中生存的企業。node
爲了優化客戶端開發資源,'混合化'成爲了咱們今年客戶端重構的主題。git
先來看一下咱們如今的客戶端基本架構:github
混合化對咱們來講有兩層意思:web
基於咱們原有的客戶端基礎和狀況,混合化重構天然而然分化爲了兩個方向:chrome
理解了咱們的動機,如今再看上面的圖, 應該就好理解多了, 這是典型的三層結構, 和 MVC 很是類似:shell
通用混合層
接口,同時也爲 UI 層暴露一些平臺相關的特性。好比在桌面端,這裏會經過 Node 原生模塊橋接通用混合層, 同時也補充一些 Electron 缺失或不完美的功能。Electron 的主從進程模型是基本的常識。每一個 Electron 應用有且只要一個主進程(Main Process)、以及一個或多個渲染進程(Renderer Process), 對應多個 Web 頁面。除此以外還有 GPU 進程、擴展進程等等。能夠經過 Electron Application Architecture 瞭解 Electron 的基本架構。
主進程負責建立頁面窗口、協調進程間通訊、事件分發。爲了安全考慮,原生 GUI 相關的 API 是沒法在渲染進程直接訪問的,它們必須經過 IPC 調用主進程。這種主從進程模型缺點也很是明顯,即主進程單點故障。主進程崩潰或者阻塞,會影響整個應用的響應。好比主進程跑長時間的 CPU 任務,將阻塞渲染進程的用戶交互事件。
對咱們的應用來講,目前有如下進程, 以及它們的職責:
① 主進程
通用混合層
也跑在這個進程。經過 Node C++ 插件暴露接口。② 渲染進程
負責 Web 頁面的渲染, 具體頁面的業務處理。
③ Service Worker
負責靜態資源緩存。緩存一些網絡圖片、音頻。保證靜態資源的穩定加載。
說說咱們的技術選型。
React
Mobx
i18next
自研 CLI
源碼組織
bridge/ # 橋接層代碼
resources/ # 構建資源,以及第三方DLL
src/
main/ # 🔴主進程代碼
services/ # 📡**經過 RPC 暴露給渲染進程的全局服務**
tray.ts # 托盤狀態管理
shortcut.ts # 全局快捷鍵分發
preferences.ts # 用戶配置管理
windows.ts # 窗口管理
screen-capture.ts # 截屏
bridge.ts # 橋接層接口封裝
context-menu.ts # 右鍵菜單
state.ts # 全局狀態管理, 保存一些必要的全局狀態,例如主題、當前語言、當前用戶
...
lib/ # 封裝庫
bridge.ts # 橋接層API 分裝
logger.ts # 日誌
...
bootstrap.ts # 啓動程序
index.ts # 🔴入口文件
renderer/ # 🔴渲染進程
services/ # 📡主進程的全局服務的客戶端
windows.ts # 窗口管理客戶端
tray.ts
...
assets/ # 靜態資源
hooks/ # React Hooks
components/ # 通用組件
Webview
Editor
toast
...
pages/ # 🔴頁面
Home
ui/ # 🔴視圖代碼,由前端團隊維護
store/ # 🔴狀態代碼,由客戶端團隊維護,前端Store的公開狀態
translation/ # 國際化翻譯文件
index.tsx # 頁面入口
Settings
Login
page.json # 🔴聲明全部頁面及頁面配置。相似小程序
複製代碼
眼尖的讀者會發現每一個頁面下有 ui
和 store
目錄,分別對應視圖和狀態。爲何這麼劃分?
首先這是由於這個項目由兩個團隊共同來開發的,即原有的原生客戶端團隊和咱們的前端團隊。分離視圖和狀態有兩個好處:
class CounterStore extends MobxStore {
@observable
public count: number = 0
@action
public incr = () => {
this.count++
}
private pageReady() {
// 頁面就緒,能夠在這裏作一些準備工做
// 事件監聽
// addDisposer 將釋放函數添加到隊列中,在頁面退出時釋放
this.addDisposer(
addListener('someevent', evt => {
this.dosomething(evt)
})
)
// ...
this.initial()
}
private pageWillClose() {
// 頁面釋放,能夠在這裏作一些資源釋放
releaseSomeResource()
}
// ....
}
複製代碼
使用 Mobx 做爲狀態管理,相比 Redux,面向對象思想對他們更好理解。在這種場景,簡單纔是真理;
分離了狀態和業務邏輯,前端頁面實現也簡化了,視圖只是狀態的映射,這讓咱們的頁面和組件更好被維護和複用。
前戲完了,關於 Electron 的一些性能優化纔是本篇文章的重頭戲。
Electron 不是銀彈,魚和熊掌不可兼得。Electron 帶來開發效率的提高,其自己也有不少硬傷,譬如常被人吐槽的內存佔用高,和原生客戶端性能差別等等。爲了優化 Electron 應用,咱們也作了不少工做。
性能優化通常都分兩步走:
最好的分析工具是 Chrome 開發者工具的 Performance
。經過火焰圖, JavaScript 執行過程的任何蛛絲馬跡均可以直觀的看到。
對於主進程,開啓調試後也能夠經過 Profile
工具收集 JavaScript 執行信息。
若是你要分析某段代碼的執行過程,也能夠經過下面命令生成分析文件,而後導入到 Chrome Performance 中分析:
# 輸出 cpu 和 堆分析文件
node --cpu-prof --heap-prof -e "require('request’)」「
複製代碼
即便 Electron 一般從本地文件系統加載 JavaScript 代碼,沒有網絡加載延遲,咱們仍是須要繼續和頁面白屏作鬥爭,由於 JavaScript 等資源的加載、解析和執行仍是有至關大的代價(參考The cost of JavaScript in 2019)。做爲一個桌面端應用,細微的白屏延遲用戶均可以感受的到。咱們要儘可能讓用戶感受不到這是一個 Web 頁面。
影響 Electron 白屏的主要因素有:頁面窗口的建立、靜態資源的加載、JavaScript 解析和執行。
見招拆招,針對頁面白屏咱們作了這些優化:
① 骨架屏
最簡單的方式。在資源未加載完畢以前,先展現頁面的骨架。避免用戶看到白茫茫的屏幕。
另外須要設置背景色或者延遲顯示窗口,來避免閃爍。
② 惰性加載
優先加載核心的功能,保證初次加載效率,讓用戶能夠儘快進行交互。
代碼分割 + 預加載: 代碼分割是最多見優化方式。咱們把隱藏的內容、或者次優先級的模塊拆分出去,啓動模塊中只保留關鍵路徑。咱們也能夠在瀏覽器空閒時預加載這些模塊。
延後加載 Node 模塊: Nodejs 模塊的加載和執行須要花費較大的代價, 例如模塊查找、模塊文件讀取、接着纔是模塊解析和執行。這些操做都是同步了,別忘了,node_modules 黑洞,某塊模塊可能會引用大量的依賴....
Node 應用和 Electron 應用不太同樣,一般 Node 服務器應用都會將模塊放置在文件頂部, 而後同步加載進來。這個放到 Electron 用戶界面上就沒法忍受了。 用戶界面的啓動速度和交互阻塞, 用戶是能夠感知到的,並且忍耐程度會較低。
因此要充分評估模塊的大小和依賴。或者能夠選擇使用打包工具優化和合並 Node 模塊。
劃分加載優先級:既然咱們沒辦法一開始將全部東西都加載出來,那就按照優先級漸進式地將在它們。舉個例子,當咱們使用 VSCode 打開一個文件時,VScode 會先展現代碼面板、接着是目錄樹、側邊欄、代碼高亮、問題面板、初始化各類插件...
③ 使用現代的 JavaScript/CSS 代碼
Electron 每一個版本都會預裝當時最新的 Chrome,對於前端來講,這是最爽的一件事情:
④ 打包優化
即便使用最新最牛逼的瀏覽器,打包工具仍是頗有用。
⑤ v8 Snapshot or v8 Code Cache
Atom 有不少優質的文章,分享了他們優化Atom的經歷。例如它們使用了 V8 的snapshot 來優化啓動時間。
這是一種 AOT
優化策略,簡單說 Snapshot 是堆快照,你能夠認爲它是 JavaScript 代碼在V8中的內存表示形態。
它有兩個好處: 一是相比普通 JavaScript 加載更快,二是它是二進制的,若是你爲了‘安全’考慮,能夠將模塊轉換成snapshot,這樣更難被‘破解’。
不過它也有較多限制。對架構的影響比較大。好比要求在初始化的過程當中不要有‘反作用’,例如DOM訪問。由於在‘編譯時‘這些東西不存在。
這篇文章詳細介紹瞭如何在 Electron 中應用 v8 snapshot: How Atom Uses Chromium Snapshots
還有一個更加普遍使用的方案是 v8 Code Cache。NodeJS 12 開始在構建時提早爲內置庫生成代碼緩存,從而提高 30% 的啓動耗時。
經過這些文章,深刻了解 Code Cache 擴展閱讀:
⑥ 窗口預熱 與 窗口池、窗口常駐
爲了追趕原生窗口的打開和展現速度,咱們運用了不少技巧,用空間來換取時間。
例如咱們的應用首頁,用戶在打開登陸頁面時,咱們就會在後臺預熱,將該加載的資源都準備好,在登陸成功後,就能夠當即渲染顯示。窗口打開的延時很短,基本接近原生的窗口體驗。
這裏用到了一些 Hack 手段,咱們將這些窗口放到了屏幕以外,並設置 skipTaskBar
來實現隱藏或者關閉的效果。
對於頻繁開啓/關閉的窗口,也可使用窗口池來優化。好比 Webview 頁面,打開的一個 Webview 頁面時,會優先從窗口池中選取,當窗口池爲空時才建立新的窗口, 後面頁面關閉後會再放回窗口池中,方便後續複用。
另外,對於業務無關的、通用的窗口,也能夠採用常駐模式,例如通知,圖片查看器。這些窗口一旦建立就不會釋放,打開效果會更好。
⑦ 跟進 Electron 最新版本
保持版本的更新。
白屏時間的優化只是一個開始,應用使用過程當中的交互體驗也是一個很是重要的部分。下面講講咱們的一些優化手段:
① 靜態資源緩存
對於一些網絡資源,咱們採起了一些緩存手段,保證它們展現的速度。咱們目前採用的是 Service-Worker + Workbox 的方式,利用 Service-Worker 能夠攔截多個頁面的網絡請求,從而實現跨頁面的靜態資源緩存,這種方式實現比較簡單。
除了 Service Worker,也能夠經過協議攔截方式來實現。詳見: protocol。後面有時間再嘗試一下,看效果怎麼樣。
② 預加載機制
若是你看過個人 《這多是最通俗的 React Fiber(時間分片) 打開方式》, 應該見識到 requestIdleCallback
的強大,React 利用它來調度一些渲染任務,保證瀏覽器響應用戶的交互。
這個 API 對於咱們的應用優化也有重要的意義。經過它咱們能夠知道瀏覽器的資源利用狀況,利用瀏覽器空閒時間來預執行一些低優先級的任務。好比:
例如 React 代碼分割:
export default function lazy(factory, Fallback) {
const Comp = l(factory)
// 預加載調度
scheduleIdle({
name: 'LazyComponent',
size: TaskSize.Heavy,
task: factory,
timeout: 2000,
})
return function LazyComponent(props) {
return (
<Suspense fallback={Fallback ? <Fallback /> : null}> <Comp {...props} /> </Suspense> ) } as typeof Comp } 複製代碼
使用:
const List = lazy(() => import('./List'))
複製代碼
③ 避免同步操做
Electron 能夠經過 NodeJS 進行 I/O 操做,可是咱們必定要儘可能避免同步 I/O。例如同步的文件操做、同步的進程間通訊。它們會阻塞頁面的渲染和事件交互。
④ 減小主進程負荷
Electron 的主進程很是重要。它是全部窗口的父進程,它負責調度各類資源。若是主進程被阻塞,將影響整個應用響應性能。
你能夠作一個簡單的實驗,在主進程上打一個斷點,你會發現全部的頁面窗口都會失去響應,儘管它們在各自不一樣的進程。這是由於全部用戶交互都是由主進程分發給渲染進程的,主進程阻塞了,渲染進程固然沒法接收用戶事件啦。
因此不要讓主進程幹髒活累活,能在渲染進程作的,就在渲染進程作。千萬避免在主進程中跑計算密集任務和同步I/O。
⑤ 分離CPU密集型操做到單獨進程或Worker, 避免阻塞UI
⑥ React 優化
⑦ 放棄CSS-in-js
咱們爲了壓縮運行時性能,能在編譯時作的就在編譯時作,放棄了 CSS-in-js 方案,使用純 CSS + BEM 來編寫樣式。主要有兩個緣由:
⑧ 沒有退路了,那就只能上 Node 原生模塊了
真好,還有退路
涉及到多頁面/窗口的 Electron 應用,IPC 會很是頻繁,搞很差會成爲性能瓶頸。
① 不要濫用 remote
remote 提供了一種簡便的、無侵入的形式來訪問主進程的API和數據。其底層基於同步的 IPC。你能夠經過我這篇文章來了解它的原理。
坑在哪裏呢?
① 它是同步的 ② 屬性動態獲取。爲了確保你可以獲取到最新的值,remote底層並不會進行緩存,而是每次獲取一個屬性就動態到主進程中取。
好比獲取一個主進程中的對象:
// 主進程
global.foo = {
foo: 1,
bar: {
baz: 2
}
}
複製代碼
渲染進程訪問:
import {remote} from 'electron'
JSON.stringify(remote.getGlobal('foo'))
複製代碼
這裏會觸發 4 次 同步 IPC: getGlobal、foo、bar、bar.baz。對於複雜的數據,這個消耗就很難忍受了。
避免使用 remote,除非你知道你本身在幹什麼。
② 封裝IPC 庫
爲了優化 IPC 通訊,咱們本身基於Electron 的IPC接口, 封裝了本身的一套 RPC 庫。主要特徵有:
舉個例子:
import rpc from 'myrpc'
// 註冊方法
rpc.registerHandler('echo', async data => {
return data
})
// 事件監聽
rpc.on('some-event', (data, source) => {
// dosomething
})
複製代碼
客戶端:
import rpc from 'myrpc'
rpc.emit(target, 'some-event') // target 爲接收的窗口或者主進程。
// 方法調用
const res = await rpc.callHandler(target, 'echo', 'hello-world')
複製代碼
還不夠,咱們還在優化,後續再分享給你們。
一路走來也遇到不少坑。痛並快樂着。
ivan
進羣