🅰️

Control FlowとViewレンダリング

2023/12/13に公開

はじめに

2023年11月に Angular 17 がリリースされました。 Signal からプレビューが外れたり、 Control Flow がプレビューで導入されたりと、魅力的な新機能が盛りだくさんですね。
本記事はそんな Angular 17 で追加された Control Flow を深掘りし、 Control Flow がコンパイル後にどのようなコードになりどのように View をレンダリングしているかを眺めてみるだけです。

TL;DR

Control Flow とは従来の NgIf NgFor NgSwitch 3種の組み込みの構造化ディレクティブをコンパイラーレベルで最適化したものです。それぞれに対応した ɵɵconditionalɵɵrepeater などの Control Flow 用 Ivy View 関数が新設され、組み込み構造化ディレクティブの代わりにこれらを使用することで構造化ディレクティブを使用した時のオーバーヘッドなどが解消されています。

環境情報・前提条件

冒頭にも記載の通り、本記事ではコンパイル後の Angular の View テンプレートを眺めます。
本記事は以下の環境にて検証を実施しています。また特筆の無い限りコンパイル後のコードに含まれるコメントは筆者が追加したものです。

Angular CLI: 17.0.6
Node: 20.10.0
Package Manager: npm 10.2.3
OS: win32 x64

Angular: 17.0.6
... animations, cli, common, compiler, compiler-cli, core, forms
... platform-browser, platform-browser-dynamic, router

Package                         Version
---------------------------------------------------------
@angular-devkit/architect       0.1700.6
@angular-devkit/build-angular   17.0.6
@angular-devkit/core            17.0.6
@angular-devkit/schematics      17.0.6
@schematics/angular             17.0.6
rxjs                            7.8.1
typescript                      5.2.2
zone.js                         0.14.2

Control Flow のコンパイル後のコードを眺めてみよう

Angular View テンプレートと Incremental DOM

Control Flow のコンパイル後のコードを眺めるにしても、そもそも普段実装している Angular の View テンプレートがコンパイル後にどうなっているのかをご存じ無い方の方が多いのかなと思います。ですので最初に簡単な Angular の View テンプレートのコンパイル結果を記載します。

こちらはとてもシンプルなコンパイル前のコンポーネントです。

@Component({
  template: `
    <div>
      <span>sample</span>
      <span>{{ text }}</span>
    </div>
  `,
})
export class AppComponent {
  protected text = 'sample'
}

これをコンパイルするとこのようになります。 View を生成している箇所のみ抜粋しています。

function AppComponent_Template(rf, ctx) {
  if (rf & 1 /* RenderFlags.Create */ ) { // DOMを生成するモード
    ɵɵelementStart(0, "div")(1, "span");  // div(0)とspan(1)の開始タグを生成する
    ɵɵtext(2, "sample");                  // sampleというTextNode(2)を生成する
    ɵɵelementEnd();                       // span(1)の終了タグを生成する
    ɵɵelementStart(3, "span");            // span(3)の開始タグを生成する
    ɵɵtext(4);                            // 空のTextNode(4)を生成する
    ɵɵelementEnd()();                     // span(3)とdiv(0)の終了タグを生成する
  }
  if (rf & 2 /* RenderFlags.Update */ ) { // 値を更新(バインド)するモード
    ɵɵadvance(4);                         // 指定のIndexだけ現在の要素の指定を進める
    ɵɵtextInterpolate(ctx.text);          // TextNode(4)に対して文字列をバインドする
  }
}

Angular のレンダリングは React のような仮想 DOM 方式では無く Incremental DOM という方式で行われており、 DOM 生成 ⇒ 値のバインド、という2つのステップから構成されます。 Incremental DOM は仮想 DOM と比較すると DOM ツリーへの増分更新のためのメモリ割り当てと GC スラッシングが大幅に削減されるため、場合によってはパフォーマンスが大幅に向上する、そうです[1]
なお ɵɵ から始まる関数は Angular のコンパイラーによって生成されるコードに使用される内部 API となるため、これらの API を私達が直接使用することはあり得ません。

