Angular系列之變化檢測(Change Detection)
概述
簡單來說變化檢測就是
Angular
用來檢測檢視與模型之間繫結的值是否發生了改變,當檢測到模型中繫結的值發生改變時,則同步到檢視上,反之,當檢測到檢視上繫結的值發生改變時,則回撥對應的繫結函式。
什麼情況下會引起變化檢測?
總結起來, 主要有如下幾種情況可能也改變資料:
- 使用者輸入操作,比如點選,提交等
- 請求服務端資料(XHR)
- 定時事件,比如
setTimeout
,setInterval
上述三種情況都有一個共同點,即這些導致繫結值發生改變的事件都是非同步發生的。如果這些非同步的事件在發生時能夠通知到Angular
框架,那麼Angular
框架就能及時的檢測到變化。
左邊表示將要執行的程式碼,這裡的stack
Javascript
的執行棧,而webApi
則是瀏覽器中提供的一些Javascript
的API
,TaskQueue
表示Javascript
中任務佇列,因為Javascript
是單執行緒的,非同步任務在任務佇列中執行。
具體來說,非同步執行的執行機制如下:
- 所有同步任務都在主執行緒上執行,形成一個執行棧(
execution context stack
)。 - 主執行緒之外,還存在一個"任務佇列"(
task queue
)。只要非同步任務有了執行結果,就在"任務佇列"之 中放置一個事件。 - 一旦"執行棧"中的所有同步任務執行完畢,系統就會讀取"任務佇列",看看裡面有哪些事件。那些對應的非同步任務,於是結束等待狀態,進入執行棧,開始執行。
- 主執行緒不斷重複上面的第三步。
當上述程式碼在Javascript
中執行時,首先func1
進入執行棧,func1
執行完畢後,setTimeout
進入執行棧,執行setTimeout
過程中將回調函式cb
加入到任務佇列,然後setTimeout
出棧,接著執行func2
函式,func2
函式執行完畢時,執行棧為空,接著任務佇列中cb
進入執行棧得到執行。可以看出非同步任務首先會進入任務佇列,當執行棧中的同步任務都執行完畢時,非同步任務進入執行棧得到執行。如果這些非同步的任務執行前與執行後能提供一些鉤子函式,通過這些鉤子函式,Angular
便能獲知非同步任務的執行。
angular2 獲取變化通知
那麼問題來了,angular2
是如何知道資料發生了改變?又是如何知道需要修改DOM的位置,準確的最小範圍的修改DOM呢?沒錯,儘可能小的範圍修改DOM,因為操作DOM對於效能來說可是一件奢侈品。
在AngularJS
中是由程式碼$scope.$apply()
或者$scope.$digest
觸發,而Angular
接入了ZoneJS
,由它監聽了Angular
所有的非同步事件。
ZoneJS
是怎麼做到的呢?
實際上Zone有一個叫猴子補丁的東西。在Zone.js
執行時,就會為這些非同步事件做一層代理包裹,也就是說Zone.js執行後,呼叫setTimeout、addEventListener
等瀏覽器非同步事件時,不再是呼叫原生的方法,而是被猴子補丁包裝過後的代理方法。代理裡setup了鉤子函式, 通過這些鉤子函式, 可以方便的進入非同步任務執行的上下文.
//以下是Zone.js啟動時執行邏輯的抽象程式碼片段
function zoneAwareAddEventListener() {...}
function zoneAwareRemoveEventListener() {...}
function zoneAwarePromise() {...}
function patchTimeout() {...}
window.prototype.addEventListener=zoneAwareAddEventListener;
window.prototype.removeEventListener=zoneAwareRemoveEventListener;
window.prototype.promise = zoneAwarePromise;
window.prototype.setTimeout = patchTimeout;
變化檢測的過程
Angular
的核心是元件化,元件的巢狀會使得最終形成一棵元件樹。Angular的變化檢測可以分元件進行,每一個Component
都對應有一個changeDetector,我們可以在Component中通過依賴注入來獲取到changeDetector
。而我們的多個Component
是一個樹狀結構的組織,由於一個Component對應一個changeDetector
,那麼changeDetector
之間同樣是一個樹狀結構的組織.
另外,Angular的資料流是自頂而下,從父元件到子元件單向流動。單向資料流向保證了高效、可預測的變化檢測。儘管檢查了父元件之後,子元件可能會改變父元件的資料使得父元件需要再次被檢查,這是不被推薦的資料處理方式。在開發模式下,Angular會進行二次檢查,如果出現上述情況,二次檢查就會報錯:Expression Changed After It Has Been Checked Error
。而在生產環境中,髒檢查只會執行一次。
相比之下,AngularJS
採用的是雙向資料流,錯綜複雜的資料流使得它不得不多次檢查,使得資料最終趨向穩定。理論上,資料可能永遠不穩定。AngularJS
給出的策略是,髒檢查超過10次,就認為程式有問題,不再進行檢查。
變化檢測策略
Angular有兩種變化檢測策略。
Default
是Angular預設的變化檢測策略,也就是上述提到的髒檢查,只要有值發生變化,就全部從父元件到所有子元件進行檢查,。另一種更加高效的變化檢測方式:OnPush
。OnPush策略,就是隻有當輸入資料(即@Input)的引用發生變化或者有事件觸發時,元件才進行變化檢測。
defalut 策略
main.component.ts
@Component({
selector: 'app-root',
template: `
<h1>變更檢測策略</h1>
<p>{{ slogan }}</p>
<button type="button" (click)="changeStar()"> 改變明星屬性
</button>
<button type="button" (click)="changeStarObject()">
改變明星物件
</button>
<movie [title]="title" [star]="star"></movie>`,
})
export class AppComponent {
slogan: string = 'change detection';
title: string = 'default 策略';
star: Star = new Star('周', '杰倫');
changeStar() {
this.star.firstName = '吳';
this.star.lastName = '彥祖';
}
changeStarObject() {
this.star = new Star('劉', '德華');
}
}
movie.component.ts
@Component({
selector: 'movie',
styles: ['div {border: 1px solid black}'],
template: `
<div>
<h3>{{ title }}</h3>
<p>
<label>Star:</label>
<span>{{star.firstName}} {{star.lastName}}</span>
</p>
</div>`,
})
export class MovieComponent {
@Input() title: string;
@Input() star;
}
上面程式碼中, 當點選第一個按鈕改變明星屬性時,依次對slogan
, title
, star
三個屬性進行檢測, 此時三個屬性都沒有變化, star
沒有發生變化,是因為實質上在對star
檢測時只檢測star
本身的引用值是否發生了改變,改變star
的屬性值並未改變star
本身的引用,因此是沒有發生變化。
而當我們點選第二個按鈕改變明星物件時 ,重新new了一個 star
,這時變化檢測才會檢測到 star
發生了改變。
然後變化檢測進入到子元件中,檢測到star.firstName
和star.lastName
發生了變化, 然後更新檢視.
OnPush策略
與上面程式碼相比, 只在movie.component.ts
中的@component
中增加了一行程式碼:
changeDetection:ChangeDetectionStrategy.OnPush
此時, 當點選第一個按鈕時, 檢測到star
沒有發生變化, ok,變化檢測到此結束, 不會進入到子元件中, 檢視不會發生變化.
當點選第二個按鈕時,檢測到star
發生了變化, 然後變化檢測進入到子元件中,檢測到star.firstName
和star.lastName
發生了變化, 然後更新檢視.
所以,當你使用了OnPush
檢測機制時,在修改一個繫結值的屬性時,要確保同時修改到了繫結值本身的引用。但是每次需要改變屬性值的時候去new一個新的物件會很麻煩,immutable.js 你值得擁有!
變化檢測物件引用
通過引用變化檢測物件ChangeDetectorRef
,可以手動去操作變化檢測。我們可以在元件中的通過依賴注入的方式來獲取該物件:
constructor(
private changeRef:ChangeDetectorRef
){}
變化檢測物件提供的方法有以下幾種:
markForCheck()
- 在元件的 metadata 中如果設定了changeDetection:ChangeDetectionStrategy.OnPush
條件,那麼變化檢測不會再次執行,除非手動呼叫該方法, 該方法的意思是在變化監測時必須檢測該元件。detach()
- 從變化檢測樹中分離變化檢測器,該元件的變化檢測器將不再執行變化檢測,除非手動呼叫 reattach() 方法。reattach()
- 重新新增已分離的變化檢測器,使得該元件及其子元件都能執行變化檢測detectChanges()
- 從該元件到各個子元件執行一次變化檢測
OnPush策略下手動發起變化檢測
-
元件中新增事件改變輸入屬性
在上面程式碼movie.component.ts中修改如下
@Component({ selector: 'movie', styles: ['div {border: 1px solid black}'], template: ` <div> <h3>{{ title }}</h3> <p> <button (click)="changeStar()">點選切換名字</button> <label>Star:</label> <span>{{star.firstName}} {{star.lastName}}</span> </p> </div>`, changeDetection:ChangeDetectionStrategy.OnPush }) export class MovieComponent { constructor( private changeRef:ChangeDetectorRef ){} @Input() title: string; @Input() star; changeStar(){ this.star.lastName = 'xjl'; } }
此時點選按鈕切換名字時,star更改如下
![圖片描述][3]
-
第二種就是上面講到的使用變化檢測物件中的
markForCheck()
方法.ngOnInit() { setInterval(() => { this.star.lastName = 'xjl'; this.changeRef.markForCheck(); }, 1000); }
輸入屬性為Observable
修改app.component.ts
@Component({
selector: 'app-root',
template: `
<h1>變更檢測策略</h1>
<p>{{ slogan }}</p>
<button type="button" (click)="changeStar()"> 改變明星屬性
</button>
<button type="button" (click)="changeStarObject()">
改變明星物件
</button>
<movie [title]="title" [star]="star" [addCount]="count"></movie>`,
})
export class AppComponent implements OnInit{
slogan: string = 'change detection';
title: string = 'OnPush 策略';
star: Star = new Star('周', '杰倫');
count:Observable<any>;
ngOnInit(){
this.count = Observable.timer(0, 1000)
}
changeStar() {
this.star.firstName = '吳';
this.star.lastName = '彥祖';
}
changeStarObject() {
this.star = new Star('劉', '德華');
}
}
此時,有兩種方式讓MovieComponent進入檢測,一種是使用變化檢測物件中的 markForCheck()
方法.
ngOnInit() {
this.addCount.subscribe(() => {
this.count++;
this.changeRef.markForCheck();
})
另外一種是使用async pipe 管道
@Component({
selector: 'movie',
styles: ['div {border: 1px solid black}'],
template: `
<div>
<h3>{{ title }}</h3>
<p>
<button (click)="changeStar()">點選切換名字</button>
<label>Star:</label>
<span>{{star.firstName}} {{star.lastName}}</span>
</p>
<p>{{addCount | async}}</p>
</div>`,
changeDetection: ChangeDetectionStrategy.OnPush
})