老樹發新芽—使用 mobx 加速你的 AngularJS 應用

發表於2018-05-23

1月底的時候,Angular 官方部落格釋出了一則訊息:

AngularJS is planning one more significant release, version 1.7, and on July 1, 2018 it will enter a 3 year Long Term Support period.

即在 7月1日 AngularJS 釋出 1.7.0 版本之後,AngularJS 將進入一個為期 3 年的 LTS 時期。也就是說 2018年7月1日 起至 2021年6月30日,AngularJS 不再合併任何會導致 breaking changes 的 features 或 bugfix,只做必要的問題修復。詳細資訊見這裡:Stable AngularJS and Long Term Support

看到這則訊息時我還是感觸頗多的,作為我的前端啟蒙框架,我從 AngularJS 上汲取到了非常多的養分。雖然 AngularJS 作為一款優秀的前端 MVW 框架已經出色的完成了自己的歷史使命,但考慮到即便到了 2018 年,許多公司基於 AngularJS 的專案依然處於服役階段,結合我過去一年多在 mobx 上的探索和實踐,我決定給 AngularJS 強行再續一波命?。(搭車求治拖延症良方,二月初起草的文章五月份才寫完,新聞都要過期了?)

準備工作

在開始之前,我們需要給 AngularJS 搭配上一些現代化 webapp 開發套件,以便後面能更方便地裝載上 mobx 引擎。

AngularJS 配合 ES6/next

現在是2018年,使用 ES6 開發應用已經成為事實標準(有可能的推薦直接上 TS )。如何將 AngularJS 搭載上 ES6 這裡不再贅述,可以看我之前的這篇文章:Angular1.x + ES6 開發風格指南

基於元件的應用架構

AngularJS 在 1.5.0 版本後新增了一系列激動人心的特性,如 onw-way bindings、component lifecycle hooks、component definition 等,基於這些特性,我們可以方便的將 AngularJS 系統打造成一個純元件化的應用(如果你對這些特性很熟悉可直接跳過至 AngularJS 搭配 mobx)。我們一個個來看:

  • onw-way bindings 單向繫結
    AngularJS 中使用 來定義元件的單向資料繫結,例如我們這樣定義一個元件:

    使用時:

    當我們點選元件的 increase 按鈕時,可以看到元件內的 count 加 1 了,但是 app.count並不受影響。

    區別於 AngularJS 賴以成名的雙向繫結特性 scope: { count: '='},單向資料繫結能更有效的隔離操作影響域,從而更方便的對資料變化溯源,降低 debug 難度。
    雙向繫結與單向繫結有各自的優勢與劣勢,這裡不再討論,有興趣的可以看我這篇回答:單向資料繫結和雙向資料繫結的優缺點,適合什麼場景?

  • component lifecycle hooks 元件生命週期鉤子1.5.3 開始新增了幾個元件的生命週期鉤子(目的是為更方便的向 Angular2+ 遷移),分別是 $onInit $onChanges $onDestroy $postLink $doCheck(1.5.8增加),寫起來大概長這樣:

    事實上在 1.5.3 之前,我們也能借助一些機制來模擬元件的生命週期(如 $scope.$watch$scope.$on('$destroy')等),但基本上都需要藉助$scope這座‘‘橋樑’’。但現在我們有了框架原生 lifecycle 的加持,這對於我們構建更純粹的、框架無關的 ViewModel 來講有很大幫助。更多關於 lifecycle 的資訊可以看官方文件:AngularJS lifecycle hooks

  • component definitionAngularJS 1.5.0 後增加了 component 語法用於更方便清晰的定義一個元件,如上述例子中的元件我們可以用component語法改寫成:

    本質上component就是directive的語法糖,bindings 是 bindToController + controllerAs + scope 的語法糖,只不過component語法更簡單語義更明瞭,定義元件變得更方便,與社群流行的風格也更一致(熟悉 vue 的同學應該已經發現了?)。更多關於 AngularJS 元件化開發的 best practice,可以看官方的開發者文件:Understanding Components

