[譯] $digest 在 Angular 中重生

lx1036發表於2018-04-09

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

$digest

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

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

這個設計的主要問題就是成本太高。改變程式中的任何事物,需要執行成百上千個函式去查詢哪個資料發生變化。而這是 Angular 的基礎部分,但是它會把查詢限定在部分 UI 上,從而提高效能。

如果能更好理解 Angular 是如何實現 digest 的,就可能把你的程式設計的更高效,比如,使用 $scope.$digest() 而不是 $scope.$apply,或者使用不可變物件。但事實是,為了設計出更高效的程式,從而去理解框架內部實現,這可能對很多人來說不是簡單的事情。

所以大量有關 Angular 的文章教程裡都宣稱框架裡不會再有 $digest cycle 了。這取決於對 digest 概念如何理解,但我認為這很有誤導性,因為它仍然存在。的確,在 Angular 裡沒有 scopes 和 watchers,也不再需要呼叫 $scope.$digest(),但是檢測資料變化的機制依然是遍歷整個元件樹,隱式呼叫 watchers ,然後更新 DOM。所以實際上是完全重寫了,但被優化增強了,關於新的查詢機制可以檢視我寫的 Everything you need to know about change detection in Angular

digest 的必要性

開始前讓我們先回憶下 Angular.js 中為何存在 digest。所有框架都是在解決資料模型(JavaScript Objects)和 UI(Browser DOM)的同步問題,最大難題是如何知道什麼時候資料模型發生改變,而查詢資料模型何時發生改變的過程就是變更檢測(change detection)。這個問題的不同實現方案也是現在眾多前端框架的最大區別點。我計劃寫篇文章,有關不同框架變更檢測實現的比較,如果你感興趣並希望收到通知,可以關注我。

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

假設我們有如下一個物件:

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

然後我們去更新 name 屬性值,但是框架是怎麼知道這個值何時被更新呢?一種方式是需要使用者告訴框架(注:如 React 方式):

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();
        })
    }
}
複製程式碼

相關文章