Angular中級 #5 Standalone と Lazy Loading

読了 10分

アプリが小さいうちは、すべてのコードを一度に受け取っても大した問題にはなりません。しかし画面が数十個に増え、チャートライブラリ、エディタ、管理者専用画面のような重い依存関係が付き始めると、話が変わってきます。ユーザーが初めてアクセスして見る画面 1 つを表示するために、管理画面でしか使わないチャートライブラリまでまとめてダウンロードさせるのは無駄です。

基礎 #6 Router の最後に loadComponent でルートを 1 つ lazy に分離するパターンを短く見せましたが、今回の記事ではそのテーマを本格的に分解します。その出発点は意外なことに Standalone コンポーネントの imports 配列 です。「自分がどんな依存関係を持つかをコンポーネント自身が知っている」というこのモデルこそが、lazy loading を今のようにすっきりとさせてくれた最大の立役者だからです。

Standalone Components 深掘り #

基礎講座 #2 で、standalone コンポーネントの形は見ました。もう一度のぞいてみましょう。

src/app/user-card/user-card.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AvatarComponent } from '../shared/avatar.component';

@Component({
  selector: 'app-user-card',
  standalone: true,
  imports: [CommonModule, RouterLink, AvatarComponent],
  templateUrl: './user-card.component.html',
})
export class UserCardComponent {
  // ...
}

中核は imports 配列です。このコンポーネントのテンプレートで使う他のコンポーネント、ディレクティブ、パイプを コンポーネントが直接宣言 します。NgModule という中間の登録簿がなく、それゆえコンパイラもビルドツールも依存関係グラフを明確に把握できます。

これがなぜ lazy loading と関係があるのでしょうか? コンパイラが「このコンポーネントが正確に何を引っ張ってくるか」を知れば、ビルド時点でコードチャンクを分割するときも、どのコードがどこに行かなければならないかを自動で決定できます。私たちがルートで () => import(...) の 1 行だけを書いてもチャンクがすっきり分かれる理由がここにあります。

もう 1 つ実用的な点 — standalone コンポーネントは、それ自体がルートの単位になりえます。別のモジュールで包まずに loadComponent にそのまま渡せるという意味です。昔の NgModule 時代には、ページ 1 つを lazy にするために専用のモジュールとルーティングモジュールを一緒に作らなければならなかったことを考えると、ものすごい単純化です。

NgModule 短い回顧 #

Angular 14 以前では、すべてのコンポーネント、ディレクティブ、パイプが NgModule という単位に登録されていなければなりませんでした。AppModuleSharedModuleUserModule のようにフォルダごとに *.module.ts が 1 つずつあり、その中に declarationsimportsexportsproviders をびっしりと埋める形でした。

src/app/user/user.module.ts (legacy)
@NgModule({
  declarations: [UserListComponent, UserDetailComponent],
  imports: [CommonModule, RouterModule.forChild(routes)],
  providers: [UserService],
})
export class UserModule {}

問題は、ボイラープレートが多すぎて、同じコンポーネントを複数の箇所で使うには exports を別途用意しなければならず、依存関係の流れがモジュールの間にぼんやり広がって追跡が難しかった、という点です。

Angular 14 で standalone が導入され、v17 で既定値になったことで、NgModule は事実上 legacy に押しやられました。新しいプロジェクトはすべて standalone であり、公式ドキュメント・チュートリアル・ng new の出力もすべて standalone 基準です。

注記
しかし実務では NgModule に出会います。社内に v13、v14 で始まったプロジェクトが生きている場合が多く、ライブラリの中にはまだ NgModule API のみを公開しているものもあります。@NgModuledeclarationsRouterModule.forRoot(...) のようなキーワードが見えたら「ああ、legacy パターンだな」と認識すれば構いません。新しく書くときは standalone、既存コードはそのまま維持しつつ段階的に standalone へマイグレーション — これが現在の推奨方向です。

Eager loading vs Lazy loading #

ルートのコンポーネントをルート定義に直接書くと、そのコンポーネントは eager にロードされます。

src/app/app.routes.ts (eager)
import { HomeComponent } from './pages/home.component';
import { AdminComponent } from './pages/admin.component';

export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'admin', component: AdminComponent },
];

AdminComponentimport した時点でそのコードはルート定義ファイルに紐づき、ルート定義は app.config.ts に紐づき、結局 初期バンドル に含まれます。ユーザーが /admin に一度も入らなくても、最初のページを表示するときに admin のコードまですべて受け取ることになるのです。

Lazy loading はそのインポートを 関数の中に遅らせる 手法です。

src/app/app.routes.ts (lazy)
export const routes: Routes = [
  { path: '', component: HomeComponent },
  {
    path: 'admin',
    loadComponent: () => import('./pages/admin.component').then(m => m.AdminComponent),
  },
];

