[譯] $digest 在 Angular 中重生

原文連接:Angular.js’ $digest is reborn in the newer version of Angularhtml

$digest

我使用 Angular.js 框架好些年了,儘管它飽受批評,但我依然以爲它是個難以想象的框架。我是從這本書 Building your own Angular.js 開始學習的,而且讀了框架的大量源碼,因此我以爲本身對 Angular.js 內部機制比較瞭解,而且對建立這個框架的架構思想也比較熟悉。最近我在試圖掌握新版 Angular 框架內部架構思想,並與舊版 Angular.js 內部架構思想進行比較。我發現並非像網上說的那樣,偏偏相反,Angular 大量借鑑了 Angular.js 的設計思想。前端

其中之一就是名聲糟糕的 digest loopnode

這個設計的主要問題就是成本過高。改變程序中的任何事物,須要執行成百上千個函數去查詢哪一個數據發生變化。而這是 Angular 的基礎部分,可是它會把查詢限定在部分 UI 上,從而提升性能。git

若是能更好理解 Angular 是如何實現 digest 的,就可能把你的程序設計的更高效,好比,使用 $scope.$digest() 而不是 $scope.$apply,或者使用不可變對象。但事實是,爲了設計出更高效的程序,從而去理解框架內部實現,這可能對不少人來講不是簡單的事情。angularjs

因此大量有關 Angular 的文章教程裏都宣稱框架裏不會再有 $digest cycle 了。這取決於對 digest 概念如何理解,但我認爲這頗有誤導性,由於它仍然存在。的確,在 Angular 裏沒有 scopes 和 watchers,也再也不須要調用 $scope.$digest(),可是檢測數據變化的機制依然是遍歷整個組件樹,隱式調用 watchers ,而後更新 DOM。因此其實是徹底重寫了,但被優化加強了,關於新的查詢機制能夠查看我寫的 Everything you need to know about change detection in Angulargithub

digest 的必要性

開始前讓咱們先回憶下 Angular.js 中爲什麼存在 digest。全部框架都是在解決數據模型(JavaScript Objects)和 UI(Browser DOM)的同步問題,最大難題是如何知道何時數據模型發生改變,而查詢數據模型什麼時候發生改變的過程就是變動檢測(change detection)。這個問題的不一樣實現方案也是如今衆多前端框架的最大區別點。我計劃寫篇文章,有關不一樣框架變動檢測實現的比較,若是你感興趣並但願收到通知,能夠關注我。api

有兩種方式來檢測變化:須要使用者通知框架;經過比較來自動檢測變化。bash

假設咱們有以下一個對象:前端框架

let person = {name: 'Angular'};
複製代碼

而後咱們去更新 name 屬性值,可是框架是怎麼知道這個值什麼時候被更新呢?一種方式是須要使用者告訴框架(注:如 React 方式):angular2

constructor() {
    let person = {name: 'Angular'};
    this.state = person;
}
...
// explicitly notifying React about the changes
// and specifying what is about to change
this.setState({name: 'Changed'});
複製代碼

或者強迫用戶去封裝該屬性,從而框架能添加 setters(注:如 Vue 方式):

let app = new Vue({
    data: {
        name: 'Hello Vue!'
    }
});
// the setter is triggered so Vue knows what changed
app.name = 'Changed';
複製代碼

另外一種方式是保存 name 屬性的上一個值,並與當前值進行比較:

if (previousValue !== person.name) // change detected, update DOM
複製代碼

可是何時結束比較呢?咱們應該在每一次異步代碼運行時都去檢查,因爲這部分運行的代碼是做爲異步事件去處理,即所謂的 Virtual Machine(VM) turn/tick(注:Virtual Machine 的理解可參考 VM),因此能夠緊接着在 VM turn 的後面,執行數據變化檢查代碼。這也是爲什麼 Angular.js 使用 digest,因此咱們能夠定義 digest 爲(注:爲清晰理解,不翻譯):

a change detection mechanism that walks the tree of components, checks each component for changes and updates DOM when a component property is changed。

若是咱們這麼去定義 digest的話,那我能夠說數據變化檢查機制的主要部分在 Angular 裏沒有變化,變化的是 digest 的實現。

Angular.js

Angular.js 使用 watcherlistener 的概念,watcher 就是一個返回被監測值的函數,大多數時候這個被監測值就是數據模型的屬性。但也不老是數據模型屬性,如咱們能夠在做用域裏追蹤組件狀態,計算屬性值,第三方組件等等。若是當前返回值與先前值不一樣,Angular.js 就會調用 listener,而 listener 一般用來更新 UI。

