深入淺出 Angular 變更檢測

Zuckjet發表於2022-02-05

Angular 中的變更檢測是一種用來將應用程式 UI 的狀態與資料的狀態同步的機制。當應用邏輯更改元件資料時,繫結到檢視中 DOM 屬性上的值也要隨之更改。變更檢測器負責更新檢視以反映當前的資料模型。閱讀本文之前,建議先檢視我的前兩篇和變更檢測緊密相關的博文,即《 揭祕Angular 生命週期函式》 和 《 Angular 之 zone.js 介紹》。

紙上得來終覺淺,絕知此事要躬行。為了讓讀者朋友們更容易理解,本文先從一個小的示例入手,然後逐步展開。示例如下:

// app.component.ts
import { Component } from '@angular/core';
@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'aa';

  handleClick() {
    this.title = 'bb';
  }
}
// app.componnet.html
<div (click)="handleClick()">{{title}}</div>

示例比較簡單,就是給div元素繫結了一個點選事件,點選該元素就會改變變數title的值,介面的顯示也會隨之更新。框架如何知道什麼時候需要更新檢視,以及如何更新檢視的呢?我們來一探究竟。

當我們點選div元素時,handleClick函式會被執行。那麼在 Angular 應用中該函式是如何被觸發執行的呢?如果你看過我之前的關於zone.js介紹的文章就會知道,Angular 應用中點選事件已經被zone.js接管。基於此答案便顯而易見,最開始肯定是被zone.js觸發執行,但在這裡我還們還要進一步分析直接呼叫關係進而層層展開。最靠近handleClick函式呼叫的是下面的程式碼:

function wrapListener(listenerFn, ...) {
    return function wrapListenerIn_markDirtyAndPreventDefault(e) {
      let result = executeListenerWithErrorHandling(listenerFn, ...);
    }
}

上述程式碼中listenerFn函式指向的便是handleClick,但它又是wrapListener函式的引數。示例中元素繫結點選事件,相關模板編譯產物大概是這樣:

function AppComponent_Template(rf, ctx) {
  ......
  i0["ɵɵlistener"]("click", function AppComponent_Template_div_click_0_listener() {
    return ctx.handleClick();
  })
}

初次載入應用會依次執行renderView、然後執行executeTemplate,接著便觸發了上述的模板函式,就這樣元素的點選函式便一路傳遞到了listenerFn引數。到這裡我們瞭解了,點選函式的觸發源頭是zone.js,真實的點選函式傳遞卻是由 Angular 實現,那麼zone.js和 Angular 是如何關聯的呢?zone.js會為每個非同步事件安排一個任務,結合本文示例來說,invokeTask便是由下面程式碼呼叫:

function forkInnerZoneWithAngularBehavior(zone) {
  zone._inner = zone._inner.fork({
    name: 'angular',
    properties: { 'isAngularZone': true },
    onInvokeTask: (delegate, current, target, task, applyThis, applyArgs) => {
      try {
        onEnter(zone);
        return delegate.invokeTask(target, task, ...);
      }
      finally {
        onLeave(zone);
      }
    }
  })
}

看到這裡是不是就很熟悉了,因為在之前的zone.js介紹的文章裡,便有類似的程式碼片段。而forkInnerZoneWithAngularBehavior函式又是由類 NgZone 的建構函式呼叫。至此我們引出了 Angular 變更檢測的一個主角 NgZone,它是對zone.js的一個簡單封裝。

現在我們知道示例中點選函式是如何被執行的,那麼函式執行了以後應用資料有變化了,檢視又是如何及時更新的呢?我們還是回到上面提到的forkInnerZoneWithAngularBehavior函式中,try finally語句塊中,執行了invokeTask函式最終還會執行onLeave(zone)函式。再往下分析就能看到onLeave函式最終呼叫了checkStable函式:

function checkStable(zone) {
  zone.onMicrotaskEmpty.emit(null);
}

相應地在類ApplicationRef建構函式中訂閱了這個emit事件:

class ApplicationRef {
    /** @internal */
    constructor() {
    this._zone.onMicrotaskEmpty.subscribe({
            next: () => {
                this._zone.run(() => {
                    this.tick();
                });
            }
        });
    }

在訂閱相關回撥函式中,this.tick()是不是很眼熟呢?如果你看了我之前的關於 Angular 生命週期函式的文章,那麼你肯定還會有印象,它是觸發檢視更新的關鍵呼叫。雖然在那篇生命週期介紹的文章中有講過這個函式,但本文的重點是變更檢測因此函式雖然相同但側重點略有變化。this.tick相關呼叫順序大概是這樣:

this.tick() -> 
view.detectChanges() -> 
renderComponentOrTemplate() ->
refreshView()

這裡refreshView比較重要單獨拿出來分析一下:

function refreshView(tView, lView, templateFn, context) {
  ......
  if (templateFn !== null) {
    // 關鍵程式碼1
    executeTemplate(tView, lView, templateFn, ...);
  }
  ......
  if (components !== null) {
    // 關鍵程式碼2
    refreshChildComponents(lView, components);
  }
}

這個過程中refreshView函式會被呼叫二次,第一次進入的是關鍵程式碼2分支,然後依次呼叫如下函式重新進入refreshView函式:

refreshChildComponents() -> 
refreshChildComponents() ->
refreshComponent() ->
refreshView()

第二次進入refreshView函式呼叫的便是關鍵程式碼1分支了,即執行的是:executeTemplate函式。而該函式最終執行的是模板編譯產物中的AppComponent_Template函式:

function AppComponent_Template(rf, ctx) {
  if (rf & 1) { // 條件分支1
    i0["ɵɵelementStart"](0, "div", 0);
    i0["ɵɵlistener"]("click", function AppComponent_Template_div_click_0_listener() {
      return ctx.handleClick();
    });
    i0["ɵɵtext"](1);
    i0["ɵɵelementEnd"]();
  } 
  if (rf & 2) { // 條件分支2
    i0["ɵɵadvance"](1);
    i0["ɵɵtextInterpolate"](ctx.title);
  }

如果還有讀者不清楚上述模板編譯產物中的函式是怎麼來的,建議閱讀之前關於依賴注入原理講解的文章,因篇幅限制不再贅述。此時AppComponent_Template函式執行的是條件分支2裡的程式碼,ɵɵadvance函式作用是更新相關的索引值,以保證找到正確的元素。這裡重點講講ɵɵtextInterpolate函式,它最終呼叫的是函式ɵɵtextInterpolate1:

function ɵɵtextInterpolate1(prefix, v0, suffix) {
    const lView = getLView();
    // 關鍵程式碼1
    const interpolated = interpolation1(lView, prefix, v0, suffix);
    if (interpolated !== NO_CHANGE) {
        // 關鍵程式碼2
        textBindingInternal(lView, getSelectedIndex(), interpolated);
    }
    return ɵɵtextInterpolate1;
}

值得指出的是,該函式名末尾是數字1,這是因為還有類似的ɵɵtextInterpolate2ɵɵtextInterpolate3等等,Angular 內部根據插值表示式的數量呼叫不同的專用函式,本文示例中文字節點的插值表示式數量為1,因此實際呼叫的是ɵɵtextInterpolate1函式。該函式主要做了兩件事,關鍵程式碼1作用是比較插值表示式值有沒有更新,關鍵程式碼2則是更新文字節點的值。先來看看關鍵程式碼1的函式interpolation1,它最終呼叫的是:

function bindingUpdated(lView, bindingIndex, value) {
    const oldValue = lView[bindingIndex];
    if (Object.is(oldValue, value)) {
        return false;
    }
    else {
        lView[bindingIndex] = value;
        return true;
    }
}

變更檢測前的文字節點值稱之為oldValue, 該值儲存在lView中,lView我在之前的文章中也提到過,忘記了的讀者可以去看看lView的作用。bindingUpdated首先會比較新值和舊值,比較的方法便是Object.is。如果新值舊值沒有變化,則返回false。如果有變化,則更新lView中儲存的值,並返回true。關鍵程式碼2的函式textBindingInternal最終呼叫的是下述函式:

function updateTextNode(renderer, rNode, value) {
    ngDevMode && ngDevMode.rendererSetText++;
    isProceduralRenderer(renderer) ? renderer.setValue(rNode, value) : rNode.textContent = value;
}

走完上述流程,我們點選div元素時,介面顯示內容便會由aa變為bb,即完成了從應用資料的變更到 UI 狀態的同步更新,這便是 Angular 最基本的變更檢測過程了。

因篇幅限制,本文所舉示例比較簡單,但 Angular 的變更檢測還有很多沒有講到。比如,如果應用是由若干個元件組成的,父子元件間的變更檢測如何進行,以及如何通過策略優化變更檢測等等。如果有對這方面感興趣的朋友,歡迎關注我的個人公眾號【朱玉潔的部落格】,後續將在那裡分享更多前端知識。

相關文章