@if

まずは一番シンプルそうな @if から始めましょう。

app.component.ts
@Component({
  template: `
    <div>
      @if (condition === true)       { <span>true</span> }
      @else if (condition === false) { <span>false</span> }
      @else if (condition === null)  { <span>null</span> }
      @else                          { <span>undefined</span> }
    </div>
  `,
})
export class AppComponent {
  protected condition?: boolean | null = false;
}
main.js
// AppComponentのViewテンプレート
function AppComponent_Template(rf, ctx) {
  if (rf & 1 /* RenderFlags.Create */ ) {
    ɵɵelementStart(0, "div");
    ɵɵtemplate(1, AppComponent_Conditional_1_Template, 2, 0, "span")
              (2, AppComponent_Conditional_2_Template, 2, 0)
              (3, AppComponent_Conditional_3_Template, 2, 0)
              (4, AppComponent_Conditional_4_Template, 2, 0);
    ɵɵelementEnd();
  }
  if (rf & 2 /* RenderFlags.Update */ ) {
    ɵɵadvance(1);
    ɵɵconditional(1, ctx.condition === true  ? 1 :
                     ctx.condition === false ? 2 :
                     ctx.condition === null  ? 3 :
                     4);
  }
}

// @if (condition === true) 時にレンダリングされるテンプレート
function AppComponent_Conditional_1_Template(rf, ctx) { /* 省略 */ }

// @else if (condition === false) 時にレンダリングされるテンプレート
function AppComponent_Conditional_2_Template(rf, ctx) { /* 省略 */ }

// @else if (condition === null) 時にレンダリングされるテンプレート
function AppComponent_Conditional_3_Template(rf, ctx) { /* 省略 */ }

// @else 時にレンダリングされるテンプレート
function AppComponent_Conditional_4_Template(rf, ctx) { /* 省略 */ }

とても分かりやすくて良いですね。 DOM 生成のタイミングで if 条件に応じた全てのテンプレートを登録し、値バインドのタイミングで ɵɵconditional を使用してどのテンプレートでレンダリングするかを選択しています。 ɵɵconditional は Control Flow のために実装された新規の関数で、 ifswitch に対して使用されます。

デバッグで止めて if 内側の DOM 生成時スタックトレースを並べてると以下の違いとなりました。

@if                                 | NgIf
------------------------------------|--------------------------------------
AppComponent_Conditional_2_Template | AppComponent_ng_template_2_Template
executeTemplate                     | executeTemplate
renderView                          | renderView
createAndRenderEmbeddedLView        | createAndRenderEmbeddedLView
                                    | TemplateRef#createEmbeddedViewImpl
                                    | ViewContainer#createEmbeddedView
                                    | NgIf#_updateView
                                    | set NgIf#ngIfElse
                                    | writeToDirectiveInput
                                    | setInputsForProperty
                                    | elementPropertyInternal
ɵɵconditional                       | ɵɵproperty
AppComponent_Template               | AppComponent_Template

最終的にやっていることは両者とも同じに見えますが、そこに至る過程が異なりますね。
@ifNgIf と比較してかなりシンプルにレンダリングされていることが分かります。 @ifɵɵconditional から直接 View のレンダリングを実行しているのに対して NgIf はディレクティブにプロパティのバインド ⇒ View 更新処理 ⇒ View のレンダリングと段階を踏んでいます。

参考: NgIf のコンパイル後コード
app.component.ts
@Component({
  template: `
    <div>
      <span *ngIf="condition === true; else condition_else">true</span>
      <ng-template #condition_else>
        <span>else</span>
      </ng-template>
    </div>
  `,
})
export class AppComponent {
  protected condition?: boolean | null = false;
}
main.js
// AppComponentのViewテンプレート
function AppComponent_Template(rf, ctx) {
  if (rf & 1 /* RenderFlags.Create */ ) {
    ɵɵelementStart(0, "div");
    ɵɵtemplate(1, AppComponent_span_1_Template, 2, 0, "span", 0)
              (2, AppComponent_ng_template_2_Template, 2, 0, "ng-template", null, 1, ɵɵtemplateRefExtractor);
    ɵɵelementEnd();
  }
  if (rf & 2 /* RenderFlags.Update */ ) {
    const _r2 = ɵɵreference(3);
    ɵɵadvance(1);
    ɵɵproperty("ngIf", ctx.condition === true)
              ("ngIfElse", _r2);
  }
}

