[译] 关于Angular的变更检测(Change Detection)你需要知道这些
原文地址:Everything you need to know about change detection in Angular
如果你像我一樣,想對(duì)Angular的變更檢測(cè)機(jī)制有一個(gè)深入的理解,由于在網(wǎng)上并沒(méi)有多少有用的信息,你只能去看源碼。大多數(shù)文章都會(huì)提到每一個(gè)組件都會(huì)有一個(gè)屬于自己的變更檢測(cè)器(change detector),它負(fù)責(zé)檢查和這個(gè)組件,但是他們幾乎都僅限于在說(shuō)怎么使用immutable 數(shù)據(jù)和變更檢測(cè)策略,這篇文章將會(huì)讓你明白為什么使用immutable可以工作,并且臟檢查機(jī)制是如何影響檢查的過(guò)程的。還有,這篇文章將會(huì)引發(fā)你對(duì)性能優(yōu)化方面的一些場(chǎng)景的思考。
這篇文章包含2部分,第一部相當(dāng)?shù)挠屑夹g(shù)含量,它包含了一些指向源碼的鏈接,它詳細(xì)的介紹了臟檢查機(jī)制在Angular的底層是怎么運(yùn)行的,所有內(nèi)容是基·Angular的最新 版本-4.0.1(注:作者寫(xiě)這篇文章的時(shí)候,Angular的最新版本是4.0.1), 臟檢查機(jī)制的實(shí)現(xiàn)在這個(gè)版本的實(shí)現(xiàn)和之前的2.4.1版本是不一樣的,如果你對(duì)之前版本的實(shí)現(xiàn)感興趣的話,你可以在這個(gè)stackoverflow的答案上學(xué)習(xí)到一些東西。
第二部分介紹了變更檢測(cè)在應(yīng)用程序中該怎么使用,這部分內(nèi)容既適用于之前的2.4.1版本,也使用于最新的4.0.1版本,因?yàn)檫@部分的API并沒(méi)有改變。
將視圖(view)作為一個(gè)核心概念
在Angular的教程中提到過(guò),一個(gè)Angular應(yīng)用程序就是一個(gè)組件樹(shù),然而,Angular在底層用了一個(gè)低級(jí)的抽象,叫做 視圖(view)。一個(gè)視圖和一個(gè)組件之間有直接的關(guān)聯(lián):一個(gè)視圖對(duì)應(yīng)著一個(gè)組件,反之亦然。一個(gè)視圖通過(guò)一個(gè)叫component的屬性,保持著對(duì)與其所關(guān)聯(lián)的那個(gè)組件類的實(shí)例的引用。所有的操作(比如屬性檢查,DOM更新等),都會(huì)表現(xiàn)在視圖上面,因此從技術(shù)上來(lái)講,更正確的說(shuō)法是,Angular是一個(gè)視圖樹(shù),一個(gè)組件可以被看做是一個(gè)視圖的更高級(jí)的概念。下面是一些源碼中的關(guān)于視圖的介紹.
一個(gè)視圖是一個(gè)應(yīng)用程序UI的基本組成單位,它是能夠被一起創(chuàng)建和銷毀的最小的一個(gè)元素集合。
在一個(gè)視圖中,元素的屬性可以改變,但是它的結(jié)構(gòu)(數(shù)量和順序)不會(huì)被改變,只有通過(guò)一個(gè)ViewContainerRef來(lái)插入、移動(dòng)或是刪除內(nèi)嵌的視圖這些操作才可以改變?cè)氐慕Y(jié)構(gòu)。每一個(gè)視圖可以包含多個(gè)視圖容器。
在本文中,我將交替使用組件視圖和組件的概念。
在這里有一點(diǎn)需要注意的是,網(wǎng)上的所有文章和StackOverflow上的一些回答將變更檢測(cè)視為變更檢測(cè)器對(duì)象或者`ChangeDetectorRef`,指的就是我在這里所說(shuō)的視圖(view)。實(shí)際上,沒(méi)有一個(gè)單獨(dú)的對(duì)象來(lái)進(jìn)行變更檢測(cè),并且視圖才是變更檢測(cè)所運(yùn)行的地方。 復(fù)制代碼每一個(gè)視圖通nodes屬性對(duì)它的子視圖有一個(gè)引用,因此,它可以在它的子視圖中執(zhí)行一些操作。
視圖狀態(tài)(View state)
每一個(gè)視圖都有一個(gè)狀態(tài),它扮演著非常重要的角色,因?yàn)楦鶕?jù)這個(gè)狀態(tài)的值,Angular來(lái)決定是要對(duì)這個(gè)視圖以及它的子視圖進(jìn)行變更檢測(cè)還是忽略掉。有許多可能的狀態(tài),但是下面的這幾個(gè)是與本文相關(guān)的幾個(gè)。
如果ChecksEnabled是false或者視圖是Errored或者Destroyed的狀態(tài),變更檢測(cè)將會(huì)跳過(guò)這個(gè)視圖以及它的子視圖。默認(rèn)的,所有的視圖都被初始化為ChecksEnabled的狀態(tài),除非你設(shè)置了ChangeDetectionStrategy.OnPush。稍后將會(huì)詳細(xì)介紹。視圖的狀態(tài)也可以合并,例如,一個(gè)視圖既可以有FirstCheck的狀態(tài),也可以由ChecksEnabled的狀態(tài)。
Angular有許多高級(jí)的概念來(lái)操作視圖,我在這里寫(xiě)了一些,其中一個(gè)就是viewRef,它封裝了基本的組件視圖,還有一個(gè)指定的方法detectChanges,當(dāng)一個(gè)異步事件發(fā)生的時(shí)候,Angular將會(huì)在它的頂級(jí)viewRef觸發(fā)變更檢測(cè),它會(huì)在對(duì)它自己進(jìn)行變更檢測(cè)后對(duì)它的子視圖進(jìn)行變更檢測(cè)。
你可以通過(guò)ChangeDetectorRef標(biāo)記將這個(gè)viewRef注入到一個(gè)組件的constructor中:
export class AppComponent {constructor(cd: ChangeDetectorRef) { ... } 復(fù)制代碼可以看下這兩個(gè)類的定義
export declare abstract class ChangeDetectorRef {abstract checkNoChanges(): void;abstract detach(): void;abstract detectChanges(): void;abstract markForCheck(): void;abstract reattach(): void; } export abstract class ViewRef extends ChangeDetectorRef {... } 復(fù)制代碼變更檢測(cè)操作
主邏輯負(fù)責(zé)對(duì)存在于checkAndUpdateView函數(shù)中的視圖進(jìn)行變更檢測(cè),它的大部分功能在子組件上執(zhí)行,這個(gè)函數(shù)從主組件開(kāi)始被每一個(gè)組件遞歸的調(diào)用,這就意味著隨著遞歸樹(shù)的展開(kāi),子組件在下一個(gè)調(diào)用中成為父組件。
當(dāng)為特定視圖觸發(fā)此函數(shù)時(shí),它按照指定的順序執(zhí)行以下操作:
如果一個(gè)視圖是第一次被檢查,則將ViewState.firstCheck設(shè)置為true,如果是已經(jīng)被檢查過(guò)了,則設(shè)置為false.
檢查并更新在子組件/指令實(shí)例上的輸入屬性。
更新子視圖變更檢測(cè)狀態(tài)(一部分是變更檢測(cè)策略的實(shí)現(xiàn))。
對(duì)內(nèi)嵌的視圖執(zhí)行變更檢測(cè)(重復(fù)列出的這些步驟)。
如果綁定的值改變的話,在子組件中調(diào)用 OnChanges生命周期鉤子。
調(diào)用子組件的OnInit和ngDoCheck生命周期鉤子(OnInit只有在第一次檢查的時(shí)候才會(huì)被調(diào)用)。
在子視圖組件實(shí)例中更新ContentChildren queryList。
在子組件實(shí)例中調(diào)用AfterContentInit和AfterContentChecked生命周期鉤子(AfterContentInit只有在第一次檢查的時(shí)候才會(huì)被調(diào)用)。
如果當(dāng)前視圖組件實(shí)例上的屬性變化的話,更新DOM插值表達(dá)式。
對(duì)子視圖執(zhí)行變更檢查(重復(fù)這個(gè)列表里的步驟)。
更新當(dāng)前視圖組件實(shí)例中的ViewChildren查詢列表。
在當(dāng)前組件實(shí)例中調(diào)用AfterViewInit和AfterViewChecked生命周期鉤子(AfterViewInit只有在第一次檢查的時(shí)候才會(huì)被調(diào)用)。
禁用當(dāng)前視圖的檢查(一部分是變更檢測(cè)策略的實(shí)現(xiàn))。
基于上面的執(zhí)行列表,有幾個(gè)需要強(qiáng)調(diào)的事情。
第一個(gè)事情就是onChanges生命周期鉤子是發(fā)生在子組件中的,它在子視圖被檢查之前觸發(fā)的,并且即使這個(gè)子視圖沒(méi)有進(jìn)行變更檢測(cè)它也會(huì)觸發(fā)。這是個(gè)很重要的信息,本文的第二部分你將會(huì)看到我們?cè)趺蠢眠@個(gè)信息。
第二個(gè)事情就是當(dāng)視圖被檢測(cè)的時(shí)候,它的DOM的更新是作為變更檢測(cè)機(jī)制的一部分的,也就是說(shuō)如果一個(gè)組件沒(méi)有被檢查,即使這個(gè)組件的被用到模板上的屬性改變了,DOM也不會(huì)被更新。模板是在第一次檢查前就被渲染了,我所指的DOM更新實(shí)際上指的是插值表達(dá)式的更新,因此如果你有一個(gè)這樣的模板<span>some {{name}}</span>,DOM元素span將會(huì)在第一次檢查前就被渲染,而在檢查的時(shí)候,只有{{name}}這部分才會(huì)被渲染。
另外一個(gè)有趣的發(fā)現(xiàn)是在變更檢測(cè)期間,一個(gè)子組件的視圖的狀態(tài)會(huì)被改變。我在前面提到過(guò)所有的組件視圖在初始化時(shí)默認(rèn)都是ChecksEnabled的的狀態(tài),但是對(duì)于那些使用了OnPush策略的組件來(lái)說(shuō),變更檢測(cè)將會(huì)在第一次檢查后被禁用。(上面操作列表中的第9步):
if (view.def.flags & ViewFlags.OnPush) {view.state &= ~ViewState.ChecksEnabled; } 復(fù)制代碼這意味著在后面的變更檢測(cè)在執(zhí)行檢查時(shí),這個(gè)組件及它的所有子組件將會(huì)被忽略掉。文檔中說(shuō)一個(gè)設(shè)置了OnPush策略的組件只有在它綁定的輸入屬性改變的時(shí)候才會(huì)被檢查,因此必須通過(guò)設(shè)置ChecksEnabled位來(lái)啟用檢查,這也是下面的代碼所做的(步驟2):
if (compView.def.flags & ViewFlags.OnPush) {compView.state |= ViewState.ChecksEnabled; } 復(fù)制代碼只有當(dāng)父級(jí)視圖綁定改變并且子組件視圖被初始化為ChangeDetectionStrategy.OnPush策略時(shí),狀態(tài)才會(huì)被更新。
最后,當(dāng)前視圖的變更檢測(cè)負(fù)責(zé)開(kāi)啟它的子視圖的變更檢測(cè)(步驟8)。這是檢查子組件視圖狀態(tài)的地方,如果ChecksEnabled是true,那么執(zhí)行變更檢測(cè),下面是相關(guān)的代碼:
viewState = view.state; ... case ViewAction.CheckAndUpdate:if ((viewState & ViewState.ChecksEnabled) &&(viewState & (ViewState.Errored | ViewState.Destroyed)) === 0) {checkAndUpdateView(view);} } 復(fù)制代碼現(xiàn)在你已經(jīng)知道了視圖的狀態(tài)控制著是否要對(duì)這個(gè)視圖以及它的子組件執(zhí)行變更檢測(cè),所以問(wèn)題是我們能控制這些狀態(tài)碼?答案是可以,這也是本文第二部分要講的內(nèi)容。
有的聲明周期鉤子在DOM更新之前被調(diào)用(3,4,5),有的是在之后(9)。因此如果你有下面的組件層級(jí)關(guān)系:A -> B -> C,下面就是聲明周期鉤子被調(diào)用和綁定更新的順序。
A: AfterContentInit A: AfterContentChecked A: Update bindingsB: AfterContentInitB: AfterContentCheckedB: Update bindingsC: AfterContentInitC: AfterContentCheckedC: Update bindingsC: AfterViewInitC: AfterViewCheckedB: AfterViewInitB: AfterViewChecked A: AfterViewInit A: AfterViewChecked 復(fù)制代碼探索含義(Exploring the implications)
我們假設(shè)有下面的一個(gè)組件樹(shù):
正如我們上面所學(xué)到的,每一個(gè)組件都有一個(gè)與之相關(guān)聯(lián)的組件視圖,每一個(gè)視圖初始化時(shí)的ViewState.ChecksEnabled都為true,這就意味著當(dāng)Angular執(zhí)行變更檢測(cè)時(shí),組件樹(shù)上的每一個(gè)組件杜輝被檢查。
假設(shè)我們想禁用掉AComponent及它的子組件的變更檢測(cè),我們只需要很簡(jiǎn)單的把它的ViewState.ChecksEnabled設(shè)置為false就可以的。直接改變狀態(tài)是一個(gè)低級(jí)的操作,因此Angular為我們提供了一些在視圖上可用的公共方法。每一個(gè)組件都可以通過(guò)ChangeDetectorRef來(lái)獲得與其關(guān)聯(lián)的視圖的引用,Angular文檔中為這個(gè)類定義了如下的公共接口:
class ChangeDetectorRef {markForCheck() : voiddetach() : voidreattach() : voiddetectChanges() : voidcheckNoChanges() : void } 復(fù)制代碼讓我們看看我們看以從中收獲點(diǎn)什么吧。
deatch
第一個(gè)我們可以操作視圖的方法是deatch,它僅僅是能夠禁用掉對(duì)當(dāng)前視圖的檢查:
detach(): void { this._view.state &= ~ViewState.ChecksEnabled; }復(fù)制代碼讓我們看看怎么在代碼中使用它:
export class AComponent {constructor(public cd: ChangeDetectorRef) {this.cd.detach();} 復(fù)制代碼它確保了在接下來(lái)的變更檢測(cè)中,以AComponent為開(kāi)始的左側(cè)部分將會(huì)被忽略掉(橘黃色的組件將不會(huì)被檢查):
在這里有兩個(gè)地方需要注意--第一個(gè)就是就是我們改變了AComponent的檢測(cè)狀態(tài),所有它的子組件也不會(huì)被檢查。第二個(gè)就是由于左側(cè)的組件們北郵執(zhí)行變更檢測(cè),所有他們呢的模板視圖也不會(huì)被更新,下面是一個(gè)小例子來(lái)證明這一點(diǎn):
@Component({selector: 'a-comp',template: `<span>See if I change: {{changed}}</span>` }) export class AComponent {constructor(public cd: ChangeDetectorRef) {this.changed = 'false';setTimeout(() => {this.cd.detach();this.changed = 'true';}, 2000);} 復(fù)制代碼第一次(檢查)的時(shí)候,span標(biāo)簽將會(huì)被渲染成文本See if I change: false. 當(dāng)2秒后,changed屬性變?yōu)閠rue的時(shí)候,span標(biāo)簽中的文本將不會(huì)改變,但當(dāng)我們刪掉this.cd.detach()的時(shí)候,一切都會(huì)如期執(zhí)行。
reattach
像本文中第一部分中所說(shuō)的那樣,如果綁定的輸入屬性aProp在AppComponent中改變了,AComponent的OnChanges生命周期鉤子仍舊會(huì)觸發(fā)。這就意味著一旦我們輸入屬性改變了,我們就可以激活當(dāng)前視圖的變更檢測(cè)器去執(zhí)行變更檢測(cè),然后在下個(gè)事件循環(huán)中再把它從deatch(變更檢測(cè)樹(shù)中分離)掉,下面的代碼片段證明了這一點(diǎn):
export class AComponent {@Input() inputAProp;constructor(public cd: ChangeDetectorRef) {this.cd.detach();}ngOnChanges(values) {this.cd.reattach();setTimeout(() => {this.cd.detach();})} 復(fù)制代碼其實(shí),reattach僅僅對(duì)ViewState.ChecksEnabled進(jìn)行了位操作:
reattach(): void { this._view.state |= ViewState.ChecksEnabled; } 復(fù)制代碼這跟我們把ChangeDetectionStrategy設(shè)置為OnPush幾乎是等價(jià)的:在第一次變更檢測(cè)執(zhí)行完后就禁用掉,然后當(dāng)父組件綁定的屬性改變時(shí)再啟用檢查,檢查完了之后再禁用掉。
注意只有在禁用分支的最頂層的組件的OnChanges鉤子才會(huì)被觸發(fā),而不是禁用分支的所有組件。
markForCheck
reattach方法只能對(duì)當(dāng)前的組件啟用檢查,但是如果當(dāng)前的組件的父組件沒(méi)有啟用臟檢查的話,它將不起作用,這就意味著reattach方法僅僅對(duì)禁用分支的頂層組件起作用。
我們需要一個(gè)方法來(lái)對(duì)所有的父組件一直到根組件都啟用臟檢查,這里有一個(gè)markForCheck的方法:
let currView: ViewData|null = view; while (currView) {if (currView.def.flags & ViewFlags.OnPush) {currView.state |= ViewState.ChecksEnabled;}currView = currView.viewContainerParent || currView.parent; } 復(fù)制代碼從上面的實(shí)現(xiàn)中可以看到,它僅僅是向上遍歷,對(duì)所有的父組件啟用檢查一直到根組件。
什么時(shí)候它是有用的呢?就像是ngOnChanges一樣,即使組件使用OnPush策略,ngDoCheck生命周期鉤子也會(huì)被觸發(fā),同樣的,只有在禁用分支的最頂層的組件中才會(huì)被觸發(fā),而不是禁用分支的所有組件。但是我們可以用這個(gè)鉤子來(lái)執(zhí)行一些定制化的邏輯,使我們的組件可以在一個(gè)變更檢測(cè)周期中執(zhí)行檢查。由于Angular僅僅檢查對(duì)象的引用,我們可以實(shí)現(xiàn)一些對(duì)象屬性的臟檢查:
Component({...,changeDetection: ChangeDetectionStrategy.OnPush }) MyComponent {@Input() items;prevLength;constructor(cd: ChangeDetectorRef) {}ngOnInit() {this.prevLength = this.items.length;}ngDoCheck() {if (this.items.length !== this.prevLength) {this.cd.markForCheck(); this.prevLenght = this.items.length;}} 復(fù)制代碼detectChanges
有一種方法只在當(dāng)前視圖和它的子視圖只運(yùn)行一次變更檢測(cè),那就是detectChanges方法, 這個(gè)方法在運(yùn)行變更檢測(cè)時(shí)候不管當(dāng)前組件的狀態(tài)是什么,那就意味著當(dāng)前的視圖可能會(huì)保持禁用檢查的狀態(tài),在下一個(gè)常規(guī)的變更檢測(cè)進(jìn)行時(shí),它將不會(huì)被檢查,下面是一個(gè)例子:
export class AComponent {@Input() inputAProp;constructor(public cd: ChangeDetectorRef) {this.cd.detach();}ngOnChanges(values) {this.cd.detectChanges();} 復(fù)制代碼當(dāng)輸入屬性改變的時(shí)候,即使變更檢測(cè)器還保持著分離的狀態(tài),DOM也會(huì)更新。
checkNoChanges
變更檢測(cè)器上最后一個(gè)有用的方法是在運(yùn)行當(dāng)前的變更檢測(cè)時(shí),確保沒(méi)有變化發(fā)生。基本上,它執(zhí)行了本文第一部分那個(gè)步驟中的1,7,8的操作,并且當(dāng)它發(fā)現(xiàn)一個(gè)綁定值變化了或是決定DOM應(yīng)該要被更行的時(shí)候,將會(huì)拋出一個(gè)異常。
總結(jié)
以上是生活随笔為你收集整理的[译] 关于Angular的变更检测(Change Detection)你需要知道这些的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: Mysql创建数据库字符集的选择
- 下一篇: 关于Mysql触发器的使用