AngularJS 搭配 mobx

準備工作做了一堆,我們也該開始進入本文的正題,即如何給 AngularJS 搭載上 mobx 引擎(本文假設你對 mobx 中的基礎概念已經有一定程度的瞭解,如果不瞭解可以先移步 mobx repo mobx official doc):

1. mobx-angularjs

引入 mobx-angularjs 庫連線 mobx 和 angularjs 。

2. 定義 ViewModel

在標準的 MVVM 架構裡,ViewModel/Controller 除了構建檢視本身的狀態資料(即區域性狀態)外,作為檢視跟業務模型之間溝通的橋樑,其主要職責是將業務模型適配(轉換/組裝)成對檢視更友好的資料模型。因此,在 mobx 視角下,ViewModel 主要由以下幾部分組成:

  • 檢視(區域性)狀態對應的 observable data

    可觀察資料(對應的 observer 為 view),即檢視需要對其變化自動做出響應的資料。在 mobx-angularjs 庫的協助下,通常 observable data 的變化會使關聯的檢視自動觸發 rerender(或觸發網路請求之類的副作用)。ViewModel 中的 observable data 通常是檢視狀態(UI-State),如 isLoading、isOpened 等。

  • 由 應用/檢視 狀態衍生的 computed data

    Computed values are values that can be derived from the existing state or other computed values.

    計算資料指的是由其他 observable/computed data 轉換而來,更方便檢視直接使用的衍生資料(derived data)。 在重業務輕互動的 web 類應用中(通常是各種企業服務軟體), computed data 在 ViewModel 中應該佔主要部分,且基本是由業務 store 中的資料(即應用狀態)轉換而來。 computed 這種資料推導關係描述能確保我們的應用遵循 single source of truth 原則,不會出現資料不一致的情況,這也是 RP 程式設計中的基本原則之一。

  • action
    ViewModel 中的 action 除了一小部分改變檢視狀態的行為外,大部分應該是直接呼叫 Model/Store 中的 action 來完成業務狀態的流轉。建議把所有對 observable data 的操作都放到被 aciton 裝飾的方法下進行。

mobx 配合下,一個相對完整的 ViewModel 大概長這樣:

3. 連線 AngularJS 和 mobx

可以看到,除了常規的基於 mobx 的 ViewModel 定義外,我們只需要在模板的根節點加上 mobx-autorun 指令,我們的 angularjs 元件就能很好的運作的 mobx 的響應式引擎下,從而自動的對 observable state 的變化執行 rerender。

mobx-angularjs 加速應用的魔法

從上文的示例程式碼中我們可以看到,將 mobx 跟 angularjs 銜接運轉起來的是 mobx-autorun指令,我們翻下 mobx-angularjs 程式碼:

可以看到 核心程式碼 其實就三行:

思路非常簡單,即在指令 link 之後,遍歷一遍當前 scope 上掛載的 watchers 並取值,由於這個動作是在 mobx reaction 執行上下文中進行的,因此 watcher 裡依賴的所有 observable 都會被收集起來,這樣當下次其中任何一個 observable 發生變更時,都會觸發 reaction 的副作用對 scope 進行 digest,從而達到自動更新檢視的目的。

我們知道,angularjs 的效能被廣為詬病並不是因為 ‘髒檢查’ 本身慢,而是因為 angularjs 在每次非同步事件發生時都是無腦的從根節點開始向下 digest,從而會導致一些不必要的 loop 造成的。而當我們在搭載上 mobx 的 push-based 的 change propagation 機制時,只有當被檢視真正使用的資料發生變化時,相關聯的檢視才會觸發區域性 digest (可以理解為只有 observable data 存在 subscriber/observer 時,狀態變化才會觸發關聯依賴的重算,從而避免不必要資源消耗,即所謂的 lazy),區別於非同步事件觸發即無腦地 $rootScope.$apply, 這種方式顯然更高效。