// NgIfがtrue時にレンダリングされるテンプレート
function AppComponent_span_1_Template(rf, ctx) { /* 省略 */ }

// NgIfがfalse時にレンダリングされるテンプレート
function AppComponent_ng_template_2_Template(rf, ctx) { /* 省略 */ }

@switch

続いて @switch です。

app.component.ts
@Component({
  template: `
    <div>
      @switch (condition) {
        @case (true)  { <span>true</span> }
        @case (false) { <span>false</span> }
        @case (null)  { <span>null</span> }
        @default      { <span>undefined</span> }
      }
    </div>
  `,
})
export class AppComponent {
  protected condition?: boolean | null = false;
}
main.js
// AppComponentのViewテンプレート
function AppComponent_Template(rf, ctx) {
  if (rf & 1 /* RenderFlags.Create */ ) {
    ɵɵelementStart(0, "div");
    ɵɵtemplate(1, AppComponent_Case_1_Template, 2, 0)
              (2, AppComponent_Case_2_Template, 2, 0)
              (3, AppComponent_Case_3_Template, 2, 0)
              (4, AppComponent_Case_4_Template, 2, 0);
    ɵɵelementEnd();
  }
  if (rf & 2 /* RenderFlags.Update */ ) {
    let AppComponent_contFlowTmp;
    ɵɵadvance(1);
    ɵɵconditional(1, (AppComponent_contFlowTmp = ctx.condition) === true  ? 1 :
                     AppComponent_contFlowTmp                   === false ? 2 :
                     AppComponent_contFlowTmp                   === null  ? 3 :
                     4);
  }
}

// @case (true) 時にレンダリングされるテンプレート
function AppComponent_Case_1_Template(rf, ctx) { /* 省略 */ }

// @case (false) 時にレンダリングされるテンプレート
function AppComponent_Case_2_Template(rf, ctx) { /* 省略 */ }

// @case (null) 時にレンダリングされるテンプレート
function AppComponent_Case_3_Template(rf, ctx) { /* 省略 */ }

// @default 時にレンダリングされるテンプレート
function AppComponent_Case_4_Template(rf, ctx) { /* 省略 */ }

@switch はコンパイルされると評価対象の値を一旦変数に代入していますがそれ以外は @if と同じようです。 NgSwitchNgSwitchCaseNgSwitchDefault それぞれに対して個別にバインドされている点と比較すると、一手でバインド実行できる @switch はかなりパフォーマンスが良さそうです。

参考: NgSwitch のコンパイル後コード
app.component.ts
@Component({
  template: `
    <div>
      <ng-container [ngSwitch]="condition">
        <span *ngSwitchCase="true">true</span>
        <span *ngSwitchCase="false">false</span>
        <span *ngSwitchCase="null">null</span>
        <span *ngSwitchDefault>undefined</span>
      </ng-container>
    </div>
  `,
})
export class AppComponent {
  protected condition?: boolean | null = false;
}
main.js
// AppComponentのViewテンプレート
function AppComponent_Template(rf, ctx) {
  if (rf & 1 /* RenderFlags.Create */ ) {
    ɵɵelementStart(0, "div");
    ɵɵelementContainerStart(1, 0);
    ɵɵtemplate(2, AppComponent_span_2_Template, 2, 0, "span", 1)
              (3, AppComponent_span_3_Template, 2, 0, "span", 1)
              (4, AppComponent_span_4_Template, 2, 0, "span", 1)
              (5, AppComponent_span_5_Template, 2, 0, "span", 2);
    ɵɵelementContainerEnd();
    ɵɵelementEnd();
  }
  if (rf & 2 /* RenderFlags.Update */ ) {
    ɵɵadvance(1);
    ɵɵproperty("ngSwitch", ctx.condition);
    ɵɵadvance(1);
    ɵɵproperty("ngSwitchCase", true);
    ɵɵadvance(1);
    ɵɵproperty("ngSwitchCase", false);
    ɵɵadvance(1);
    ɵɵproperty("ngSwitchCase", null);
  }
}

