Angular の @defer とは、テンプレートの一部とその中で使うコンポーネントの JavaScript を別ファイル(lazy chunk)に分け、指定したタイミング(トリガー)で読み込んで表示する構文です。 ルーティングを変えずに、グラフやエディタのような重い部品だけを後から読み込めるので、初期表示を軽くできます。

この記事では Angular 22.2.0 で、chart.js と marked を使う2つのコンポーネントを @defer あり・なしでビルドし、Initial total と lazy chunk を実測しました。さらに「@defer で囲んだのに遅延されない」ケースをビルド出力で確認し、Playwright のネットワークログで viewport・interaction・prefetch などのトリガーが実際にいつチャンクを読み込むかを記録しています。トリガー一覧はコピペで使える形にまとめました。

Angular @defer の早見表

やりたいこと 書き方(コピペ用) 読み込むタイミング(実測)
ブラウザが暇になったら読む(既定) @defer { ... } または @defer (on idle) { ... } ページ表示直後(66ms)
暇にならなくても最大 500ms で読む @defer (on idle(500)) { ... } —
画面に入ったら読む @defer (on viewport) { ... } @placeholder { ... } スクロールして入った直後(10ms 後)
余白・しきい値つきで画面に入ったら @defer (on viewport({ rootMargin: '100px', threshold: 0.5 })) { ... } @placeholder { ... } —
クリック・キー入力で読む @defer (on interaction) { ... } @placeholder { <button>表示</button> } クリックから 65ms 後
マウスを乗せたら読む @defer (on hover) { ... } @placeholder { ... } hover から 64ms 後
別の要素の操作で読む <button #btn>表示</button> @defer (on interaction(btn)) { ... } —
初期描画が終わったらすぐ読む @defer (on immediate) { ... } ページ表示直後(54ms)
一定時間後に読む @defer (on timer(1500ms)) { ... }(1.5s も可) 1547ms
条件が true になったら読む @defer (when ready()) { ... } 条件変更から 35ms 後
表示は操作時、JS は先に取っておく @defer (on interaction; prefetch on idle) { ... } JS はページ表示直後、表示はクリック時
どちらか早い方で読む @defer (on viewport; on timer(5s)) { ... } ; 区切りは OR 条件
読み込み前の表示 @placeholder (minimum 300ms) { ... } 最低 300ms は表示し続ける
読み込み中の表示 @loading (after 100ms; minimum 500ms) { ... } 100ms 以上かかったら表示、出したら最低 500ms
読み込み失敗時の表示 @error { ... } チャンク取得に失敗したとき

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular @defer 使い方|トリガー早見表と遅延されない例を実測」

検証環境と題材

項目 バージョン
Angular(core / cli / build) 22.2.0(新規作成、zoneless 既定)
chart.js / marked 4.5.1 / 18.0.14
Vitest 5.0.2(ng test)
Node.js 24.21.0
Playwright / Chromium 1.56.1 / 141

プロジェクトは npx -y -p @angular/cli@22 ng new defer-app --defaults --skip-git --style=css --routing=false --ssr=false --ai-config=none で作りました。題材は備品ダッシュボードで、重い依存を持つコンポーネントを2つ用意します。

npm i chart.js marked
// src/app/stock-chart/stock-chart.ts(chart.js を使うグラフ)
import { AfterViewInit, Component, ElementRef, viewChild } from '@angular/core';
import Chart from 'chart.js/auto';

@Component({
  selector: 'app-stock-chart',
  template: `<canvas #canvas width="480" height="240"></canvas>`,
})
export class StockChart implements AfterViewInit {
  private canvas = viewChild.required<ElementRef<HTMLCanvasElement>>('canvas');

  ngAfterViewInit() {
    new Chart(this.canvas().nativeElement, {
      type: 'bar',
      data: {
        labels: ['ペン', 'ノート', '付箋', 'ファイル'],
        datasets: [{ label: '在庫数', data: [120, 80, 45, 60] }],
      },
    });
  }
}
// src/app/memo-preview/memo-preview.ts(marked で Markdown を HTML に)
import { Component, input, computed } from '@angular/core';
import { marked } from 'marked';

@Component({
  selector: 'app-memo-preview',
  template: `<div class="preview" [innerHTML]="html()"></div>`,
})
export class MemoPreview {
  text = input('');
  html = computed(() => marked.parse(this.text(), { async: false }));
}
// src/app/app.ts
import { Component, signal } from '@angular/core';
import { StockChart } from './stock-chart/stock-chart';
import { MemoPreview } from './memo-preview/memo-preview';