進一步壓榨效能

我們知道 angularjs 是通過劫持各種非同步事件然後從根節點做 apply 的,這就導致只要我們用到了會被 angularjs 劫持的特性就會觸發 apply,其他的諸如 $http $timeout 都好說,我們有很多替代方案,但是 ng-click 這類事件監聽指令我們無法避免,就像上文例子中一樣,假如我們能杜絕潛藏的根節點 apply,想必應用的效能提升能更進一步。

思路很簡單,我們只要把 ng-click 之流替換成不觸發 apply 的版本即可。比如把原來的 ng event 實現這樣改一下:

時間監聽的回撥中只是簡單觸發一下繫結的函式即可,不再 apply,bingo!

注意事項/ best practise

在 mobx 配合 angularjs 開發過程中,有一些點我們可能會 碰到/需要考慮:

  • 避免 TTL
    單向資料流優點很多,大部分場景下我們會優先使用 one-way binding 方式定義元件。通常你會寫出這樣的程式碼:

    todo-panel 元件使用單向資料繫結定義:

    看上去沒有任何問題,但是當你把程式碼扔到瀏覽器裡時就會收穫一段 angularjs 饋贈的 TTL 錯誤:Error: $rootScope:infdigInfinite $digest Loop。實際上這並不是 mobx-angularjs 惹的禍,而是 angularjs 目前未實現 one-way binding 的 deep comparison 導致的,由於每次 get unCompeletedTodos 都會返回一個新的陣列引用,而又是基於引用作對比,從而每次 prev === current 都是 false,最後自然報 TTL 錯誤了(具體可以看這裡 One-way bindings + shallow watching )。

    不過好在 mobx 優化手段中恰好有一個方法能間接的解決這個問題。我們只需要給 computed 加一個表示要做深度值對比的 modifier 即可:

    本質上還是對 unCompeletedTodos 的 memorization,只不過對比基準從預設的值對比(===)變成了結構/深度 對比,因而在第一次 get unCompeletedTodos 之後,只要計算出來的結果跟前次的結構一致(只有當 computed data 依賴的 observable 發生變化的時候才會觸發重算),後續的 getter 都會直接返回前面快取的結果,從而不會觸發額外的 diff,進而避免了 TTL 錯誤的出現。

  • $onInit 和 $onChanges 觸發順序的問題
    通常情況下我們希望在 ViewModel 中藉助元件的 lifecycle 鉤子做一些事情,比如在 $onInit 中觸發副作用(網路請求,事件繫結等),在 $onChanges 裡監聽傳入資料變化做檢視更新。

    可以發現其實我們在 $onInit 和 $onChanges 中做了重複的事情,而且這種寫法也與我們要做檢視框架無關的資料層的初衷不符,藉助 mobx 的 observe 方法,我們可以將上面的程式碼改造成這種:

    熟悉 angularjs 的同學應該能發現,事實上 observe 做的事情跟 $scope.$watch 是一樣的,但是為了保證資料層的 UI 框架無關性,我們這裡用 mobx 自己的觀察機制來替代了 angularjs 的 watch。

  • 忘記你是在寫 AngularJS,把它當成一個簡單的動態模板引擎不論是我們嘗試將 AngularJS 應用 ES6/TS 化還是引入 mobx 狀態管理庫,實際上我們的初衷都是將我們的 Model 甚至 ViewModel 層做成檢視框架無關,在藉助 mobx 管理資料的之間的依賴關係的同時,通過 connector 將 mobx observable data 與檢視連線起來,從而實現檢視依賴的狀態發生變化自動觸發檢視的更新。在這個過程中,angularjs 不再扮演一個框架的角色影響整個系統的架構,而僅僅是作為一個動態模板引擎提供 render 能力而已,後續我們完全可以通過配套的 connector,將 mobx 管理的資料層連線到不同的 view library 上。目前 mobx 官方針對 React/Angular/AngularJS 均有相應的 connector,社群也有針對 vue 的解決方案,並不需要我們從零開始。在藉助 mobx 構建資料層之後,我們就能真正做到標準 MVVM 中描述的那樣,在 Model 甚至 VIewModel 不改一行程式碼的前提下輕鬆適配其他檢視。view library 的語法、機制差異不再成為檢視層 升級/替換 的鴻溝,我們能通過改很少量的程式碼來填平它,畢竟只是替換一個動態模板引擎而已?。