// NgSwitchCase = true時にレンダリングされるテンプレート
function AppComponent_span_2_Template(rf, ctx) { /* 省略 */ }

// NgSwitchCase = false時にレンダリングされるテンプレート
function AppComponent_span_3_Template(rf, ctx) { /* 省略 */ }

// NgSwitchCase = null時にレンダリングされるテンプレート
function AppComponent_span_4_Template(rf, ctx) { /* 省略 */ }

// NgSwitchDefault時にレンダリングされるテンプレート
function AppComponent_span_5_Template(rf, ctx) { /* 省略 */ }

@for

最後に @for です。

app.component.ts
@Component({
  template: `
    <div>
      @for (item of items; track item.id; let index = $index) {
        <span>{{ index + ':' + item.value }}</span>
      }
      @empty {
        <span>empty</span>
      }
    </div>
  `,
})
export class AppComponent {
  protected items = [
    { id: 1, value: '1' },
    { id: 2, value: '2' }
  ];
}
main.js
var _forTrack0 = ($index, $item) => $item.id;

// AppComponentのViewテンプレート
function AppComponent_Template(rf, ctx) {
  if (rf & 1 /* RenderFlags.Create */ ) {
    ɵɵelementStart(0, "div");
    ɵɵrepeaterCreate(1, AppComponent_For_2_Template, 2, 1, "span", null,
      _forTrack0, false, AppComponent_ForEmpty_3_Template, 2, 0);
    ɵɵelementEnd();
  }
  if (rf & 2 /* RenderFlags.Update */ ) {
    ɵɵadvance(1);
    ɵɵrepeater(ctx.items);
  }
}

// ループでレンダリングされるテンプレート
function AppComponent_For_2_Template(rf, ctx) {
  if (rf & 1 /* RenderFlags.Create */ ) {
    ɵɵelementStart(0, "span");
    ɵɵtext(1);
    ɵɵelementEnd();
  }
  if (rf & 2 /* RenderFlags.Update */ ) {
    const item_r2 = ctx.$implicit;
    const index_r3 = ctx.$index;
    ɵɵadvance(1);
    ɵɵtextInterpolate(index_r3 + ":" + item_r2.value);
  }
}

// コレクションの要素が空の場合にレンダリングされるテンプレート
function AppComponent_ForEmpty_3_Template(rf, ctx) { /* 省略 */ }

@for は DOM 生成時に ɵɵrepeaterCreate 関数でループ内部のテンプレート、要素が空の場合のテンプレート、 trackBy の関数を登録し、バインド時には ɵɵrepeater 関数で DOM を表示するための配列をバインドしています。 ɵɵrepeaterCreate はループ処理に使用するメタデータの初期化を行い、 ɵɵrepeater はコレクションの差分更新が実行しているようです。 @for では差分更新のロジックは reconcil 関数に実装されており、従来の NgFor で使用されていた変更検知とは別のロジックとなっています。

https://github.com/angular/angular/blob/17.0.6/packages/core/src/render3/list_reconciliation.ts#L63-L246

参考: NgFor のコンパイル後コード
app.component.ts
@Component({
  template: `
    <div>
      <span 
        *ngFor="let item of items; trackBy: trackByItem; let index = index"
      >{{ index + ':' + item.value }}</span>
    </div>
  `,
})
export class AppComponent {
  protected items = [
    { id: 1, value: '1' },
    { id: 2, value: '2' }
  ];

  protected trackByItem: TrackByFunction<{ id: number, value: string }> = (index, item) => item.id;
}

