原文連結:Angular.js’ $digest is reborn in the newer version of Angular
我使用 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 使用 watcher
和 listener
的概念,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 緊密耦合在一起,這些函式就儲存在生成檢視結構 ViewDefinition 的 updateRenderer 中。
它們也很特別:只追蹤模型變化,而不是像 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.detectChanges 或 ApplicationRef.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();
})
}
}
複製程式碼