Dev Study
← 解説「変更検知 — 「いつ・どこを」再描画するかの世代交代」に戻る

サンプルコードで身につける: 変更検知 — 「いつ・どこを」再描画するかの世代交代

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

1zone.js のパッチを覗く

「非同期APIを横取りする」が具体的に何なのかをコードで見ます。setTimeoutが起動時に別物へ差し替えられていて、コールバックの完了をAngularに通報していた、というのがzone.jsの正体です。

// zone.js が起動時にやっていること(概念)
const originalSetTimeout = window.setTimeout;

window.setTimeout = (callback, delay) => {
  return originalSetTimeout(() => {
    callback();          // 本来の処理を実行してから
    zone.notify();       // 「何か終わった」とAngularへ通報
    //  → ApplicationRef.tick() → ルートから全査定
  }, delay);
};

// クリック、Promise、XMLHttpRequest なども同様にパッチ。
// だから「setTimeout で普通のプロパティを書き換えただけでも
// 画面が更新されていた」— 便利だが、通報のたびに全査定が走る。
// polyfills の zone.js の1行が、この横取りの読み込みだった

2全査定とOnPushの査定範囲を図で比べる

同じクリック1回で、既定(全査定)とOnPushで実行される更新モードの範囲がどう違うかを木の図で比べます。v22でOnPushが既定になった意味が視覚的に分かります。

// Counter のボタンをクリックしたときに更新モードが走る範囲
//
// ── 既定(zone.js + 全査定)──          ── OnPush ──
//   App        ✓査定                      App        ✓
//   ├ Header   ✓査定(変化なし)            ├ Header   スキップ
//   ├ Sidebar  ✓査定(変化なし)            ├ Sidebar  スキップ
//   │ └ Menu   ✓査定(変化なし)            │ └ Menu   (親ごと)
//   └ Main     ✓査定                      └ Main     ✓
//     └ Counter ✓査定 ←ここだけ変化         └ Counter ✓ イベント発生元
//
// OnPush の枝が査定されるのは:
//   ・input が新しい値になった
//   ・枝の中でイベントが発生した
//   ・枝の中で使っているシグナルが変わった
// のいずれかのときだけ。「変わりようがない枝は見ない」剪定

3シグナルの「読む=依存登録」を時系列で追う

標的更新が成立する鍵は、テンプレートが値を「読んだ」瞬間に依存が記録されることです。登録→変更→標的実行の3ステップを時系列で並べます。computedやeffectと同じ仕組みがビューにも効いています。

// count = signal(0) と {{ count() }} の裏側
//
// [初回描画時]
//   CounterComponent の更新モードが count() を読む
//   → シグナル側に「このビューが購読者」と記録される
//     (computed が依存を自動追跡するのと同じ機構。
//      ビュー自体が1つの「購読者」になっている)
//
// [count.set(1) の瞬間]
//   → 購読者リストにあるビューへ dirty マークを付ける
//   → スケジューラに「次の描画タイミングで処理」を予約
//
// [次の描画タイミング]
//   → dirty なビューだけ更新モードを実行
//   → 台帳比較で 0→1 の差分だけDOMへ反映
//
// zone.js が「いつ」を通報し損ねる心配も、全査定の無駄もない

4ゾーンレスで画面が止まる典型例と直し方

v22編で「普通のプロパティを外から書き換えると更新されない」と学びました。その内部理由がこのレッスンで説明できます。通報者(zone.js)がいなくなったので、変更を台帳に伝える手段がシグナルだけになったのです。

@Component({
  selector: 'app-timer',
  template: '<p>{{ seconds }}</p>', // 普通のプロパティ
})
export class TimerComponent {
  seconds = 0;

  constructor() {
    setInterval(() => {
      this.seconds++; // 値は変わるが…
      // ゾーンレスでは誰も「変わった」と知らない:
      //   ・zone.js の通報 → もういない
      //   ・シグナルの依存記録 → ただのプロパティなので無い
      // → dirty にならず、更新モードが走らず、画面は 0 のまま
    }, 1000);
  }
}

// 直し方: seconds = signal(0) にして seconds.update(n => n + 1)
// → set が購読者のビューを dirty にする経路が復活する

5NG0100 — 開発モードの二重査定が守っているもの

有名なExpressionChangedAfterItHasBeenCheckedErrorも変更検知の内部から説明できます。開発モードは査定を2回走らせ、1回目と2回目で値が食い違ったら「描画中に値を変えた」設計ミスとして知らせています。

// NG0100: ExpressionChangedAfterItHasBeenCheckedError
//
// 開発モードの変更検知は各ビューを2回査定する:
//   1回目: 通常の更新(台帳に値を記録)
//   2回目: 確認専用(値が1回目と同じであるべき)
//   → 食い違ったらこのエラー
//
// 典型的な出し方: 査定の最中に値を変えてしまう
//   ngAfterViewInit() {
//     this.title = '変更!'; // 親の査定が終わった直後に書き換え
//   }
//
// なぜ守るのか: 査定中に値が動くと「どこまで描画したら
// 終わりか」が収束しなくなるため。一方通行(状態→描画)を
// 保つための検問で、本番モードでは検問ごと省略される。
// シグナル+ゾーンレスでは「書き込み→次の描画タイミングで反映」
// が徹底されるため、この種の食い違い自体が起きにくい