轉載自GitHub JTangming : https://github.com/JTangming/tm/issues/4
Angular應用程序通過組件實例和模板之間進行數據交互,也就是將組件的數據和頁面DOM元素關連起來,當數據有變化后,NG2能夠監測到這些變化並更新視圖,反之亦然,它的數據流向是單項的,通過屬性綁定和事件綁定來實現數據的流入和流出,數據從屬性綁定流入組件,從事件流出組件,數據的雙向綁定就是通過這樣來實現的。那么它是如何實現變化檢測的呢?
需要進行變化監測的情形
試想一下,在什么樣的場景下,angular才需要去更新視圖:
- event,在view中綁定事件來監聽用戶的操作,如果數據有變更則更新視圖;
- xmlHTTPRequest/webSocket,例如從遠端服務拉取對應的數據,這是一個異步的過程;
- timeout,例如:
setTimeout
,setInterval
,requestAnimationFrame
都是在某個延時后觸發。
以上的共同特征是什么?很明顯共同點是它們都是異步的處理,即需要使用異步回調函數,這帶給我們的結論就是,不管任何時候的一個異步操作,我們應用程序狀態可能已經被改變,這就需要告訴Angular去更新視圖。
我們創建一個組件來呈現一個Todo例子,我們可以在模板中這樣使用這個組件:
<todo-cmp [model]="myTodo" (complete)="onCompletingTodo(todo)"></todo-cmp>
這將告訴Angular不管任何時候myTodo發生改變,Angular必須通過調用視圖模型設置的myTodo數據(model setter)來自動的更新todo模板組件。
同樣的,數據的流出是通過事件綁定來實現的,如果一個complete
事件被觸發,它將調用這個onCompletingTodo
方法,該方法可能是一個獲取后台最新數據的操作,這將需要用后台返回的異步數據與之前的數據參考進行對比來確定是否需要更新視圖。
正如上面的例子,Angular2的屬性和事件綁定的核心語法是很簡單的,我們通過屬性綁定實現了數據從父傳遞給了子,而事件綁定則實現了數據由子到父的傳遞,這也就是Angular2用來實現數據雙向綁定的方法。它實現的是單向流的數據傳遞,也就是說,你的數據流只能向下流入組件,如果你需要進行數據變化,你可以發射導致變化的事件到頂部,待數據變化處理完成,然后再往下流入組件。那么問題來了,Angular2如何知道數據是否已經處理處理完成,這份新的數據是否有變化,如果數據有變化,那是怎么來通知數據往下流入組件通知組件來改變視圖呢?這里我們先理解zone。
關於Zone
Zone實際上是Dart的一種語言特性,其是對Javascript某些設計缺陷的一些補充,簡單的可以概述成Zone是一個異步事件攔截器,也就是說Zone能夠hook到異步任務的執行上下文,以此來處理一些操作,比如說,在我們每次啟動或者完成一個異步的操作、進行堆棧的跟蹤處理、某段功能代碼進入或者離開zone,我們可以在這些關鍵的節點重寫我們所需處理的方法。
Zone中提供了各類hooks,允許在每一個回調函數的開始和結束時,去執行統一的自定義邏輯,其本身是不做任何事的,相反它是依賴其它的代碼,獲取到這些代碼片段的執行上下文,通過hooks來完成相關的功能。Zone的另一個值得一提的是它必須依賴異步操作,當一個異步操作在執行時,它是有必要去捕獲的這個異步操作並在該異步功能開始或者完成時建立對應的callback,然后存儲到當前的zone,舉個例子,如果一個代碼片段在fork的zone中執行,並且這段代碼中包含一個setTimeout
的異步任務,那么執行到和完成這個setTimeout
方法需要包裹一個異步的回調函數,存儲到當前zone。
這樣是確保每個異步操作之間的相互不受影響,也就是受保護的狀態,例如一個頁面由業務代碼和一些第三方廣告代碼組成,這兩份代碼之間是相互獨立的,我們需要的是業務代碼的異常捕獲數據提交到我們自己的后台服務器上,第三方廣告代碼的異常捕獲提交到他們自己的服務器上。當fork了多個zone之后,異步操作將會精准的執行其所在的子zone上面方法。
Zone的一個重要意義在於,我們的功能或者業務代碼運行在了fork的一個zone中,我們zone有了對該代碼塊執行上下文的控制權。其中也提供了一些鈎子(hook)來處理我們基本的業務情景需求,大致有:
- Zone.onZoneCreated:在zone被fork時運行
- Zone.beforeTask:在執行zone.run包裹的函數之前調用
- Zone.afterTask:在執行zone.run包裹的函數之后調用
- Zone.onError:zone.run方法中的Task任務拋出異常時的鈎子函數
下面我們通過這樣的一個例子來幫助你理解Zone,簡單的代碼如下:
zone.fork({ beforeTask: () => { console.log('hi, beforeTask in.'); }, afterTask: () => { console.log('hi, afterTask in.'); } }).run(function () { zone.inTheZone = true; setTimeout(function () { console.log('in the zone: ' + !!zone.inTheZone); }, 0); }); console.log('in the zone: ' + !!zone.inTheZone);
這段代碼按照執行上下文順序的執行,我們在zone的run函數執行的開始和結束會有對應的hooks,例如要統計這段代碼執行所消耗的時間,然而通常情況下,這里的異步處理,比如說是服務端異步返回給我們所需要的數據,或者是一些異步事件更改視圖模型的數據等。這樣通過beforeTask
和afterTask
統計到整個代碼的耗時。這種情形在zone得到了很好的解決,Zone能夠hook到異步任務的執行上下文,在異步事件發生或者結束的時候,允許我們在這樣的異步任務節點執行一些分析代碼。zone使用也很簡單,一旦我們引入zone.js,那我們在全局作用域中可以獲取到zone對象。
但是這遠遠不夠的,很多時候我們的應用場景要比這個復雜的多,現在是時候體現zone的暴力美了,zone.js采用猴子補丁(Monkey-patched)的方式將Js中的異步任務都進行了包裹,同樣的這使得這些異步任務都將運行在zone的執行上下文中,每一個異步的任務在zone.js都是一個task,除了提供了一些供開發者使用的勾子(hook)函數外,默認情況下zone.js重寫了並提供了如下的方法:
- Zone.setInterval() / Zone.setTimeout()
- Zone.alert()
- Zone.prompt()
- Zone.requestAnimationFrame()
- Zone.addEventListener()
- Zone.removeEventListener()
綜上所述,我們應該能理解zone.js的應用場景了,即實現了異步task的跟蹤分析和錯誤記錄以便更好的進行開發debug等。接下來將回到主題來探討一下Angular2的數據綁定和zone的關系。
Angular2數據綁定和Zone
在Angular1.x中,默認的選擇是雙向的數據綁定,你的控制器數據發生變化,或者表單直接操作數據變動等,最終體現在視圖中顯示數據。
Angular1.x雙向數據綁定的問題是,隨着你的項目增長,它往往會導致整個應用的級聯效應,並很難跟蹤你的數據流。除非你使用Angular1.x框架的內置服務和指令,否則我們在model上做數據修改或者數據輸出,Angular是無法預知的,當然就不會去更新視圖模板中的數據來展示給UI。
好在Angular2框架把zone.js作為依賴,因為zone.js是一個獨立的庫,可以不依賴於其他庫或者框架而單獨被使用,因此在Angular2開發的應用中,zone擁有angular應用運行環境的執行上下文,事實證明,zone是能夠解決在我們在angular應用中變化監測的問題的。
下面我們來介紹ngZone。實際上,ngZone是基於Zone.js來實現的,Angular2 fork了zone.js,它是zone派生出來的一個子zone,在Angular環境內注冊的異步事件都運行在這個子zone上(因為ngZone擁有整個Angular運行環境的執行上下文),並且onTurnStart和onTurnDone事件也會在該子zone的run方法中觸發。
在Angular2源碼中,有一個ApplicationRef類,其作用是用來監聽ngZone中的onTurnDone事件,不論何時只要觸發這個事件,那么將會執行一個tick()方法用來告訴Angular去執行變化監測。
// very simplified version of actual source class ApplicationRef { changeDetectorRefs:ChangeDetectorRef[] = []; constructor(private zone: NgZone) { this.zone.onTurnDone .subscribe(() => this.zone.run(() => this.tick()); } tick() { this.changeDetectorRefs .forEach((ref) => ref.detectChanges()); } }
Angular2的變化監測
現在我們已經知道了Angular2的變化監測在何時被觸發,那它是怎么去做變化監測的呢?實際上在Angular2中,任何的一個Angular2應用都是由大大小小的組件組成的,可以把它看成是一顆線性的組件樹,重要的是,每一個組件都有自己的變化檢測器。這樣的一個圖可以幫助你理解這些概念,具體也可以參考關於Angular2變化監測的文章。
正是因為每個組件都擁有它的變化檢測器,組成了Angular2應用的一顆組件樹,同樣的我們也有變化監測樹,它也是線性的,數據的流向也是從上到下,因為變化監測在每個組件中的執行也是從根組件開始,從上往下的執行。單向的數據流相對angular1.x的環形數據流來說要更好預測的多,其實我們清楚視圖中數據的來源,也就是說這些數據的變化是來自於哪個組件數據變化的結果。我們來舉個例子吧:
@Component({ template: '<v-card [vData]="vData"></v-card>' }) class VCardApp { constructor() { this.vData = { name: '***', email: '****@**.com' } } changeData() { this.vData.name = '*****'; } }
Angular2在整個運行期間都會為每一個組件創建監測類,用來監測每個組件在每個運行周期是否有異步操作發生。當變化監測被執行時會發生什么呢?假象一下changeData()方法在一個異步的操作之后被執行,那么vData.name被改變,然后被傳遞到<v-card [vData]="vData"></v-card>
的變化檢測器來和之前的數據對比是否有改變,如果和參照數據對比有變動的話,Angular將更新視圖。
因為在JavaScript語言中不提供給我們對象的變化通知,所以Angular必須保守的要對每一個組件的每一次運行結果執行變化檢測,但其實很多組件的輸入屬性是沒有變化的,沒必要對這樣的組件來一次變化監測,如何減少不必要的監測,我們有兩種方式去實現。
Immutable Objects
不可變對象(Immutable Objects)給我們提供的保障是對象不會改變,即當其內部的屬性發生變化時,相對舊有的對象,我們將會保存另一份新的參照。它僅僅依賴輸入的屬性,也就是當輸入屬性沒有變動(沒有變動即沒有產生一份新的參照),Angular將跳過對該組件的全部變化監測,直到有屬性變化為止。如果需要在Angular2中使用不可變對象,我們需要做的就是設置changeDetection: ChangeDetectionStrategy.OnPush,如下的例子:
@Component({ template: ` <h2>{{vData.name}}</h2> <span>{{vData.email}}</span> `, changeDetection: ChangeDetectionStrategy.OnPush }) class VCardCmp { @Input() vData; }
例子中,VCardCmp僅僅依賴它的輸入屬性,同時我們也設定了變化監測策略為OnPush來告訴Angular如果屬性屬性沒有任何變化的話,則跳過該組件的變化監測。
Observables
和不可變對象類似,但卻又和不可變對象不同,它們有相關變化的時候不會提供一份新的參照,可觀測對象在輸入屬性發生變化的時候來觸發一個事件來更新組件視圖,同樣的,我們也是添加OnPush來跳過子組件樹的監測器,我們給這樣的一個例子來幫你加深理解:
@Component({ template: '{{counter}}', changeDetection: ChangeDetectionStrategy.OnPush }) class CartBadgeCmp { @Input() addItemStream:Observable<any>; counter = 0; ngOnInit() { this.addItemStream.subscribe(() => { this.counter++; // application state changed }) } }
該組件是模擬的當用戶觸發一個事件后增加counter這樣一個場景,確切的講,CartBadgeCmp設置了一個插值counter和一個輸入屬性addItemStream,當有異步操作需要更新counter的時候,將會觸發一個事件流,但是輸入屬性addItemStream作為參考對象將不會更改,意味着該組件樹的變化監測將不會發生。那怎么辦?我們將怎么來通知Angular某區塊有改變呢?Angular2的變化監測總是從組件樹的頭到尾來執行,我們其實需要的就是在整個組件樹的某個發生改變的地方來做出相應即可,Angular是不知道那一塊目錄有改變的,但是我們知道,我們可以通過依賴注入給組件來引入一個ChangeDetectorRef,這個方法正是我們所需要的,它能標記整顆組件樹的目錄直到下一次變化監測的執行,代碼示例如下:
class CartBadgeCmp { constructor(private cd: ChangeDetectorRef) {} @Input() addItemStream:Observable<any>; counter = 0; ngOnInit() { this.addItemStream.subscribe(() => { this.counter++; // application state changed this.cd.markForCheck(); // marks path }) } }
當這個可監測的addItemStream觸發一個事件,該事件處理句柄將會從根路徑到這個已經改變的addItemStream組件來處理監測,一旦變化監測跑遍整個監測路徑,它將會存儲OnPush狀態到整個組件樹。這樣做的好處是,變化監測系統將會走遍整棵樹,你可以利用他們來監測樹在局部是否有真正的改變,以此來做出相應的改變。