Why MobX

React and MobX together are a powerful combination. React renders the application state by providing mechanisms to translate it into a tree of renderable components. MobX provides the mechanism to store and update the application state that React then uses.

Both React and MobX provide optimal and unique solutions to common problems in application development. React provides mechanisms to optimally render UI by using a virtual DOM that reduces the number of costly DOM mutations. MobX provides mechanisms to optimally synchronize application state with your React components by using a reactive virtual dependency state graph that is only updated when strictly needed and is never stale.

MobX 官方的介紹,把上面一段介紹中的 React 換成任意其他( Vue/Angular/AngularJS ) 檢視框架/庫(VDOM 部分適當調整一下) 也都適用。得益於 MobX 的概念簡單及獨立性,它非常適合作為檢視中立的狀態管理方案。簡言之是檢視層只做拿資料渲染的工作,狀態流轉由 MobX 幫你管理。

Why Not Redux

Redux 很好,而且社群也有很多跟除 React 之外的檢視層整合的實踐。單純的比較 Redux 跟 MobX 大概需要再寫一篇文章來闡述,這裡只簡單說幾點與檢視層整合時的差異:

  1. 雖然 Redux 本質也是一個觀察者模型,但是在 Redux 的實現下,狀態的變化並不是通過資料 diff 得出而是 dispatch(action) 來手動通知的,而真正的 diff 則交給了檢視層,這不僅導致可能的渲染浪費(並不是所有 library 都有 vdom),在處理各種需要在變化時觸發副作用的場景也會顯得過於繁瑣。
  2. 由於第一條 Redux 不做資料 diff,因此我們無法在檢視層接手資料前得知哪個區域性被更新,進而無法更高效的選擇性更新檢視。
  3. Redux 在 store 的設計上是 opinionated 的,它奉行 單一 store 原則。應用可以完全由狀態資料來描述、且狀態可管理可回溯 這一點上我沒有意見,但並不是只有單一 store這一條出路,多 store 依然能達成這一目標。顯然 mobx 在這一點上是 unopinionated 且靈活性更強。
  4. Redux 概念太多而自身做的又太少。可以對比一下 ngRedux 跟 mobx-angularjs 看看實現複雜度上的差異。

最後

除了給 AngularJS 搭載上更高效、精確的高速引擎之外,我們最主要的目的還是為了將 業務模型層甚至 檢視模型層(統稱為應用資料層) 做成 UI 框架無關,這樣在面對不同的檢視層框架的遷移時,才可能做到遊刃有餘。而 mobx 在這個事情上是一個很好的選擇。

最後想說的是,如果條件允許的話,還是建議將 angularjs 系統升級成 React/Vue/Angular 之一,畢竟大部分時候基於新的檢視技術開發應用是能帶來確實的收益的,如 效能提升、開發效率提升 等。即便你短期內無法替換掉 angularjs(多種因素,比如已經基於 angularjs 開發/使用 了一套完整的元件庫,程式碼體量太大改造成本過高),你依然可以在區域性使用 mobx/mobx-angularjs 改造應用或開發新功能,在 mobx-angularjs 幫助你提升應用效能的同時,也給你後續的升級計劃創造了可能性。

PS: mobx-angularjs 目前由我和另一個 US 小哥全力維護,如果有任何使用上的問題,歡迎隨時聯絡?。

相關文章