noxi雑記

.NET、Angularまわりの小ネタブログ

デバッグで追いかけるAngularのViewレンダリング

この記事は Angular #2 Advent Calendar 2019 の12日目の記事です。11日目は @kawakami-kazuyoshi さんの Predictive Prefetching、PrefetchとGuess.js、時々、Angular でした。

この記事は普段利用している Angular がどのように View を表示・更新しているか、 ChangeDetectionStrategy の違いはどこにあるのかをソースコードを読みつつ理解してみることを目的としています。ここに書かれていることは筆者がソースコードを読んでみた結果であり、事実や Angular チームの意図から外れている可能性があります。



ソースコードを追う方針

なんとなく Angular の View レンダリング周りのソースコードを眺めてみたいなという気持ちはあるものの、筆者はその辺の構造に無知なのでそもそもコードがどこにあるのか、またどこから読み始めれば良いのが良く分かりません。ですので分からないなら実際に描画するところをデバッグすればいいじゃない、と言う方針の下、ステップイン・アウトを駆使してレンダリングを追っていきます。

環境

この記事で使用している環境の情報です。Angular 9なかなか出ませんね。

Angular CLI: 9.0.0-rc.5
Node: 12.13.1
OS: win32 x64

Angular: 9.0.0-rc.5
... animations, cli, common, compiler, compiler-cli, core, forms
... language-service, platform-browser, platform-browser-dynamic
... router
Ivy Workspace: Yes

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.900.0-rc.5
@angular-devkit/build-angular     0.900.0-rc.5
@angular-devkit/build-optimizer   0.900.0-rc.5
@angular-devkit/build-webpack     0.900.0-rc.5
@angular-devkit/core              9.0.0-rc.5
@angular-devkit/schematics        9.0.0-rc.5
@ngtools/webpack                  9.0.0-rc.5
@schematics/angular               9.0.0-rc.5
@schematics/update                0.900.0-rc.5
rxjs                              6.5.3
typescript                        3.6.4
webpack                           4.41.2

動作確認用のコンポーネント

今回処理を追って行くにあたり、なるべくシンプルなものにするため、プロパティ1個だけをデータバインドしたコンポーネントを作成しました。

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
})
export class AppComponent implements OnInit {

  private _counterText: string = '';
  counter: number = 0;

  get counterText(): string {
    return this._counterText;
  }

  set counterText(value: string) {
    this._counterText = value;
  }

  ngOnInit() {
    this._updateCounter();
    setInterval(() => this._updateCounter(), 1000);
  }

  private _updateCounter() {
    this.counterText = `count: ${this.counter++}`;
  }

}
<main>
  <div>
    <p>{{ counterText }}</p>
  </div>
</main>

ちなみにこれが AOT コンパイルされるとこうなります(出力結果にフォーマッターを適用しています)。

/***/ './src/app/app.component.ts':
/*!**********************************!*\
  !*** ./src/app/app.component.ts ***!
  \**********************************/
/*! exports provided: AppComponent */
/***/
(function(module, __webpack_exports__, __webpack_require__) {

  'use strict';
  __webpack_require__.r(__webpack_exports__);
  /* harmony export (binding) */
  __webpack_require__.d(__webpack_exports__, 'AppComponent', function() {
    return AppComponent;
  });
  /* harmony import */
  var _angular_core__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! @angular/core */ './node_modules/@angular/core/__ivy_ngcc__/fesm2015/core.js');


  class AppComponent {
    constructor() {
      this._counterText = '';
      this.counter = 0;
    }

    get counterText() {
      return this._counterText;
    }

    set counterText(value) {
      this._counterText = value;
    }

    ngOnInit() {
      this._updateCounter();
      setInterval(() => this._updateCounter(), 1000);
    }

    _updateCounter() {
      this.counterText = `count: ${this.counter++}`;
    }
  }

  AppComponent.ɵfac = function AppComponent_Factory(t) {
    return new (t || AppComponent)();
  };
  AppComponent.ɵcmp = _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵdefineComponent']({
    type: AppComponent,
    selectors: [['app-root']],
    decls: 4,
    vars: 1,
    template: function AppComponent_Template(rf, ctx) {
      if (rf & 1) {
        _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementStart'](0, 'main');
        _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementStart'](1, 'div');
        _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementStart'](2, 'p');
        _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵtext'](3);
        _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementEnd']();
        _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementEnd']();
        _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementEnd']();
      }
      if (rf & 2) {
        _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵadvance'](3);
        _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵtextInterpolate'](ctx.counterText);
      }
    },
    styles: [''],
  });
  /*@__PURE__*/
  (function() {
    _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵsetClassMetadata'](AppComponent, [{
      type: _angular_core__WEBPACK_IMPORTED_MODULE_0__['Component'],
      args: [{
        selector: 'app-root',
        templateUrl: './app.component.html',
        styleUrls: ['./app.component.scss'],
      }],
    }], null, null);
  })();
  /***/
}),