@Component({
  selector: 'app-root',
  imports: [StockChart, MemoPreview],
  templateUrl: './app.html',
})
export class App {
  memo = signal('# 備品メモ\n\n- ペンを **20本** 追加');
}

@defer なし・ありで ng build を比べる

@defer なし

<!-- src/app/app.html -->
<h1>備品ダッシュボード</h1>
<app-memo-preview [text]="memo()" />
<app-stock-chart />
Initial chunk files | Names         |  Raw size | Estimated transfer size
main-MLFSFQ3Q.js    | main          | 374.15 kB |               109.58 kB
styles-5INURTSO.css | styles        |   0 bytes |                 0 bytes
                    | Initial total | 374.15 kB |               109.58 kB

@defer あり(コピペで動く完成形)

<!-- src/app/app.html -->
<h1>備品ダッシュボード</h1>

@defer (on interaction) {
  <app-memo-preview [text]="memo()" />
} @placeholder {
  <button type="button">メモのプレビューを表示</button>
} @loading (after 100ms; minimum 500ms) {
  <p>読み込み中...</p>
} @error {
  <p>読み込みに失敗しました</p>
}

<div style="height: 2000px">下にスクロールするとグラフが表示されます</div>

@defer (on viewport) {
  <app-stock-chart />
} @placeholder (minimum 300ms) {
  <div style="height: 240px">グラフ領域</div>
}
Initial chunk files | Names         |  Raw size | Estimated transfer size
chunk-ECYVK7GQ.js   | -             | 124.90 kB |                37.89 kB
main-ZC5ZNMOY.js    | main          |  13.92 kB |                 4.66 kB
styles-5INURTSO.css | styles        |   0 bytes |                 0 bytes
                    | Initial total | 138.82 kB |                42.55 kB

Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-57LHOU6O.js   | stock-chart   | 204.76 kB |                61.11 kB
chunk-ZGDTCH7K.js   | memo-preview  |  45.88 kB |                12.70 kB
構成 Initial total(Raw) 転送量の目安 Lazy chunk
@defer なし 374.15 kB 109.58 kB なし
@defer あり 138.82 kB 42.55 kB stock-chart 204.76 kB、memo-preview 45.88 kB

初期読み込みは 374.15kB → 138.82kB(約63%減)になりました。 Lazy chunk の「Names」列にコンポーネントのファイル名が出るので、狙いどおり分割されたかはビルド出力だけで確認できます。 TypeScript 側の imports: [StockChart, MemoPreview] はそのままでよく、@defer の中でしか使っていなければコンパイラが自動で動的 import に変えます。

トリガーが実際にチャンクを読み込むタイミング(Playwright)

上の構成を本番ビルドしてローカルサーバーで配信し、Playwright で .js の取得時刻を記録しました(ページを開いた時刻を 0ms とします)。

// check-defer.mjs(node check-defer.mjs で実行)
import { chromium } from 'playwright';
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1000, height: 700 } });
const t0 = Date.now();
const log = (msg) => console.log(`${String(Date.now() - t0).padStart(5)}ms  ${msg}`);
p.on('request', (r) => { if (r.url().endsWith('.js')) log('GET ' + r.url().split('/').pop()); });
await p.goto('http://localhost:7122');
log('--- 読み込み完了。2秒待つ');
await p.waitForTimeout(2000);
log('--- ボタンをクリック');
await p.getByRole('button', { name: 'メモのプレビューを表示' }).click();
await p.waitForTimeout(750);
log('--- 一番下までスクロール');
await p.mouse.wheel(0, 3000);
await p.waitForTimeout(1000);
log('canvas の数: ' + (await p.locator('canvas').count()));
await b.close();
   29ms  GET chunk-ECYVK7GQ.js
   29ms  GET main-ZC5ZNMOY.js
   53ms  --- 読み込み完了。2秒待つ
 2054ms  --- ボタンをクリック
 2119ms  GET chunk-ZGDTCH7K.js      ← memo-preview(interaction)
 2881ms  --- 一番下までスクロール
 2891ms  GET chunk-57LHOU6O.js      ← stock-chart(viewport)
 3887ms  canvas の数: 1

2秒待っている間は、どちらの lazy chunk も取得されていません。 クリックした直後に memo-preview、スクロールして placeholder が画面に入った直後に stock-chart が読み込まれました。

prefetch on idle を付けた場合