$watch 函數的參數列表以下:

$watch(watcher, listener);
複製代碼

因此,若是咱們有一個帶有name 屬性的 person 對象,並在模板裏這樣使用 <span>{{name}}</span>,那就能夠像這樣去追蹤這個屬性變化從而更新 DOM:

$watch(() => {
    return person.name
}, (value) => {
    span.textContent = value
});
複製代碼

這與插值和 ng-bind 類的指令本質上作的同樣,Angular.js 使用指令來映射 DOM 的數據模型。可是 Angular 再也不這麼去作,它使用屬性映射來鏈接數據模型和 DOM。上面的示例在 Angular 會這麼實現:

<span [textContent]="person.name"></span>
複製代碼

因爲存在不少組件,並組成了組件樹,每個組件都有着不一樣的數據模型,因此就存在分層的 watchers,與分層的組件樹很類似。儘管使用做用域把 watchers 組合在一塊兒,但它們並不相關。

如今,在 digest 期間,Angular.js 會遍歷 watchers 樹並更新 DOM。若是你使用 $timeout$http 或根據須要使用 $scope.$apply$scope.$digest 等方式,就會在每一次異步事件中觸發 digest cycle

watchers 是嚴格按照順序觸發:首先是父組件,而後是子組件。這頗有意義,但卻有着不受歡迎的缺點。一個被觸發的 watcher listener 有不少反作用,好比包括更新父組件的屬性。若是父監聽器已經被觸發了,而後子監聽器又去更新父組件屬性,那這個變化不會被檢測到。這就是爲什麼 digest loop 要運行屢次來獲取穩定的程序狀態,即確保沒有數據再發生變化。運行次數最大限定爲 10 次,這個設計如今被認爲是有缺陷的,而且 Angular 不允許這樣作。

Angular

Angular 並無相似 Angular.js 中 watcher 概念,可是追蹤模型屬性的函數依然存在。這些函數是由框架編譯器生成的,而且是私有不可訪問的。另外,它們也和 DOM 緊密耦合在一塊兒,這些函數就存儲在生成視圖結構 ViewDefinitionupdateRenderer 中。

它們也很特別:只追蹤模型變化,而不是像 Angular.js 追蹤一切數據變化。每個組件都有一個 watcher 來追蹤在模板中使用的組件屬性,並對每個被監聽的屬性調用 checkAndUpdateTextInline 函數。這個函數會比較屬性的上一個值與當前值,若是有變化就更新 DOM。

好比,AppComponent 組件的模板:

<h1>Hello {{model.name}}</h1>
複製代碼

Angular Compiler 會生成以下相似代碼:

function View_AppComponent_0(l) {
    // jit_viewDef2 is `viewDef` constructor
    return jit_viewDef2(0,
        // array of nodes generated from the template
        // first node for `h1` element
        // second node is textNode for `Hello {{model.name}}`
        [
            jit_elementDef3(...),
            jit_textDef4(...)
        ],
        ...
        // updateRenderer function similar to a watcher
        function (ck, v) {
            var co = v.component;
            // gets current value for the component `name` property
            var currVal_0 = co.model.name;
            // calls CheckAndUpdateNode function passing
            // currentView and node index (1) which uses
            // interpolated `currVal_0` value
            ck(v, 1, 0, currVal_0);
        });
}
複製代碼

注:使用 Angular-CLI ng new 一個新項目,執行 ng serve 運行程序後,就可在 Chrome Dev Tools 的 Source Tab 的 ng:// 域下查看到編譯組件後生成的 **.ngfactory.js 文件,即上面相似代碼。

因此,即便 watcher 實現方式不一樣,但 digest loop 仍然存在,僅僅是換了名字爲 change detection cycle (注: 爲清晰理解,不翻譯):

In development mode, tick() also performs a second change detection cycle to ensure that no further changes are detected.

上文說到在 digest 期間,Angular.js 會遍歷 watchers 樹並更新 DOM,這與 Angular 中機制很是相似。在變動檢測循環期間(注:與本文中 digest cycle 相同概念),Angular 也會遍歷組件樹並調用渲染函數更新 DOM。這個過程是 checking and updating view process 過程的一部分,我也寫了一篇長文 Everything you need to know about change detection in Angular