ビルドツール (esbuild/webpack) は import('./pages/admin.component') パターンを見て 別チャンク に切り離します。ユーザーが /admin へ移動する瞬間に、初めてそのチャンクがネットワーク経由で受け取られて実行されます。

判断基準は単純です。

  • よく見る画面で軽量 → eager のほうが良いです。チャンクが分かれると、かえってルート遷移ごとに短い遅延が生じます
  • たまに入るが重い (管理者ページ、統計ダッシュボード、設定画面など) → lazy が正解です
  • ログイン前後で画面が完全に異なる → ログイン/会員登録は eager で、本アプリは lazy で分離するパターンがよく使われます

loadComponent でルート単位の lazy #

もっとも軽い分離単位はルート 1 つです。先ほど見たパターンが標準ですね。

src/app/app.routes.ts
export const routes: Routes = [
  { path: '', component: HomeComponent },
  {
    path: 'reports',
    loadComponent: () =>
      import('./pages/reports/reports.page').then(m => m.ReportsPage),
  },
  {
    path: 'settings',
    loadComponent: () =>
      import('./pages/settings/settings.page').then(m => m.SettingsPage),
  },
];

.then(m => m.ReportsPage) の部分はボイラープレートのように見えますが、もっと短くもできます。コンポーネントを default export で書き出せば構いません。

src/app/pages/reports/reports.page.ts
@Component({
  selector: 'app-reports',
  standalone: true,
  template: `<h1>レポート</h1>`,
})
export default class ReportsPage {}
src/app/app.routes.ts
{
  path: 'reports',
  loadComponent: () => import('./pages/reports/reports.page'),
},

then の 1 段階がまるごと消えます。ただし default export は 1 ファイルに 1 つだけ可能なので、ページコンポーネントのように そのファイルから 1 つだけ書き出す 場合によく合います。shared コンポーネントを default export にすると、別のところで import するときに毎回名前を付け直さなければならず、かえって不便です。

loadChildren でルートグループの lazy #

ページ 1 つではなく 複数のページが同じ領域 に束ねられているなら、loadChildren のほうがすっきりします。たとえば /admin の下にダッシュボード、ユーザー管理、設定のような画面が集まっているなら、その束ごと lazy に分離できます。

src/app/admin/admin.routes.ts
import { Routes } from '@angular/router';

export const ADMIN_ROUTES: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./pages/admin-shell.component').then(m => m.AdminShellComponent),
    children: [
      { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
      {
        path: 'dashboard',
        loadComponent: () =>
          import('./pages/dashboard.page').then(m => m.DashboardPage),
      },
      {
        path: 'users',
        loadComponent: () =>
          import('./pages/users.page').then(m => m.UsersPage),
      },
    ],
  },
];
src/app/app.routes.ts
export const routes: Routes = [
  { path: '', component: HomeComponent },
  {
    path: 'admin',
    loadChildren: () =>
      import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
  },
];

/admin に初めて入った時点で admin.routes.ts と、その中で import するすべてのコンポーネントがチャンクとして受け取られます。ルートグループを 1 つの単位として扱うので、領域別のフォルダ構成とも自然に噛み合います。

Provider も lazy 区間にだけ #

Lazy 区間でだけ使うサービスがあるなら、その provider も lazy ルートの providers に入れておけば構いません。そうすれば、そのサービスのコードも lazy チャンクに一緒にまとまって入り、その領域に入る前まではメモリにインスタンス化もされません。

src/app/admin/admin.routes.ts
export const ADMIN_ROUTES: Routes = [
  {
    path: '',
    providers: [
      provideHttpClient(),
      AdminAnalyticsService,
      { provide: REPORT_API_BASE, useValue: '/api/admin' },
    ],
    loadComponent: () =>
      import('./pages/admin-shell.component').then(m => m.AdminShellComponent),
    children: [/* ... */],
  },
];

ルート単位の providers は、そのサブツリーに限定された 隔離されたインジェクタ を作ります。同じトークンがルートと lazy 区間の両方にあるとき、lazy 内では lazy 側のインスタンスが使われることになります。領域ごとに設定が異なる HTTP クライアント、分析サービスのようなものをすっきりと隔離できます。

ビルド分析 #

Lazy チャンクがちゃんと分かれているか確認するには、production ビルドの結果を見なければなりません。

production ビルド
ng build

ビルドが終わると、ターミナルにチャンク別のサイズが出力されます。

ビルド出力例
Initial chunk files | Names         |  Raw size
main.abc123.js      | main          |  142.5 kB
polyfills.def456.js | polyfills     |   33.2 kB

Lazy chunk files    | Names         |  Raw size
chunk-ghi789.js     | admin-routes  |   78.4 kB
chunk-jkl012.js     | reports-page  |   24.1 kB

Initial に入っているのが、最初のページを表示するときに受け取るコードです。ここに入るべきでない重いライブラリ (チャート、エディタ、PDF ビューアなど) が見えたら、lazy に移す候補です。