main.js
// AppComponentのViewテンプレート
function AppComponent_Template(rf, ctx) {
  if (rf & 1 /* RenderFlags.Create */ ) {
    ɵɵelementStart(0, "div");
    ɵɵtemplate(1, AppComponent_span_1_Template, 2, 1, "span", 0);
    ɵɵelementEnd();
  }
  if (rf & 2 /* RenderFlags.Update */ ) {
    ɵɵadvance(1);
    ɵɵproperty("ngForOf", ctx.items)
              ("ngForTrackBy", ctx.trackByItem);
  }
}

// ループでレンダリングされるテンプレート
function AppComponent_span_1_Template(rf, ctx) {
  if (rf & 1 /* RenderFlags.Create */ ) {
    ɵɵelementStart(0, "span");
    ɵɵtext(1);
    ɵɵelementEnd();
  }
  if (rf & 2 /* RenderFlags.Update */ ) {
    const item_r1 = ctx.$implicit;
    const index_r2 = ctx.index;
    ɵɵadvance(1);
    ɵɵtextInterpolate1(index_r2 + ":" + item_r1.value);
  }
}

おまけ: defer

Control Flow と同様の構文で Component を遅延読込する機能も Angular 17 で追加されています。コンパイル前後のコードを置いておきます。

こちらも Control Flow 同様に遅延読込専用の Ivy View 関数が実装され Angular コンパイラーによって変換されています。 ɵɵdefer を使用して遅延読込される Component のインポート関数を登録するのは全ての遅延読込種別で共通ですが、 on idle であれば ɵɵdeferOnIdle 、 on viewport であれば ɵɵdeferOnViewport などと遅延読込種別毎に専用の関数が用意されており、 ɵɵdefer の直後に呼び出す流れとなっています。また遅延読み込みの種別やロジックが多岐に渡ることからか、遅延表示に関数の実装場所は Control Flow が他の Ivy View 関数と同じく core/src/render3/instructions の下だったのに対して遅延読込は core/src/defer の下と分離されていました。

defer コンパイル before / after
app.component.ts
@Component({
  template: `
    <div class="idle">
      @defer (on idle) {
        <app-idle />
      }
    </div>

    <div class="viewport">
      @defer (on viewport) {
        <app-viewport />
      } @placeholder {
        <span>viewport placeholder</span>
      }
    </div>

    <div class="interaction">
      <button #def_interact type="button">Interact</button>
      @defer (on interaction(def_interact)) {
        <app-interaction />
      }
    </div>

    <div class="interaction-prefetch">
      <button #def_interact_prefetch type="button">Interact</button>
      @defer (on interaction(def_interact_prefetch); prefetch on idle) {
        <app-interaction-prefetch />
      }
    </div>

    <div class="hover">
      @defer (on hover) {
        <app-hover />
      } @placeholder {
        <span>hover placeholder</span>
      }
    </div>

    <div class="immediate">
      @defer (on immediate) {
        <app-immediate />
      }
    </div>

    <div class="timer">
      @defer (on timer(1s)) {
        <app-timer />
      }
    </div>
  `,
})
export class AppComponent {}
main.js
// コンポーネント遅延読み込みのためのインポート
var AppComponent_Defer_2_DepsFn  = () => [import("/chunk-CR4SIZS4.js").then((m) => m.IdleComponent)];
var AppComponent_Defer_7_DepsFn  = () => [import("/chunk-E3PP7HO7.js").then((m) => m.ViewportComponent)];
var AppComponent_Defer_14_DepsFn = () => [import("/chunk-I4GOKJCU.js").then((m) => m.InteractionComponent)];
var AppComponent_Defer_21_DepsFn = () => [import("/chunk-VGJANUCM.js").then((m) => m.InteractionPrefetchComponent)];
var AppComponent_Defer_26_DepsFn = () => [import("/chunk-GI6RIT4H.js").then((m) => m.HoverComponent)];
var AppComponent_Defer_30_DepsFn = () => [import("/chunk-IU6IHUCW.js").then((m) => m.ImmediateComponent)];
var AppComponent_Defer_34_DepsFn = () => [import("/chunk-S6RN2M3B.js").then((m) => m.TimerComponent)];

