變化檢測是前端框架中很有趣的一部分內容,各個前端的框架也都有自己的一套方案,一般情況下我們不太需要過多的瞭解變化檢測,因為框架已經幫我們完成了大部分的工作。不過隨著我們深入的使用框架,我們會發現我們很難避免的要去了解變化檢測,瞭解變化檢測可以幫助我們更好的理解框架、排查錯誤、進行效能優化等等。【相關教學推薦:《》】
簡單的來說,變化檢測就是通過檢測檢視與狀態之間的變化,在狀態發生了變化後,幫助我們更新檢視,這種將檢視和我們的資料同步的機制就叫變化檢測。
我們瞭解了什麼是變化檢測,那何時觸發變化檢測呢?我們可以看看下面這兩個簡單的Demo
Demo1:
一個計數器元件,點選按鈕Count會一直加 1
@Component({ selector: "app-counter", template: ` Count:{{ count }} <br /> <button (click)="increase()">Increase</button> `, }) export class CounterComponent { count = 0; constructor() {} increase() { this.count = this.count + 1; } }
Demo2:
一個Todo List的元件,通過Http獲取資料後渲染到頁面
@Component({ selector: "app-todos", template: ` <li *ngFor="let item of todos">{{ item.titme }}</li> `, }) export class TodosComponent implements OnInit { public todos: TodoItem[] = []; constructor(private http: HttpClient) {} ngOnInit() { this.http.get<TodoItem[]>("/api/todos").subscribe((todos: TodoItem[]) => { this.todos = todos; }); } }
從上面的兩個 Demo 中我們發現,在兩種情況下觸發了變化檢測:
點選事件發生時
通過 http 請求遠端資料時
仔細思考下,這兩種觸發的方式有什麼共同點呢? 我們發現這兩種方式都是非同步操作,所以我們可以得出一個結論: 只要發生了非同步操作,Angular 就會認為有狀態可能發生變化了,然後就會進行變化檢測。
這個時候可能大家會想到 setTimeout
setInterval
,是的,它們同樣也會觸發變化檢測。
@Component({ selector: "app-counter", template: ` Count:{{ count }} <br /> <button (click)="increase()">Increase</button> `, }) export class CounterComponent implements OnInit { count = 0; constructor() {} ngOnInit(){ setTimeout(()=>{ this.count= 10; }); } increase() { this.count = this.count + 1; } }
簡而言之,如果發生以下事件之一,Angular 將觸發變化檢測:
任何瀏覽器事件(click、keydown 等)
setInterval()
和 setTimeout()
HTTP 通過 XMLHttpRequest
進行請求
剛才我們瞭解到,只要發生了非同步操作,Angular 就會進行變化檢測,那 Angular 又是如何訂閱到非同步事件的狀態,從而觸發變化檢測的呢?這裡我們就要聊一聊 zone.js 了。
Zone.js
Zone.js 提供了一種稱為 ** 區域(Zone) ** 的機制,用於封裝和攔截瀏覽器中的非同步活動、它還提供 非同步生命週期的勾點 和 統一的非同步錯誤處理機制。
Zone.js 是通過 Monkey Patching(猴子修補程式) 的方式來對瀏覽器中的常見方法和元素進行攔截,例如 setTimeout
和 HTMLElement.prototype.onclick
。Angular 在啟動時會利用 zone.js 修補幾個低階瀏覽器 API,從而實現非同步事件的捕獲,並在捕獲時間後呼叫變化檢測。
下面用一段簡化的程式碼來模擬一下替換 setTimeout 的過程:
function setTimeoutPatch() { // 儲存原始的setTimeout var originSetTimeout = window['setTimeout']; // 對瀏覽器原生方法的包裹封裝 window.setTimeout = function () { return global['zone']['setTimeout'].apply(global.zone, arguments); }; // 建立包裹方法,提供給上面重寫後的setTimeout使用Ï Zone.prototype['setTimeout'] = function (fn, delay) { // 先呼叫原始方法 originSetTimeout.apply(window, arguments); // 執行完原始方法後就可以做其他攔截後需要進行的操作了 ... }; }
NgZone
Zone.js 提供了一個全域性區域,可以被 fork 和擴充套件以進一步封裝/隔離非同步行為,Angular 通過建立一個fork並使用自己的行為擴充套件它,通常來說, 在 Angular APP 中,每個 Task 都會在 Angular 的 Zone 中執行,這個 Zone 被稱為 NgZone
。一個 Angular APP 中只存在一個 Angular Zone, 而變更檢測只會由執行於這個 ** **NgZone**
** 中的非同步操作觸發 。
簡單的理解就是: Angular 通過 Zone.js 建立了一個自己的區域並稱之為 NgZone,Angular 應用中所有的非同步操作都執行在這個區域中。
我們瞭解 Angular 的核心是 元件化 ,元件的巢狀會使得最終形成一棵 元件樹 。
Angular 在生成元件的同時,還會為每一個元件生成一個變化檢測器 changeDetector
,用來記錄元件的資料變化狀態,由於一個 Component 會對應一個 changeDetector
,所以changeDetector
同樣也是一個樹狀結構的組織。
在元件中我們可以通過注入 ChangeDetectorRef
來獲取元件的 changeDetector
@Component({ selector: "app-todos", ... }) export class TodosComponent{ constructor(cdr: ChangeDetectorRef) {} }
我們在建立一個 Angular 應用 後,Angular 會同時建立一個 ApplicationRef
的範例,這個範例代表的就是我們當前建立的這個 Angular 應用的範例。 ApplicationRef
建立的同時,會訂閱 ngZone 中的 onMicrotaskEmpty
事件,在所有的微任務完成後呼叫所有的檢視的detectChanges()
來執行變化檢測。
下是簡化的程式碼:
class ApplicationRef { // ViewRef 是繼承於 ChangeDetectorRef 的 _views: ViewRef[] = []; constructor(private _zone: NgZone) { this._zone.onMicrotaskEmpty.subscribe({ next: () => { this._zone.run(() => { this.tick(); }); }, }); } // 執行變化檢測 tick() { for (let view of this._views) { view.detectChanges(); } } }
單向資料流
什麼是單向資料流?
剛才我們說了每次觸發變化檢測,都會從根元件開始,沿著整棵元件樹從上到下的執行每個元件的變更檢測,預設情況下,直到最後一個葉子 Component 元件完成變更檢測達到穩定狀態。在這個過程中,一但父元件完成變更檢測以後,在下一次事件觸發變更檢測之前,它的子孫元件都不允許去更改父元件的變化檢測相關屬性狀態的,這就是單向資料流。
我們看一個範例:
@Component({ selector: "app-parent", template: ` {{ title }} <app-child></app-child> `, }) export class ParentComponent { title = "我的父元件"; } @Component({ selector: "app-child", template: ``, }) export class ChildComponent implements AfterViewInit { constructor(private parent: ParentComponent) {} ngAfterViewInit(): void { this.parent.title = "被修改的標題"; } }
為什麼出現這個錯誤呢?
這是因為我們違反了單向資料流,ParentComponent 完成變化檢測達到穩定狀態後,ChildComponent 又改變了 ParentComponent 的資料使得 ParentComponent 需要再次被檢查,這是不被推薦的資料處理方式。在開發模式下,Angular 會進行二次檢查,如果出現上述情況,二次檢查就會報錯: ExpressionChangedAfterItHasBeenCheckedError ,在生產環境中,則只會執行一次檢查。
並不是在所有的生命週期去呼叫都會報錯,我們把剛才的範例修改一下:
@Component({ selector: "app-child", template: ``, }) export class ChildComponent implements OnInit { constructor(private parent: ParentComponent) {} ngOnInit(): void { this.parent.title = "被修改的標題"; } }
修改後的程式碼執行正常,這是為什麼呢?這裡要說一下Angular檢測執行的順序:
更新所有子子元件繫結的屬性
呼叫所有子元件生命週期的勾點 OnChanges, OnInit, DoCheck ,AfterContentInit
更新當前元件的DOM
呼叫子元件的變換檢測
呼叫所有子元件的生命週期勾點 ngAfterViewInit
ngAfterViewInit
是在變化檢測之後執行的,在執行變化檢測後我們更改了父元件的資料,在Angular執行開發模式下的第二次檢查時,發現與上一次的值不一致,所以報錯,而ngOnInit
的執行在變化檢測之前,所以一切正常。
這裡提一下AngularJS,AngularJS採用的是雙向資料流,錯綜複雜的資料流使得它不得不多次檢查,使得資料最終趨向穩定。理論上,資料可能永遠不穩定。AngularJS的策略是,髒檢查超過10次,就認為程式有問題,不再進行檢查。
剛才我們聊了變化檢測的工作流程,接下來我想說的是變化檢測的效能, 預設情況下,當我們的元件中某個值發生了變化觸發了變化檢測,那麼Angular會從上往下檢查所有的元件。 不過Angular對每個元件進行更改檢測的速度非常快,因為它可以使用 內聯快取 在幾毫秒內執行數千次檢查,其中內聯快取可生成對 VM 友好程式碼。
儘管 Angular 進行了大量優化,但是遇到了大型應用,變化檢測的效能仍然會下降,所以我們還需要用一些其他的方式來優化我們的應用。
Angular 提供了兩種執行變更檢測的策略:
Default
OnPush
Default 策略
預設情況下,Angular 使用 ChangeDetectionStrategy.Default
變更檢測策略,每次事件觸發變化檢測(如使用者事件、計時器、XHR、promise 等)時,此預設策略都會從上到下檢查元件樹中的每個元件。這種對元件的依賴關係不做任何假設的保守檢查方式稱為 髒檢查 ,這種策略在我們應用元件過多時會對我們的應用產生效能的影響。
OnPush 策略
Angular 還提供了一種 OnPush
策略,我們可以修改元件裝飾器的 changeDetection
來更改變化檢測的策略
@Component({ selector: 'app-demo', // 設定變化檢測的策略 changeDetection: ChangeDetectionStrategy.OnPush, template: ... }) export class DemoComponent { ... }
設定為 OnPush 策略後,Angular 每次觸發變化檢測後會跳過該元件和該元件的所以子元件變化檢測
OnPush模式下變化檢測流程
在 OnPush
策略下,只有以下這幾種情況才會觸發元件的變化檢測:
輸入值(@Input)更改
當前元件或子元件之一觸發了事件
手動觸發變化檢測
使用 async 管道後, observable 值發生了變化
在預設的變更檢測策略中,Angular 將在 @Input()
資料發生更改或修改時執行變化檢測,使用該 OnPush
時,傳入 @Input()
的值 必須是一個新的參照 才會觸發變化檢測。
JavaScript有兩種資料型別,值型別和參照型別,值型別包括:number、string、boolean、null、undefined,參照型別包括:Object、Arrary、Function,值型別每次賦值都會分配新的空間,而參照型別比如Object,直接修改屬性是參照是不會發生變化的,只有賦一個新的物件才會改變參照。
var a= 1; var b = a; b = 2; console.log(a==b); // false var obj1 = {a:1}; var obj2 = obj1; obj2.a = 2; console.log(obj1); // {a:2} console.log(obj1 === obj2); //true obj2= {...obj1}; console.log(obj1 === obj2); //false
如果 OnPush
元件或其子元件之一觸發事件,例如 click,則將觸發變化檢測(針對元件樹中的所有元件)。
需要注意的是在 OnPush
策略中,以下操作不會觸發變化檢測:
setTimeout()
setInterval()
Promise.resolve().then()
this.http.get('...').subscribe()
有三種手動觸發更改檢測的方法:
**detectChanges(): ** 它會觸發當前元件和子元件的變化檢測
markForCheck(): 它不會觸發變化檢測,但是會把當前的OnPush元件和所以的父元件為OnPush的元件 ** 標記為需要檢測狀態** ,在當前或者下一個變化檢測週期進行檢測
ApplicationRef.tick() : 它會根據元件的變化檢測策略,觸發整個應用程式的更改檢測
可以通過 線上Demo ,更直觀的瞭解這幾種觸發變化檢測的方式
內建的 AsyncPipe
訂閱一個 observable 並返回它發出的最新值。
每次發出新值時的內部 AsyncPipe
呼叫 markForCheck
private _updateLatestValue(async: any, value: Object): void { if (async === this._obj) { this._latestValue = value; this._ref.markForCheck(); } }
剛才我們聊了變化檢測的策略,我們可以使用
OnPush
的策略來優化我們的應用,那麼這就夠了嗎? 在我們實際的開發中還會有很多的場景,我們需要通過一些其他的方式來繼續優化我們的應用。
場景1:
假如我們在實現一個回車搜尋的功能:
@Component({ selector: "app-enter", template: `<input #input type="text" />`, }) export class EnterComponent implements AfterViewInit { @ViewChild("input", { read: ElementRef }) private inputElementRef: any; constructor() {} ngAfterViewInit(): void { this.inputElementRef.nativeElement.addEventListener( "keydown", (event: KeyboardEvent) => { const keyCode = event.which || event.keyCode; if (keyCode === 13) { this.search(); } } ); } search() { // ... } }
大家從上面的範例中可以發現什麼問題呢?
我們知道事件會觸發Angular的變化檢測,在範例中繫結 keydown 事件後,每一次鍵盤輸入都會觸發變化檢測,而這些變化檢測大多數都是多餘的檢測,只有當按鍵為 Enter 時,才需要真正的進行變化檢測。
在這種情況下,我們就可以利用 NgZone.runOutsideAngular()
來減少變化檢測的次數。
@Directive({ selector: '[enter]' }) export class ThyEnterDirective implements OnInit { @Output() enter = new EventEmitter(); constructor(private ngZone: NgZone, private elementRef: ElementRef<HTMLElement>) {} ngOnInit(): void { // 包裹程式碼將執行在Zone區域之外 this.ngZone.runOutsideAngular(() => { this.elementRef.nativeElement.addEventListener('keydown', (event: KeyboardEvent) => { const keyCode = event.which || event.keyCode; if (keyCode === 13) { this.ngZone.run(() => { this.enter.emit(event); }); } }); }); } }
場景2:
假如我們使用 WebSocket 將大量資料從後端推播到前端,則相應的前端元件應僅每 10 秒更新一次。在這種情況下,我們可以通過呼叫 detach()
和手動觸發它來停用更改檢測detectChanges()
:
constructor(private cdr: ChangeDetectorRef) { cdr.detach(); // 停用變化檢測 setInterval(() => { this.cdr.detectChanges(); // 手動觸發變化檢測 }, 10 * 1000); }
當然使用 ngZone.runOutsideAngular()
也可以處理這種場景。
之前我們說了Angular 可以自動幫我們進行變化檢測,這主要是基於Zone.js來實現,那麼很多人潛意識會任務Zone.js 就是 Angular 是一部分,Angular的 應用程式必須基於Zone.js,其實不然,如果我們對應用有極高的效能要求時,我們可以選擇移除 Zone.js,移除Zone.js 將會提升應用的效能和打包的體積,不過帶來的後果就是我們需要主要去呼叫變化檢測。
如何移除 Zone.js?
手動呼叫變化檢測
在 Ivy 之後,我們有一些新的API可以更方便的呼叫變化檢測
**ɵmarkDirty: ** 標記一個元件為 dirty 狀態 (需要重新渲染) 並將在未來某個時間點安排一個變更檢測
ɵdetectChanges: 因為某些效率方面的原因,內部檔案不推薦使用 ɵdetectChanges
而推薦使用 ɵmarkDirty
, ɵdetectChanges
會觸發元件以子元件的變更檢測。
移除後的效能
移除Zone.js後變化檢測由應用自己來控制,極大的減少了不必要的變化檢測次數,同時打包後的提及也減少了 36k
移除前:
移除後:
元件繫結
我們先來看一個元件繫結的例子:
按我們正常開發元件的想法,當看到這個範例的時候一定認為這個Case是Ok的,但是在執行測試後我們發現這個Case失敗了。
在生產環境中,當 Angular 建立一個元件,就會自動進行變更檢測。 但是在測試中,**TestBed.createComponent()**
並不會進行變化檢測,需要我們手動觸發。
修改一下上面的Case:
origin-url0.00KB
origin-url0.00KB
從上面的範例中可以瞭解到,我們必須通過呼叫 fixture.detectChanges()
來告訴 TestBed 執行資料繫結。
如果我們在測試中動態改變了繫結值,同樣也需要呼叫 fixture.detectChanges()
。
it("should update title", () => { component.title = 'Test Title'; fixture.detectChanges(); const h1 = fixture.nativeElement.querySelector("h1"); expect(h1.textContent).toContain('Test Title'); });
自動變更檢測
我們發現寫測試過程中需要頻繁的呼叫 fixture.detectChanges()
,可能會覺得比較繁瑣,那 Angular 可不可以在測試環境中自動執行變化檢測呢?
我們可以通過設定 ComponentFixtureAutoDetect
來實現
TestBed.configureTestingModule({ declarations: [ BannerComponent ], providers: [ { provide: ComponentFixtureAutoDetect, useValue: true } ] });
然後再回頭看看剛才的範例:
上面的範例我們並沒有呼叫 fixture.detectChanges()
,但是測試依然通過了,這是因為我們開啟了自動變化檢測。
再看一個範例:
上面的範例中,我們在測試程式碼中動態修改了 title 的值,測試執行失敗,這是因為 Angular 並不知道測試改變了元件, ComponentFixtureAutoDetect
只對非同步操作進行自動變化檢測,例如 Promise、setTimeout、click 等DOM事件等,如果我們手動更改了繫結值,我們依然還需要呼叫 fixture.detectChanges()
來執行變化檢測。
常見的坑
上面這個範例,繫結值修改後呼叫了 fixture.detectChanges()
, 但是執行測試後仍然報錯,這是為什麼呢?
檢視Angular原始碼後我們發現 ** ngModel 的值是通過非同步更新的** ,執行fixture.detectChanges()
後雖然觸發了變化檢測,但是值還並未修改成功。
修改一下測試:
修改後我們將斷言包裹在了 fixture.whenStable()
中,然後測試通過,那 whenStable()
是什麼呢?
whenStable(): Promise : 當夾具穩定時解析的承諾 當事件已觸發非同步活動或非同步變更檢測後,可用此方法繼續執行測試。
當然除了用 fixture.whenStable()
我們也可以用 tick()
來解決這個問題
tick() :為 fakeAsync Zone 中的計時器模擬非同步時間流逝 在此函數開始時以及執行任何計時器回撥之後,微任務佇列就會耗盡
上面這個範例,我們在修改屬性後呼叫了 fixture.detectChanges()
,但是測試未通過,這是為什麼呢?我們發現這個範例與第一個範例唯一的區別就是這個元件是一個 OnPush
元件,之前我們說過預設變化檢測會跳過 OnPush
元件的,只有在特定的幾種情況下才會觸發變化檢測的,遇到這種情況如何解決呢?
我們可以手動獲取元件的 ChangeDetectorRef
來主動觸發變化檢測。
虛擬DOM與增量DOM
Angular Ivy 是一個新的 Angular 渲染器,它與我們在主流框架中看到的任何東西都截然不同,因為它使用增量 DOM。 增量DOM是什麼呢?它與虛擬Dom有什麼不同呢?
虛擬 DOM
首先說一下虛擬DOM,我們要了解在瀏覽器中,直接操作Dom是十分損耗效能的,而虛擬DOM 的主要概念是將 UI的虛擬表示儲存在記憶體中,通過 Diff 操作對比當前記憶體和上次記憶體中檢視的差異,從而減少不必要的Dom操作,只針對差異的Dom進行更改。
虛擬DOM執行流程:
當 UI 發生變化時,將整個 UI 渲染到 Virtual DOM 中。
計算先前和當前虛擬 DOM 表示之間的差異。
使用更改更新真實的 DOM。
虛擬 DOM 的優點:
高效的 Diff 演演算法。
簡單且有助於提高效能。
沒有 React 也可以使用
足夠輕量
允許構建應用程式且不考慮狀態轉換
增量Dom的主要概念是將元件編譯成一系列的指令,這些指令去建立DOM樹並在資料更改時就地的更新它們。
例如:
@Component({ selector: 'todos-cmp', template: ` <p *ngFor="let t of todos|async"> {{t.description}} </p> ` }) class TodosComponent { todos: Observable<Todo[]> = this.store.pipe(select('todos')); constructor(private store: Store<AppState>) {} }
編譯後:
var TodosComponent = /** @class */ (function () { function TodosComponent(store) { this.store = store; this.todos = this.store.pipe(select('todos')); } TodosComponent.ngComponentDef = defineComponent({ type: TodosComponent, selectors: [["todos-cmp"]], factory: function TodosComponent_Factory(t) { return new (t || TodosComponent)(directiveInject(Store)); }, consts: 2, vars: 3, template: function TodosComponent_Template(rf, ctx) { if (rf & 1) { // create dom pipe(1, "async"); template(0, TodosComponent_p_Template_0, 2, 1, null, _c0); } if (rf & 2) { // update dom elementProperty(0, "ngForOf", bind(pipeBind1(1, 1, ctx.todos))); } }, encapsulation: 2 }); return TodosComponent; }());
增量DOM的優點:
渲染引擎可以被Tree Shakable,降低編譯後的體積
佔用較低的記憶體
為什麼可渲染引擎可以被 Tree Shakable?
Tree Shaking 是指在編譯目的碼時移除上下文中未參照的程式碼 ,增量 DOM 充分利用了這一點,因為它使用了基於指令的方法。正如範例所示,增量 DOM 在編譯之前將每個元件編譯成一組指令,這有助於識別未使用的指令。在 Tree Shakable 過程中,可以將這些未使用的的指令刪除掉。
減少記憶體的使用
與虛擬 DOM 不同,增量 DOM 在重新呈現應用程式 UI 時不會生成真實 DOM 的副本。此外,如果應用程式 UI 沒有變化,增量 DOM 就不會分配任何記憶體。大多數情況下,我們都是在沒有任何重大修改的情況下重新呈現應用程式 UI。因此,按照這種方法可以極大的減少裝置記憶體使用。
至此,Angular 變化檢測相關的內容就介紹完了,這是我在公司內部 2個小時的分享內容,在準備的過程中參考了很多優秀的資料,自己也學習到了更深層,更細節的一些技術點。如果大家有不理解的,歡迎在評論區溝通,如果有需要改正的地方,也歡迎大家指出,希望這篇文章可以幫助大家更好的理解Angular的變化檢測。
更多程式設計相關知識,請存取:!!
以上就是帶你深入聊聊Angular中的變化檢測的詳細內容,更多請關注TW511.COM其它相關文章!