Dev Study
← 解説「コンポーネントが画面に生まれる手順 — 実行時の変化」に戻る

サンプルコードで身につける: コンポーネントが画面に生まれる手順 — 実行時の変化

解説で学んだ概念を、5つの具体例で確認します。素朴な例から実務寄りの例へと進みます。

1誕生の瞬間は3つとも同じ手順

起動・条件表示・画面遷移は別々の機能に見えますが、内部では同じ誕生手順の呼び出し方が違うだけです。「同じ手順に合流する」と分かると、学んだ知識が1本につながります。

// 誕生手順(ホスト要素→DI→constructor→input→フック→作成モード)
// が呼ばれる3つの入り口:
//
// 1. アプリ起動
//    bootstrapApplication(AppComponent)
//    → index.html の <app-root> を host に、親なしで手順を実行
//
// 2. テンプレートの制御フロー
//    @if (show()) { <app-dialog /> }
//    → show() が true になった変更検知のタイミングで手順を実行
//
// 3. ルーティング
//    { path: 'users', component: UserListComponent }
//    → URL がマッチしたとき router-outlet の位置で手順を実行
//
// ViewContainerRef.createComponent() は、この手順を
// 自分のコードから直接起動するAPI(前レッスンの動的生成)

2コンストラクタでは input がまだ無い(実証)

誕生手順の(3)と(4)の順序を、ログで確かめられる形にした例です。「初期化はngOnInitで」という定石が、手順の順序から来る必然だと分かります。

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

@Component({ selector: 'app-user-card', template: '...' })
export class UserCardComponent implements OnInit {
  name = input(''); // 親は [name]="'松本'" を渡している

  constructor() {
    // 手順(3): input設定(4)より前なので、親の値はまだ無い
    console.log(this.name()); // '' ← 初期値のまま!
  }

  ngOnInit() {
    // 手順(5): input設定(4)の後なので、親の値が入っている
    console.log(this.name()); // '松本'
  }
}

// input() シグナルなら computed(() => this.name() + 'さん') の形で
// 「値が来たら追従する」定義をコンストラクタ時点で書けるため、
// この時差を意識する場面自体が減る(シグナル移行の御利益)

3inject() が使える場所・使えない場所の内部理由

「注入コンテキスト」という言葉を、誕生手順の時系列で説明し直します。エラーNG0203が出る場所と出ない場所の境界線が、手順(2)〜(3)のタイミングだと分かります。

import { Component, inject } from '@angular/core';
import { UserService } from './user.service';

@Component({ selector: 'app-profile', template: '...' })
export class ProfileComponent {
  // ○ フィールド初期化子: constructor 実行の一部として動く。
  //   手順(2)で自分のインジェクタが確定した直後なので特定できる
  private userService = inject(UserService);

  onClick() {
    // × NG0203: ここは誕生手順が終わった後の「ただの関数呼び出し」。
    //   Angularは「どのコンポーネントの組み立て中か」をもう
    //   知らないので、問い合わせ先インジェクタを特定できない
    // const s = inject(UserService);
  }
}

// 誕生手順の外で使いたい場合は、手順中に Injector 自体を
// 確保しておく: private injector = inject(Injector);
// → 後から runInInjectionContext(this.injector, () => ...)

4親子のライフサイクルログを予想する

誕生手順の(6)が再帰であることを、親子コンポーネントのログ順で確認します。「親のViewInitが最後」になる理由は、子の誕生が親のテンプレート実行の途中に挟まるからです。

// <app-parent> のテンプレートに <app-child> がある場合、
// 起動時のログはこの順になる:
//
// 親 constructor     ← 親の手順(3)
// 親 ngOnInit        ← 親の手順(5)。テンプレート実行より前!
// 子 constructor     ← 親の手順(6)の途中で子の手順(3)が始まる
// 子 ngOnInit        ← 子の手順(5)
// 子 ngAfterViewInit ← 子のテンプレートまで完成
// 親 ngAfterViewInit ← 子が全部済んで、やっと親のビューが完成
//
// 読み取れる法則:
//   ・OnInit は「上から下」(親→子)
//   ・AfterViewInit は「下から上」(子→親)
//   ・親の ngOnInit 時点では子はまだ存在しない
//     → viewChild の結果がここで取れない内部理由

5誕生は再帰 — @defer が効く内部理由

初回表示のコストが「配下ツリーの誕生手順の合計」であることを図で示します。@deferの境界線が、再帰のどこを切っているのかが見えると、どこに境界を引くべきかも判断できます。

// 商品ページの誕生コスト(誕生手順の実行回数)
//
// AppComponent
// └ ProductPage
//    ├ ProductInfo(+子5個)   … 誕生 6回
//    ├ ReviewList(+行50個)    … 誕生 51回 ← 重い!
//    └ RelatedItems(+カード20個)… 誕生 21回
//
// 全部同期なら: 初回表示までに誕生 約80回ぶんの仕事
//
// @defer (on viewport) { <app-review-list /> } と境界を切ると:
//   初回: ProductInfo 側の誕生だけ(再帰がそこで止まる)
//   後で: 画面に入った時点で ReviewList 以下 51回ぶんを実行
//
// 「初回表示を軽くする」パフォーマンス編の話は、
// 内部的には「起動時に走る誕生再帰をどこで打ち切るか」の設計