Dev Study
← 解説「描画の無駄を減らす — 変わった部分だけ描き直す」に戻る

サンプルコードで身につける: 描画の無駄を減らす — 変わった部分だけ描き直す

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

1track $index が並べ替えで全行を作り直す

trackの指定が実質的に効いていない例と修正版です。track $indexは「n番目の行」を識別子にするため、並べ替えると全行の中身が変わったと判定されます。一意なIDに変えるだけで、DOMの組み替えだけで済むようになります。

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

@Component({
  selector: 'app-product-list',
  // ── 遅い書き方: track $index ──
  //   '@for (p of products(); track $index) { ... }'
  // 並べ替えると「0番目の中身が変わった」…と全行が再描画される
  //
  // ── 速い書き方: 一意な ID を track にする ──
  template:
    '<button (click)="sortByPrice()">価格順に並べ替え</button>' +
    '@for (p of products(); track p.id) {' +
    '  <p>{{ p.name }}: {{ p.price }}円</p>' +
    '}',
})
export class ProductListComponent {
  products = signal([
    { id: 1, name: 'コーヒー', price: 500 },
    { id: 2, name: '紅茶', price: 400 },
  ]);

  sortByPrice() {
    // id が同じ行は DOM が再利用され、順番だけが入れ替わる
    this.products.update((list) =>
      [...list].sort((a, b) => a.price - b.price)
    );
  }
}

2毎回新しいオブジェクトを作ると track が無力化する

trackを正しく書いていても、更新のたびに全要素を新しいIDで作り直すと効果が消える例です。サーバーから再取得したデータでも、IDが安定していれば行は再利用されます。「trackの値が更新をまたいで安定しているか」が確認ポイントです。

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

type Task = { id: number; title: string; done: boolean };

@Component({
  selector: 'app-task-list',
  template:
    '@for (task of tasks(); track task.id) {' +
    '  <p>{{ task.title }}</p>' +
    '}',
})
export class TaskListComponent {
  tasks = signal<Task[]>([
    { id: 1, title: '見積書を送る', done: false },
    { id: 2, title: '議事録を書く', done: false },
  ]);

  // ── 遅い書き方: 1件直すだけなのに全件のIDを振り直す ──
  // completeAllSlow() {
  //   this.tasks.set(
  //     this.tasks().map((t, i) => ({ ...t, id: Date.now() + i }))
  //   ); // id が毎回変わる = track には「全部別物」に見える
  // }

  // ── 速い書き方: id は変えず、変わった項目だけ差し替える ──
  complete(id: number) {
    this.tasks.update((list) =>
      list.map((t) => (t.id === id ? { ...t, done: true } : t))
    );
    // id が安定しているので、該当行以外の DOM はそのまま残る
  }
}

3シグナル+OnPushで変わった部品だけ再描画する

既習のシグナルとOnPushが描画性能にどう効くかを1つの親子構成で確認する例です。親のカウンターを更新しても、シグナルを読んでいない子は再描画されません。ゾーンレス環境ではこの「必要な部品だけ更新」が既定の動きになります。

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

@Component({
  selector: 'app-price-tag',
  changeDetection: ChangeDetectionStrategy.OnPush,
  // input が変わったときだけ再描画される子コンポーネント
  template: '<span>{{ price() }}円</span>',
})
export class PriceTagComponent {
  price = input.required<number>();
}

@Component({
  selector: 'app-shop',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [PriceTagComponent],
  template:
    '<p>閲覧数: {{ views() }}</p>' +
    '<button (click)="views.set(views() + 1)">閲覧+1</button>' +
    '<app-price-tag [price]="price()" />',
})
export class ShopComponent {
  views = signal(0);
  price = signal(500);
  // views を更新しても price は変わらないので、
  // PriceTagComponent は再描画されない(DevToolsのプロファイラで確認できる)
}

4テンプレートのメソッド呼び出しを computed に直す

テンプレートから重い関数を直接呼ぶと、変更検知のたびに再計算される問題の対処例です。computedに移すと、依存するシグナルが変わったときだけ再計算され、結果はキャッシュされます。一覧の絞り込みや集計で特に効きます。

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

type Item = { id: number; name: string; price: number };

@Component({
  selector: 'app-cart',
  // ── 遅い書き方: 変更検知のたびに totalOf() が再実行される ──
  //   template: '<p>合計: {{ totalOf() }}円</p>'
  //   totalOf() { return this.items().reduce((s, i) => s + i.price, 0); }
  //
  // ── 速い書き方: computed は items が変わったときだけ再計算 ──
  template:
    '<p>合計: {{ total() }}円</p>' +
    '@for (item of items(); track item.id) {' +
    '  <p>{{ item.name }}</p>' +
    '}',
})
export class CartComponent {
  items = signal<Item[]>([
    { id: 1, name: 'コーヒー豆', price: 1200 },
    { id: 2, name: 'ドリッパー', price: 800 },
  ]);

  // 結果はキャッシュされ、items が変わるまで使い回される
  total = computed(() =>
    this.items().reduce((sum, item) => sum + item.price, 0)
  );
}

5Angular DevTools プロファイラで再描画を確認する

描画最適化の効果を目で確かめる手順です。プロファイラを録画すると、変更検知のたびにどのコンポーネントが何ミリ秒かかったかが表示されます。直す前に録画して問題を確認し、直した後に同じ操作で録画して比べます。

# ── Angular DevTools プロファイラの使い方 ──

# 0. Chrome拡張「Angular DevTools」をインストールしておく
#    (開発モードのアプリで動く。DevTools に Angular タブが増える)

# 1. DevTools → Angular タブ → Profiler を開く
# 2. 「記録」を押し、気になる操作(一覧更新など)を行って停止
#
# 読み方:
#   ・棒グラフ1本 = 1回の変更検知サイクル。高い棒が重い更新
#   ・棒をクリックすると、コンポーネントごとの所要時間が見える
#   ・ボタンを1回押しただけなのに大量のコンポーネントが
#     毎回チェックされていたら、無駄な再描画のサイン
#     (track の見直しや OnPush + シグナル化の候補)
#
# 使いどころ: 修正の前後で同じ操作を録画して比較する。
# 差がなければその最適化は不要だった、と判断できるのが最大の価値