とても見やすいですね。 AppComponent クラスの static メソッドとしてファクトリーメソッドとコンポーネント定義のメソッドが生えている様に見えます。Angular 8の構造は見ていないので適当なことを書きますが、Angular 9で entryComponent が非推奨になったのはこの static メソッドに寄るところが大きいのではないでしょうか。

テンプレートの中身

続いて AOT コンパイルされたこのテンプレートを詳しく見てみます。

// rf: RenderFlags(1: Create、2: Update)
// ctx: AppComponentのインスタンス
template: function AppComponent_Template(rf, ctx) {
  if (rf & 1) {
    _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementStart'](0, 'main');
    _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementStart'](1, 'div');
    _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementStart'](2, 'p');
    _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵtext'](3);
    _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementEnd']();
    _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementEnd']();
    _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementEnd']();
  }
  if (rf & 2) {
    _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵadvance'](3);
    _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵtextInterpolate'](ctx.counterText);
  }
},

main > div > p > text > /p > /div > /main と順に要素を入れ子で作成して閉じていく様子が分かります。昔 JavaAndroid) で XmlSerializer を使用して XML を作っていた時の思い出が蘇ります。ソースコード中にコメントを入れてしまいましたが、この関数の上部が DOM を生成する部分、下部が生成された DOM に対してデータバインドをする部分です。
そして気になるのがこの処理のスコープです。 DOM を生成したりデータバインドする先を指定していたり、、、は読み取れますが、何に対してその処理をしているのかが全くここには現れていません。きっと、この関数が実行されるスコープに何か仕掛けがあるのでしょう。。。(PUREなフラグはここには付いていないことですし)

View 作成時の処理

Bootstrap

それではデバッグ実行をしてみます。デバッガーを仕掛ける箇所は先ほどの AppComponent_Template です。 View の処理なんて作成と更新しか無いんだし更新処理はここにあるんだからここを押さえておけば大丈夫だろう、、、、なんていう適当な思いが篭もったブレークポイントです。

f:id:noxi515:20191211180036p:plain
View 作成時のStackTrace

アプリケーション起動時の流れはこうなっていました。

  @angular/core/ApplicationRef#bootstrap
└ @angular/core/ComponentFactory#create (renderer3) ルートコンポーネントの作成
└ @angular/core/renderView              (renderer3) RootView(Angularの最上位View?)を作成モードで描画
└ @angular/core/renderChildComponents   (renderer3) 子コンポーネントの初期レンダリング
└ @angular/core/renderComponent         (renderer3) AppComponentの初期レンダリング
└ @angular/core/renderView              (renderer3) AppComponentのViewを作成モードで描画
└ @angular/core/executeTemplate         (renderer3) テンプレートからDOMの生成
└ AppComponent_Template (ref = 1)

順当にアプリケーションの開始時にアプリケーショントップのコンポーネントが生成されて描画される雰囲気が分かります。ちなみに renderer3 は Angular 9 で導入された Ivy のことです。たぶん。
renderView 関数では最初に enterView を、最後に leaveView という関数を呼んでいます。この辺りのスコープはクラスや関数では無くファイル単位になっており、この2つの関数でファイルスコープの変数を書き換えていました。 AppComponent_Template の処理の先はこやつらによって制御されているのでしょう。

DOM 要素作成