チャンクの中に正確に何が入っているかを見たければ、source-map-explorer が便利です。

source-map-explorer のインストールと実行
npm install -g source-map-explorer
ng build --source-map
source-map-explorer dist/my-app/browser/*.js

ブラウザにツリーマップが開き、どのライブラリがどのチャンクでどれだけ占めるかが一目で見えます。「なぜ main チャンクに chart.js が入っているの?」のようなデバッグをするときに決定打になります。

Preloading 戦略 #

Lazy の短所は、初回の遷移時点で短い遅延が生じる点です。ユーザーがメニューを押して画面が表示されるまでに、チャンクを受け取る時間が追加されるのです。これを preloading で緩和できます — ユーザーがメニューを押す前に、あらかじめバックグラウンドで受け取っておく戦略です。

src/app/app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withPreloading(PreloadAllModules)),
  ],
};

PreloadAllModules は、名前は古い NgModule 時代の名残ですが、standalone 時代でもそのまま動作する定数です。意味は「初期ロードが終わった直後、すべての lazy チャンクをバックグラウンドであらかじめ受け取れ」です。

すべてをあらかじめ受け取るのが負担なら、ユーザーが idle 状態のときだけ受け取ったり、特定のルートだけを選んで受け取るカスタム戦略を作れます。

src/app/preload-on-idle.strategy.ts
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of, timer, switchMap } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class PreloadOnIdleStrategy implements PreloadingStrategy {
  preload(_route: Route, load: () => Observable<unknown>): Observable<unknown> {
    return timer(2000).pipe(switchMap(() => load()));
  }
}
src/app/app.config.ts
provideRouter(routes, withPreloading(PreloadOnIdleStrategy)),

最初のページがレンダーされた後、2 秒後から lazy チャンクが静かに受け取られます。ユーザーがメニューを押す頃にはキャッシュからそのまま取り出せるので、体感の遅延がほぼありません。「lazy のビルドサイズの利点 + eager の即時性」を同時に取れる、コスパの良いパターンです。

よくあるミス #

Lazy loading を初めて適用するときに、いくつかの落とし穴があります。

1. lazy 区間で eager 側の大きなコンポーネントを import

lazy 内で誤った import
// admin/pages/dashboard.page.ts (lazy チャンク)
import { HomeChartComponent } from '../../home/home-chart.component';

HomeChartComponent が main チャンクに入っている重いコンポーネントだとしても、lazy 側で直接 import すると、ビルドツールがそれを lazy チャンクにも一緒に引き込みます。結果として同じコードが 2 つのチャンクに重複して入ってしまうのです。共有しなければならないなら、shared フォルダ に切り出して、両側が同じチャンク依存を持つように整理すべきです。

2. shared “kitchen sink” モジュール/配列

NgModule 時代によく作られた SharedModule を、standalone 時代でも似た形で作ろうとする方々がいます。「共通のコンポーネント、ディレクティブ、パイプを 1 つの配列にまとめておいて、どこででも imports に展開して使おう」という発想ですが、これが lazy チャンク分割を台無しにする主犯になります。1 つのコンポーネントだけ使いたくても、その配列が引き込むすべての依存関係が一緒に入ってくるからです。モダンな Angular では、コンポーネント単位で imports を直接明示する のが正攻法です。

3. providedIn: 'root' ですべてのサービスをまとめる

サービスを作るときに何気なく @Injectable({ providedIn: 'root' }) だけ使う方が多いです。本当にグローバルに使うサービスならそれで構いませんが、lazy 領域でだけ使うサービスなら、lazy ルートの providers に置くほうがチャンク分離に有利です。

4. tree-shakable でないライブラリ

これはユーザーのせいではなくライブラリのせいですが、あるライブラリは import を 1 行書くだけでパッケージ全体を引き込みます。source-map-explorer で確認し、サイズが異常に大きければ、ライブラリのドキュメントで「modular import」ガイドを探してください。例: import { format } from 'date-fns' のように関数単位で import しなければならない場合。

まとめ #

今回の記事では、モダンな Angular のコード分割戦略を一周してきました。中核を整理すると:

  • Standalone の明示的な imports がコード分割の堅実な土台である
  • NgModule は legacy。出会ったら認識しつつ、新しく書くときは standalone
  • Eager vs Lazy の判断基準は「初期バンドルに入れる価値があるか」
  • loadComponent はルート 1 つ、loadChildren はルートグループ、providers は領域の隔離
  • ビルド分析 で、どのコードがどのチャンクに入るかを定期的に確認
  • Preloading で lazy の遅延を事実上取り除く

次の記事である「Angular中級 #6 Guards と Resolver」では、Router のもう 1 つの軸 — ルートに入る前に権限を確認する Guards と、画面を表示する前にデータをあらかじめ受け取っておく Resolver を扱います。認証が絡む実務のルーティングで欠かせない道具たちです。

X