1つ目のブロックを @defer (on interaction; prefetch on idle) に変えて同じスクリプトを実行しました。

   33ms  GET chunk-IXXIOSEZ.js
   34ms  GET main-UJKXKUGR.js
   56ms  --- 読み込み完了。2秒待つ
   75ms  GET chunk-U7FPPVK4.js      ← memo-preview を先に取得
 2056ms  --- ボタンをクリック        ← クリック時は取得なし(表示だけ切り替え)
 2877ms  --- 一番下までスクロール
 2891ms  GET chunk-2ZPHYRID.js

prefetch は「JS の取得」と「表示の切り替え」を別のタイミングにする指定です。 クリックしてから通信するとその分だけ待ちが出るので、押される可能性が高いボタンは prefetch on idle、押されることが少ないものは prefetch なし、と使い分けると通信量と体感速度のバランスが取れます。

その他のトリガー

小さなコンポーネントを5つ用意し、それぞれ別のトリガーで囲んで取得時刻を記録しました。

@defer (on idle) { <app-idle /> }
@defer (on immediate) { <app-immediate /> }
@defer (on timer(1500ms)) { <app-timer /> }
@defer (on hover) { <app-hover /> } @placeholder { <span id="hover-me">ここにマウスを乗せる</span> }
<button (click)="ready.set(true)">準備完了</button>
@defer (when ready()) { <app-when /> }
   43ms  goto 完了
   54ms  chunk 取得: immediate
   66ms  chunk 取得: idle
 1547ms  chunk 取得: timer
 2045ms  --- hover
 2109ms  chunk 取得: hover
 2406ms  --- 準備完了ボタン
 2441ms  chunk 取得: when
トリガー 使いどころ 注意点
on idle ファーストビューにない、いずれ使う部品 既定のトリガー。requestIdleCallback を使う
on immediate 初期描画を優先し、直後に読みたい部品 ほぼ即時。バンドル分割だけが目的のとき
on timer(1500ms) 通知バナーなど、少し遅れて出す部品 単位は ms か s
on viewport ページ下部のグラフ・コメント欄 対象要素がないとビルドエラー(後述)
on interaction タブ・モーダル・プレビューなど押して開く部品 click と keydown が対象
on hover ツールチップ・メニュー mouseover と focusin が対象
when 条件 権限・設定に応じて出す部品 一度 true になったら元に戻らない(読み込みは1回きり)

@placeholder・@loading・@error の動き(実測)

@placeholder が必要なトリガー

on viewport / on interaction / on hover を対象の要素を指定せずに書く場合、@placeholder の要素が監視対象になります。 @placeholder を書き忘れると、次のビルドエラーになります(実際の出力)。

✘ [ERROR] Angular compilation emit failed. [plugin angular-compiler]
  Error: defer on trigger with no target name must have a placeholder block

対象を別の要素にしたい場合は、テンプレート参照変数を渡します。

<button #openBtn type="button">プレビューを開く</button>
@defer (on interaction(openBtn)) {
  <app-memo-preview [text]="memo()" />
}

@loading の after / minimum

ネットワークが遅い場合を再現するため、Playwright で memo-preview のチャンクに 800ms の遅延を入れ、画面の文字を 20ms ごとに記録しました(クリックは約 650ms 時点)。

[slow]   658ms  メモのプレビューを表示 / 下にスクロールするとグラフが表示されます
[slow]   753ms  読み込み中... / 下にスクロールするとグラフが表示されます
[slow]  1491ms  備品メモ / ペンを 20本 追加 / ...

after 100ms のとおり、クリックから約100ms 後に「読み込み中...」が出て、チャンク取得後に本体へ切り替わりました。 遅延なしの通常実行では 100ms 以内に読み込みが終わるため、@loading は一度も表示されませんでした。after は「一瞬だけローディングが見えてチラつく」のを防ぐ指定、minimum は「出したなら最低これだけは見せる」指定です。

@error

同じくチャンクの取得を Playwright で失敗させた結果です。

[fail]   604ms  メモのプレビューを表示 / ...
[fail]   640ms  読み込みに失敗しました / ...

@error はチャンクの取得に失敗したときに表示されます。 デプロイでファイル名が変わった直後に古い画面から読み込もうとした場合などに備えて、再読み込みを促す文言を入れておくと親切です。

@defer で囲んでも遅延されないケース(ビルド出力で確認)