続いて DOM 要素の作成部分、ソースコード上だと _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementStart'](0, 'main') の部分です。
ɵɵelementStart も renderer3 の下に素材する関数です。ちなみに ɵɵ から始まる関数群はコンパイラーがコード生成に使う用だから使ってくれるなよ、書いてあります [CODE_GEN_API]

そしてその処理はというと、まず getLView という関数で処理先の View を取得しています。 LView は「処理中の View の状態を表す配列」で、 enterView 関数で更新されます。そして要素の生成と DOM ツリーへの追加、属性付与、クラスやスタイルの初期設定、 Query の実行をしています。

DOM 要素生成終了

生成した DOM 要素は ɵɵelementEnd で処理が終わります。この関数では ɵɵelementStart では確定しないクラスやスタイルの情報から @Input に流す処理が行われています。

初期データバインド

要素の生成が終わると、続いて初期データバインドが始まります。まずはスタックトレーズを見てみます。

f:id:noxi515:20191211210152p:plain
初期データバインド時のStackTrace

  @angular/core/ApplicationRef#bootstrap
└ @angular/core/ApplicationRef#_loadComponents        ViewとApplicationRefの接続
└ @angular/core/ApplicationRef#tick                   変更検知の実行
└ @angular/core/RootViewRef#detectChanges (renderer3) RootViewの変更検知実行
└ @angular/core/detectChangesInRootView   (renderer3) RootViewの変更検知実行
└ @angular/core/tickRootContext           (renderer3) RootContextに所属するコンポーネントの変更検知実行
└ @angular/core/renderComponentOrTemplate (renderer3) コンポーネントのレンダリング
└ @angular/core/refreshView               (renderer3) RootViewを更新モードで描画
└ @angular/core/refreshChildComponents    (renderer3) 子コンポーネントを更新モードで更新
└ @angular/core/refreshComponent          (renderer3) AppComponentを更新モードで更新
└ @angular/core/refreshView               (renderer3) AppComponentを更新モードで描画
└ @angular/core/executeTemplate           (renderer3) 
└ AppComponent_Template (ref = 2)

流れは要素を生成するときと大きく違いが無いように見えます。通るルートが違うため、 AppComponent_Templateref = 2 で実行されます。

データバインド先の指定

データバインド時は要素の生成の時とは異なり、データバインド先の指定 → データバインド の流れになっています。

データバインド先を指定するのは ɵɵadvance です。 この関数は対象の状態をチェックしてデータバインド先の Index をスコープに設定します。

データバインドの実行

文字列のデータバインドを実行していた関数は ɵɵtextInterpolate でした。文字列はこの関数が使用されますが、他にも xxx_interpolation が存在するので、対象に応じてバインドに使用するロジックが変化するのでしょう。

この関数は View にバインドした前回の値と比較し、差分がある場合のみ DOM へ反映します。前回バインドした値は View の状態である LView で保持されています。

// https://github.com/angular/angular/blob/9.0.0-rc.5/packages/core/src/render3/instructions/text_interpolation.ts#L60
/**
 *
 * Update text content with single bound value surrounded by other text.
 *
 * Used when a text node has 1 interpolated value in it:
 *
 * ```html
 * <div>prefix{{v0}}suffix</div>
 * ```
 *
 * Its compiled representation is:
 *
 * ```ts
 * ɵɵtextInterpolate1('prefix', v0, 'suffix');
 * ```
 * @returns itself, so that it may be chained.
 * @see textInterpolateV
 * @codeGenApi
 */
export function ɵɵtextInterpolate1(prefix: string, v0: any, suffix: string): TsickleIssue1009 {
  const lView = getLView();
  const interpolated = interpolation1(lView, prefix, v0, suffix);
  if (interpolated !== NO_CHANGE) {
    textBindingInternal(lView, getSelectedIndex(), interpolated as string);
  }
  return ɵɵtextInterpolate1;
}

// https://github.com/angular/angular/blob/848018f5d31e599996f1bd7d4d9138c35b4350cd/packages/core/src/render3/instructions/interpolation.ts#L62
/**
 * Creates an interpolation binding with 1 expression.
 *
 * @param prefix static value used for concatenation only.
 * @param v0 value checked for change.
 * @param suffix static value used for concatenation only.
 */