// AppComponentのViewテンプレート
function AppComponent_Template(rf, ctx) {
  if (rf & 1 /* RenderFlags.Create */ ) {
    // defer on idle
    ɵɵelementStart(0, "div", 0);
    ɵɵtemplate(1, AppComponent_Defer_1_Template, 1, 0);
    ɵɵdefer(2, 1, AppComponent_Defer_2_DepsFn);
    ɵɵdeferOnIdle();
    ɵɵelementEnd();

    // defer on viewport
    ɵɵelementStart(4, "div", 1);
    ɵɵtemplate(5, AppComponent_Defer_5_Template, 1, 0)
              (6, AppComponent_DeferPlaceholder_6_Template, 2, 0);
    ɵɵdefer(7, 5, AppComponent_Defer_7_DepsFn, null, 6);
    ɵɵdeferOnViewport(0, -1);
    ɵɵelementEnd();

    // defer on interaction
    ɵɵelementStart(9, "div", 2)
                  (10, "button", 3, 4);
    ɵɵtext(12, "Interact");
    ɵɵelementEnd();
    ɵɵtemplate(13, AppComponent_Defer_13_Template, 1, 0);
    ɵɵdefer(14, 13, AppComponent_Defer_14_DepsFn);
    ɵɵdeferOnInteraction(10);
    ɵɵelementEnd();

    // defer on interaction with prefetch(idle)
    ɵɵelementStart(16, "div", 2)(17, "button", 3, 5);
    ɵɵtext(19, "Interact");
    ɵɵelementEnd();
    ɵɵtemplate(20, AppComponent_Defer_20_Template, 1, 0);
    ɵɵdefer(21, 20, AppComponent_Defer_21_DepsFn);
    ɵɵdeferOnInteraction(17);
    ɵɵdeferPrefetchOnIdle();
    ɵɵelementEnd();

    // defer on hover
    ɵɵelementStart(23, "div", 6);
    ɵɵtemplate(24, AppComponent_Defer_24_Template, 1, 0)
              (25, AppComponent_DeferPlaceholder_25_Template, 2, 0);
    ɵɵdefer(26, 24, AppComponent_Defer_26_DepsFn, null, 25);
    ɵɵdeferOnHover(0, -1);
    ɵɵelementEnd();

    // defer on immediate
    ɵɵelementStart(28, "div", 7);
    ɵɵtemplate(29, AppComponent_Defer_29_Template, 1, 0);
    ɵɵdefer(30, 29, AppComponent_Defer_30_DepsFn);
    ɵɵdeferOnImmediate();
    ɵɵelementEnd();

    // defer on timer
    ɵɵelementStart(32, "div", 8);
    ɵɵtemplate(33, AppComponent_Defer_33_Template, 1, 0);
    ɵɵdefer(34, 33, AppComponent_Defer_34_DepsFn);
    ɵɵdeferOnTimer(1e3);
    ɵɵelementEnd();
  }
}

function AppComponent_Defer_1_Template(rf, ctx) {
  if (rf & 1) {
    ɵɵelement(0, "app-idle");
  }
}

/* 他のテンプレート関数は省略 */

おわりに

ただコンパイル結果を眺めてみただけの記事を最後までお読みいただきありがとうございます。普段の開発をしている際には(謎のエラーが発生しない限りは)コンパイル後のコードを眺めることなんてありませんから、 AdventCalendar という折角の機会なので新しい機能を全体的に眺めてみました。きっかけって大事ですよね。

Control Flow の登場経緯を考えると大幅に変わっているんだろうなというのは想像していましたが、想像よりも大きく、また綺麗に変更されており、大変感動を覚えました。コンパイル結果見てしまったらさっさといにしえのディレクティブは書き換えてしまいたいですね。

脚注
  1. incremental-dom | An in-place DOM diffing library ↩︎

Discussion