公式ドキュメントでは、遅延できる依存の条件を 「standalone であること」「同じファイル内で @defer の外から参照されていないこと(ViewChild クエリを含む)」 としています。実際に1つずつ崩してビルドしました。どのケースもビルドは成功し、警告も出ませんでした。Lazy chunk 表から名前が消えていないかを必ず見てください。

ケース Initial total Lazy chunk 結果
正しく書いた場合(基準) 138.82 kB stock-chart、memo-preview 両方遅延
クラスで viewChild(MemoPreview) と参照 184.82 kB stock-chart のみ memo-preview が初期に入る
memo-preview を NgModule で宣言(standalone: false) 185.06 kB stock-chart のみ memo-preview が初期に入る
親コンポーネントでも chart.js を import して使う 343.02 kB stock-chart は 704 bytes だけ chart.js 本体が初期に入る
親で import type { ChartConfiguration } from 'chart.js'(型だけ) 138.82 kB 両方 型だけなら影響なし
バレルファイル経由で2つとも import 138.82 kB widgets 250.80 kB(1つにまとまる) 遅延はされるが、クリック時にグラフの JS まで読む
バレルファイルの別の値(定数)を親で使う 389.42 kB なし すべて初期に入る

viewChild で参照している

export class App {
  memo = signal('# 備品メモ\n\n- ペンを **20本** 追加');
  preview = viewChild(MemoPreview); // ← @defer の外(クラス側)で参照している
}
Initial chunk files | Names         |  Raw size | Estimated transfer size
chunk-GQRUX2DS.js   | -             | 124.90 kB |                37.89 kB
main-NXFHVIVS.js    | main          |  59.92 kB |                17.12 kB
                    | Initial total | 184.82 kB |                55.01 kB
Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-3UGCNQJ6.js   | stock-chart   | 204.76 kB |                61.15 kB

クラスの中で型として値を使うと、その import は「@defer の外で使っている」扱いになります。 遅延したコンポーネントに触りたい場合は、output() でイベントを受け取る、状態をサービスや signal に置く、といった形にすると遅延が保たれます。

親でもライブラリを import している

import Chart from 'chart.js/auto';
Chart.defaults.color = '#333'; // 親で chart.js を直接使う
Initial chunk files | Names         |  Raw size | Estimated transfer size
chunk-CJEDIALI.js   | -             | 204.13 kB |                60.74 kB
chunk-ECYVK7GQ.js   | -             | 124.90 kB |                37.89 kB
main-MVONFLY5.js    | main          |  13.98 kB |                 4.68 kB
                    | Initial total | 343.02 kB |               103.31 kB
Lazy chunk files    | Names         |  Raw size | Estimated transfer size
chunk-ZGDTCH7K.js   | memo-preview  |  45.88 kB |                12.70 kB
chunk-CNV6T7TH.js   | stock-chart   | 704 bytes |               704 bytes

コンポーネント自体は遅延されても、重いのはライブラリ側なので効果がほぼ消えます。 全体設定はコンポーネントの中(遅延される側)に移し、親では import type で型だけを使うと、基準と同じ 138.82kB に戻りました。

バレルファイル(index.ts など)経由の import

// src/app/widgets.ts
export { StockChart } from './stock-chart/stock-chart';
export { MemoPreview } from './memo-preview/memo-preview';
export const PAGE_TITLE = '備品ダッシュボード';
import { MemoPreview, PAGE_TITLE, StockChart } from './widgets'; // バレルファイル経由

コンポーネントだけを import した場合は、遅延はされたものの2つが widgets という1つのチャンク(250.80kB)にまとまり、ボタンを押しただけでグラフ用の chart.js まで読み込まれました(Playwright でクリック時に1本だけ取得、スクロール時は取得なし)。さらに同じファイルの定数 PAGE_TITLE を親で使うと、Initial total が 389.42kB になり lazy chunk がなくなりました。@defer で使うコンポーネントは、バレルを通さずコンポーネントのファイルから直接 import するのが確実です。

@defer のテスト(Vitest)

DeferBlockBehavior.Manual にすると、各状態を好きな順番で描画してテストできます。