export function interpolation1(lView: LView, prefix: string, v0: any, suffix: string): string|
    NO_CHANGE {
  const different = bindingUpdated(lView, nextBindingIndex(), v0);
  return different ? prefix + renderStringify(v0) + suffix : NO_CHANGE;
}

余談ですが textInterpolate1 は1から8、そして可変長のVの9種類が存在します。数字の分だけ引数の文字列の数が増えていき、同じ TextNode に対して複数のバインド箇所があった場合に対応するものが使用されるようです。

コンポーネントのプロパティ変更時の処理

Angular は(変更検知の仕組みの一部として??) Zone.js を採用しています。 Zone.js は setTimeout など非同期操作になり得る処理にパッチを当て、非同期操作(コールバック)を検出しています。 Angular は Zone.js で検出された非同期コールバック(例えばAJAXのレスポンスを受け取るコールバック)の実行が全て終わるのを待機し、全てが完了した後に変更検知を実行することで、効率的なデータバインドを実現しています。
※個人の見解です

では、StackTraceを見ていきましょう。長すぎて収まっていない StackTrace 。

f:id:noxi515:20191211225705p:plain
変更検知実行時のStackTrace

Zone#onMicrotaskEmpty#next                            非同期コールバックの実行処理が全て完了
└ Zone#run                                            Zone.jsのスコープ内で処理を実行
└ @angular/core/ApplicationRef#tick                   変更検知の実行
└ @angular/core/RootViewRef#detectChanges (renderer3) RootViewの変更検知実行
└ @angular/core/detectChangesInRootView   (renderer3) RootViewの変更検知実行
└ @angular/core/tickRootContext           (renderer3) RootContextに所属するコンポーネントの変更検知実行
└ @angular/core/renderComponentOrTemplate (renderer3) コンポーネントのレンダリング
└ @angular/core/refreshView               (renderer3) RootViewを更新モードで描画
└ @angular/core/refreshChildComponents    (renderer3) 子コンポーネントを更新モードで更新
└ @angular/core/refreshComponent          (renderer3) AppComponentを更新モードで更新
└ @angular/core/refreshView               (renderer3) AppComponentを更新モードで描画
└ @angular/core/executeTemplate           (renderer3) 
└ AppComponent_Template (ref = 2)

この StackTrace は AppComponent の OnInit で実行している setInterval のコールバックが実行されたことで発火しているように見えます。 Zone#onMicrotaskEmpty は ApplicationRef のコンストラクターで購読されており、非同期処理のコールバックが全て実行された後に変更検知を実行していることが窺えました。変更検知は ApplicarionRef#tick から実行されていますので初期表示の View に対するデータバインドの流れとそこからは全く同一のようでした。

この変更検知は Zone.js の内側で生成されたコールバック全てが対象のため、何気なく mousemove などの大量に発火するイベントを購読するとすぐにパフォーマンスに影響が出そうな雰囲気を感じます。

ChangeDetection = OnPush の変更検知

最後に変更検知を自分で管理する OnPush モードの場合は処理がどのように変化するのかを眺めてみます。

OnPush時のAOTビルド結果

まずは AppComponent の変更検知モードを OnPush に変更してビルドして差分を取得してみます。以下は OnPush 時の AppComponent の抜粋です。

AppComponent.ɵcmp = _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵdefineComponent']({
  type: AppComponent,
  selectors: [['app-root']],
  decls: 4,
  vars: 1,
  template: function AppComponent_Template(rf, ctx) {
    if (rf & 1) {
      _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementStart'](0, 'main');
      _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementStart'](1, 'div');
      _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementStart'](2, 'p');
      _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵtext'](3);
      _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementEnd']();
      _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementEnd']();
      _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵelementEnd']();
    }
    if (rf & 2) {
      _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵadvance'](3);
      _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵɵtextInterpolate'](ctx.counterText);
    }
  },
  styles: [''],
  changeDetection: 0,    // ココ
});
/*@__PURE__*/
(function() {
  _angular_core__WEBPACK_IMPORTED_MODULE_0__['ɵsetClassMetadata'](AppComponent, [{
    type: _angular_core__WEBPACK_IMPORTED_MODULE_0__['Component'],
    args: [{
      selector: 'app-root',
      templateUrl: './app.component.html',
      styleUrls: ['./app.component.scss'],
      changeDetection: _angular_core__WEBPACK_IMPORTED_MODULE_0__['ChangeDetectionStrategy'].OnPush,    // ココ
    }],
  }], null, null);
})();