就像 Angular.js 同樣,在 Angular 中變動檢測也一樣是由異步事件觸發(注:如異步請求數據返回事件;用戶點擊按鈕事件;setTimeout/setInterval)。可是因爲 Angular 使用 zone 包來給全部異步事件打補丁,因此對於大部分異步事件來講,不須要手動觸發變動檢測。Angular 框架會訂閱 onMicrotaskEmpty 事件,並在一個異步事件完成時會通知 Angular 框架,而這個 onMicrotaskEmpty 事件是在當前 VM Turn 的 microtasks 隊列裏不存在任務時被觸發。然而,變動檢測也能夠手動方式觸發,如使用 view.detectChangesApplicationRef.tick (注:view.detectChanges 會觸發當前組件及子組件的變動檢測,ApplicationRef.tick 會觸發整個組件樹即全部組件的變動檢測)。

Angular 強調所謂的單向數據流,從頂部流向底部。在父組件完成變動檢測後,低層級裏的組件,即子組件,不允許改變父組件的屬性。但若是一個組件在 DoCheck 生命週期鉤子裏改變父組件屬性,倒是能夠的,由於這個鉤子函數是在**更新父組件屬性變化以前調用的**(注:即第 6 步 DoCheck, 在 第 9 步 updates DOM interpolations for the current view if properties on current view component instance changed 以前調用)。可是,若是改變父組件屬性是在其餘階段,好比 AfterViewChecked 鉤子函數階段,在父組件已經完成變動檢測後,再去調用這個鉤子函數,在開發者模式下框架會拋出錯誤:

Expression has changed after it was checked

關於這個錯誤,你能夠讀這篇文章 Everything you need to know about the ExpressionChangedAfterItHasBeenCheckedError error 。(注:這篇文章已翻譯)

在生產環境下 Angular 不會拋出錯誤,可是也不會檢查數據變化直到下一次變動檢測循環。(注:由於開發者模式下 Angular 會執行兩次變動檢測循環,第二次檢查會發現父組件屬性被改變就會拋出錯誤,而生產環境下只執行一次。)

使用生命週期鉤子來追蹤數據變化

在 Angular.js 裏,每個組件定義了一堆 watchers 來追蹤以下數據變化:

  • 父組件綁定的屬性
  • 當前組件的屬性
  • 計算屬性值
  • Angular.js 系統外的第三方組件

在 Angular 裏倒是這麼實現這些功能的:可使用 OnChanges 生命週期鉤子函數來監聽父組件屬性;可使用 DoCheck 生命週期鉤子來監聽當前組件屬性,由於這個鉤子函數會在 Angular 處理當前組件屬性變化前去調用,因此能夠在這個函數裏作任何須要的事情,來獲取即將在 UI 中顯示的改變值;也可使用 OnInit 鉤子函數來監聽第三方組件並手動運行變動檢測循環。

好比,咱們有一個顯示當前時間的組件,時間是由 Time 服務提供,在 Angular.js 中是這麼實現的:

function link(scope, element) {
    scope.$watch(() => {
        return Time.getCurrentTime();
    }, (value) => {
        $scope.time = value;
    })
}
複製代碼

而在 Angular 中是這麼實現的:

class TimeComponent {
    ngDoCheck()
    {
        this.time = Time.getCurrentTime();
    }
}
複製代碼

另外一個例子是若是咱們有一個沒集成在 Angular 系統內的第三方 slider 組件,但咱們須要顯示當前 slide,那就僅僅須要把這個組件封裝進 Angular 組件內,監聽 slider's changed 事件,並手動觸發變動檢測循環來同步 UI。Angular.js 裏這麼寫:

function link(scope, element) {
    slider.on('changed', (slide) => {
        scope.slide = slide;
        
        // detect changes on the current component
        $scope.$digest();
        
        // or run change detection for the all app
        $rootScope.$digest();
    })
}
複製代碼

Angular 裏也一樣原理(注:也一樣須要手動觸發變動檢測循環,this.appRef.tick() 會檢測全部組件,而 this.cd.detectChanges() 會檢測當前組件及子組件):

class SliderComponent {
    ngOnInit() {
        slider.on('changed', (slide) => {
            this.slide = slide

            // detect changes on the current component
            // this.cd is an injected ChangeDetector instance
            this.cd.detectChanges();

            // or run change detection for the all app
            // this.appRef is an ApplicationRef instance
            this.appRef.tick();
        })
    }
}
複製代碼
相關文章
相關標籤/搜索