// src/app/app.spec.ts
import { DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
import { App } from './app';

describe('App の @defer', () => {
  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [App],
      deferBlockBehavior: DeferBlockBehavior.Manual,
    });
  });

  it('プレースホルダー → 読み込み中 → 本体 の順に描画できる', async () => {
    const fixture = TestBed.createComponent(App);
    await fixture.whenStable();
    const el: HTMLElement = fixture.nativeElement;
    expect(el.textContent).toContain('メモのプレビューを表示');

    const [memoBlock] = await fixture.getDeferBlocks(); // テンプレート順で 1 つ目
    await memoBlock.render(DeferBlockState.Loading);
    expect(el.textContent).toContain('読み込み中...');

    await memoBlock.render(DeferBlockState.Complete);
    expect(el.querySelector('.preview h1')?.textContent).toBe('備品メモ');
  });

  it('@error ブロックも確認できる', async () => {
    const fixture = TestBed.createComponent(App);
    await fixture.whenStable();
    const [memoBlock] = await fixture.getDeferBlocks();
    await memoBlock.render(DeferBlockState.Error);
    expect(fixture.nativeElement.textContent).toContain('読み込みに失敗しました');
  });
});
 ✓ |defer-app| src/app/app.spec.ts > App の @defer > プレースホルダー → 読み込み中 → 本体 の順に描画できる 116ms
 ✓ |defer-app| src/app/app.spec.ts > App の @defer > @error ブロックも確認できる 13ms
      Tests  2 passed (2)

トリガーの発火(クリックやスクロール)を再現しなくても、各ブロックの表示内容を確かめられます。 トリガーのタイミングそのものは、前述のように Playwright で確認するのが確実です。

@defer を使うときの判断表とよくあるつまずき

状況 おすすめ
ファーストビューに見えている部品 @defer しない(表示が遅れ、レイアウトのずれにもつながる)
ページ下部の重いグラフ・地図 on viewport+高さを確保した @placeholder
タブ・モーダルの中身 on interaction; prefetch on idle
ほとんどの人が使わない管理用パネル when isAdmin() や on interaction(prefetch なし)
同じトリガーの @defer を入れ子にする 公式は避けるよう案内(連鎖して読み込みが重なる)
ng serve で試すとトリガーが効かない HMR 有効時は全チャンクが即時取得される。ng serve --no-hmr か本番ビルドで確認
SSR(サーバー描画)を使っている サーバーでは @placeholder が描画される。本体をサーバーで描画して後から有効化したい場合は hydrate トリガー(incremental hydration)を使う

@placeholder には本体と同じくらいの高さを持たせると、読み込み後にページがガクッとずれるのを防げます。また公式ドキュメントでは、読み込まれた内容を支援技術に伝えるため、aria-live="polite" の領域で囲むことも勧められています。

よくある質問

Q. @defer の中で使うコンポーネントは imports から外す必要がありますか? A. いいえ。今回のように imports: [StockChart, MemoPreview] のままで、テンプレートの @defer の中でしか使っていなければ、コンパイラが自動で動的 import に変えます。外す必要があるのは、クラスの中など @defer の外で参照している場合です。

Q. 遅延されたかどうかは、どうやって確認すればよいですか? A. ng build の出力の「Lazy chunk files」にコンポーネント名が出ているかを見るのが一番簡単です。viewChild や NgModule のケースでは警告なしで初期チャンクに入ったので、ビルド出力の確認をおすすめします。

Q. when と on は一緒に使えますか? A. 使えます。@defer (on viewport; when ready()) のように ; で区切り、どれか1つが満たされたら読み込みます。when の条件は一度 true になると、あとで false に戻っても元には戻りません。

Q. ルーティングの遅延読み込み(loadComponent)とどう使い分けますか? A. 画面(URL)単位で分けるなら loadComponent、同じ画面の中の一部を後から読むなら @defer です。両方を組み合わせることもできます。

まとめ

  • @defer はテンプレートの一部を lazy chunk に分け、トリガーで読み込む構文。実測で Initial total が 374.15kB → 138.82kB
  • トリガーは idle(既定)・viewport・interaction・hover・immediate・timer・when。; 区切りは OR 条件
  • prefetch on idle で JS の取得だけ先に済ませ、表示は操作時にできる
  • viewport / interaction / hover を対象指定なしで使うなら @placeholder が必須(ないとビルドエラー)
  • viewChild での参照・NgModule 宣言・親でのライブラリ import・バレル経由は、警告なしで遅延が効かなくなる。ビルド出力の Lazy chunk 表で確認する
  • @loading (after 100ms; minimum 500ms) でチラつきを防ぎ、@error で取得失敗に備える
  • テストは DeferBlockBehavior.Manual と render(DeferBlockState.X) で各状態を確認できる

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Angular @defer 使い方|トリガー早見表と遅延されない例を実測」

株式会社RJCでは、ビルド出力やネットワークログといった「実際の数字」を見ながら、表示の速さを改善する工夫をチームで重ねています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?