変更検知モードを OnPush に変更しても、ビルド結果はフラグが設定された事を除けば変化しませんでした。テンプレートを描画する処理自体は同一のため、先のコールバック後の変更検知処理のどこかで OnPush のフラグをチェックしていると考えられます。

ChangeDetectorでチェック対象フラグを設定する

続いて、AppComponent 内でプロパティを変更する際にきちんとチェックフラグを立ててみます。AppComponent のソースコードはこのように変わりました。

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit {

  private _counterText: string = '';
  counter: number = 0;

  get counterText(): string {
    return this._counterText;
  }

  set counterText(value: string) {
    this._counterText = value;
  }

  constructor(private readonly _changeDetectorRef: ChangeDetectorRef) {
  }

  ngOnInit() {
    this._updateCounter();
    setInterval(() => this._updateCounter(), 1000);
  }

  private _updateCounter() {
    this.counterText = `count: ${this.counter++}`;
    this._changeDetectorRef.markForCheck();
  }

}

ChangeDetectorRef#markForCheck を呼び出すことで、正常に変更検知が実行されます。

ChangeDetectorの内部処理

ところで ChangeDetectorRef とは何者なのでしょうか。
こいつの実体は ViewRef で、 markForCheck の内部処理としては markViewDirty 関数で View に対して dirty フラグを立てているだけでした。つまり、変更検知のどこかで View が Dirty かどうかをチェックしている箇所があるはずです。
なお markViewDirty は呼び出した View から親方向の全ての View に対して dirty フラグを設定しています。ですのでコンポーネントツリーのかなり深いところにいるコンポーネントで markForCheck を呼び出しても、次の変更検知のタイミングで必ず該当のコンポーネントまで更新されるのでしょう。

OnPush時の処理の別れどころ

View が Dirty かどうかをどこでチェックしているのでしょうか。処理を追っていくと、以外と後の方、 refreshComponent で行っていることが分かりました。

Zone#onMicrotaskEmpty#next
└ Zone#run
└ @angular/core/ApplicationRef#tick
└ @angular/core/RootViewRef#detectChanges (renderer3)
└ @angular/core/detectChangesInRootView   (renderer3)
└ @angular/core/tickRootContext           (renderer3)
└ @angular/core/renderComponentOrTemplate (renderer3)
└ @angular/core/refreshView               (renderer3)
└ @angular/core/refreshChildComponents    (renderer3)
└ @angular/core/refreshComponent          (renderer3)  ココでViewがDirtyかどうかチェック
└ @angular/core/refreshView               (renderer3)
└ @angular/core/executeTemplate           (renderer3) 
└ AppComponent_Template (ref = 2)

親子関係にあるコンポーネントの更新処理は refreshViewrefreshChildComponentsrefreshComponentrefreshView となっています。親子関係時の処理を今回は追っていないためこれは推測となりますが、変更検知モードを OnPush のものとそうで無い物を混ぜて開発するとき、OnPush ではないコンポーネントの親方向のどこかに OnPush のコンポーネントがいた場合はその OnPush ではないコンポーネントの更新処理に影響がありそうだなと感じます。

、、、いや、混ぜる方がいけないんですけど、いるじゃないですか。ライブラリーのコンポーネントで OnPush じゃなくてちゃんと描画が更新されないヤツ。ようやくきちんと理解出来た気がします。

終わりに

特に結論もオチも何も無い記事ですが、いかがでしたでしょうか。こう言う機会でも無い限りめったに描画パイプラインの処理を追うなんて事はしないと思うので、追ってみたそのままを記事にしてみました。なんで LocalState はクラスやオブジェクトじゃなくて配列なんでしょうね。私気になります。
今回はコンポーネントに親子関係を持たせたときの @Input @Output の処理など全然追えていないので、遠くないうちにそちらも追えればなあと。

明日は k3nNy_51rcy さんです。よろしくお願いします。