Angular中級 #6 Guards と Resolver

読了 9分

基礎 #6 Router では、ルートを定義し <router-outlet> に画面を描く方法を扱いました。そこまでが「どの URL のときに何を描くか」の話だったとすれば、その一段手前には常に 2 つの問いがついて回ります。

  • 「このユーザーがこのページに入ってもよいのか?」
  • 「画面を描く前に、データをあらかじめ受け取っておくほうがよいのではないか?」

前者を扱う道具が Guards、後者を扱う道具が Resolver です。どちらもルーターの流れをそっと横取りして決定を下したり準備をしたりする役割をします。今回の記事では、モダンな Angular の関数型スタイルを中心に Guards と Resolver を整理します。

Guard とは何か #

Guard は単純です。ルートの流れを横取りする関数 です。ユーザーがある URL へ遷移しようとするときに「通すか通さないか」を決定する検問所です。返値は次の 3 つのうち 1 つです。

  • true — 通過。元々向かおうとしていたルートへ移動
  • false — 遮断。移動を止め、現在の場所にとどまる
  • UrlTree — 別の経路へリダイレクト。router.createUrlTree(['/login']) のような形

同期で即時に決定することもできますし、PromiseObservable で非同期の決定もできます。トークン検証をサーバーに問い合わせ、その結果で通過の可否を決める形です。

Angular は検査のタイミングに応じて、複数種類の Guard を提供します。もっともよく使う 3 つは canActivatecanMatchcanDeactivate です。

canActivate — もっともよく使うガード #

canActivate は、ユーザーが特定のルートに 入ろうとする瞬間 に実行されます。認証ガードの 99% はここに入ります。

基礎 #5 Service と DI で扱ったパターンそのままに、認証状態を持つ AuthService があると仮定します — isLoggedIn()user() シグナルを公開する平凡なシングルトン Service です。これをガードの中で取り出して使います。

関数型ガード (Modern style) #

モダンな Angular はガードを 関数 で書くことを推奨します。型は CanActivateFn で、依存関係は inject() で取得します。

src/app/auth/auth.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

export const authGuard: CanActivateFn = (route, state) => {
  const auth = inject(AuthService);
  const router = inject(Router);

  if (auth.isLoggedIn()) {
    return true;
  }

  // 元々向かおうとしていた URL をクエリに保存 — ログイン後にその場所へ戻すため
  return router.createUrlTree(['/login'], {
    queryParams: { returnUrl: state.url },
  });
};

ルート定義には canActivate 配列に関数をそのまま入れます。

src/app/app.routes.ts
export const routes: Routes = [
  { path: '', component: HomeComponent },
  { path: 'mypage', canActivate: [authGuard], component: MyPageComponent },
  { path: 'login', component: LoginComponent },
];

canActivate配列 です。ガードを複数並べることができ、1 つでも false/UrlTree を返せばそこで流れが止まります。認証ガード + 権限ガードを一緒に置くパターンは、後ほど再度見ていきます。

注記
関数型ガードは Angular 14.2 で導入され、v16 から事実上の標準になりました。v15 までのクラスベースのガード (CanActivate インターフェース) は v15.2 から deprecated 状態です。新しいプロジェクトでは関数型で書いてください。既存のクラスガードを維持・保守する立場であれば、次節を参考にしてください。

クラスベースのガード (legacy) #

関数型が標準になる前は、ガードを クラス で書きました。既存のコードベースでは、依然としてよく見かけます。

legacy クラスガード
import { Injectable } from '@angular/core';
import { CanActivate, Router, UrlTree } from '@angular/router';
import { AuthService } from './auth.service';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(private auth: AuthService, private router: Router) {}

  canActivate(): boolean | UrlTree {
    return this.auth.isLoggedIn() ? true : this.router.createUrlTree(['/login']);
  }
}

ルートでは canActivate: [AuthGuard] でクラス自体を渡す形でした。動作は関数型とほぼ同じですが、クラスを定義し DI で受け取るボイラープレートが一段多くなります。また、関数型と違って、ルートの外 (たとえば別のガード関数の中) で再利用するのが難しいという短所があります。

新しいプロジェクトでは関数型、既存コードの維持・保守ではクラスガード — このように分けて考えていただければ構いません。

canMatch — マッチング自体を防ぐガード #

canActivate は「このルートがマッチした、入ってもよいか?」を問うところです。それゆえ loadComponent で lazy-loaded されるルートでも、すでにチャンクがダウンロードされた後で ガードが回ります。

canMatch は一段手前です。ルートがマッチする前に 検査するので、マッチに失敗するとチャンクを受け取らずに次のルートへ進みます。

src/app/auth/admin.guard.ts
import { inject } from '@angular/core';
import { CanMatchFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

export const adminMatch: CanMatchFn = () => {
  const auth = inject(AuthService);
  const user = auth.user();
  return user?.name === 'admin' ? true : inject(Router).createUrlTree(['/']);
};
src/app/app.routes.ts (canMatch の活用)
export const routes: Routes = [
  {
    path: 'admin',
    canMatch: [adminMatch],
    loadComponent: () => import('./pages/admin.component').then(m => m.AdminComponent),
  },
  // 同じ path を権限別に異なるマッチングにするパターン
  { path: 'admin', component: AccessDeniedComponent },
];

canMatchfalse を返せば そのルートはなかったものとして 次のルートマッチングへ進みます。権限のないユーザーに admin チャンクをダウンロードさせずに済むという点が中核です。lazy loading と一緒に使うときに真価が出ます。

canActivatecanMatch のどちらを使うか迷ったら、単純な基準があります。

  • 入ってもよいか問う + ダメならリダイレクトcanActivate
  • lazy チャンクすら受け取りたくない / 同じ path を権限別に異なるマッチングにするcanMatch

ほとんどの認証・権限チェックは canActivate で十分です。

canDeactivate — 離れるときの検査 #

canDeactivate は、ユーザーがページを 離れようとするとき に実行されます。「変更内容が保存されていません。本当に離れますか?」のようなパターンがここから出てきます。

まずコンポーネント側に「今離れてよいか」を知らせるメソッドを 1 つ置きます。

src/app/pages/edit-post.component.ts
export class EditPostComponent {
  private dirty = signal(false);

  markDirty() { this.dirty.set(true); }

  canLeave(): boolean {
    if (!this.dirty()) return true;
    return confirm('保存されていない変更内容があります。本当に離れますか?');
  }
}

ガードは、ルーターが離れようとしているコンポーネントのインスタンスを最初の引数として渡してくれます。

src/app/auth/can-leave.guard.ts
import { CanDeactivateFn } from '@angular/router';

interface HasCanLeave {
  canLeave: () => boolean | Promise<boolean>;
}

export const canLeaveGuard: CanDeactivateFn<HasCanLeave> = (component) => {
  return component.canLeave();
};
src/app/app.routes.ts (canDeactivate)
export const routes: Routes = [
  {
    path: 'posts/:id/edit',
    canDeactivate: [canLeaveGuard],
    component: EditPostComponent,
  },
];

ガードをコンポーネントではなくルートに置く理由は、コンポーネントが アンマウントされる直前にルーターが決定を下さなければならない からです。もう 1 つ、ガードを複数の編集画面で再利用できるという長所もあります — HasCanLeave インターフェースに従うコンポーネントなら、どこにでも差し込んで使えます。

Resolver — 画面進入前にデータをあらかじめ受け取る #

ここからは視点を変えます。「入ってもよいか」ではなく「入る前にデータをあらかじめ受け取っておこう」 です。

基本パターンはこうです。ユーザーが /posts/42 へ移動すると、画面が描かれる前にルーターが先に記事データを受け取ります。コンポーネントは初回レンダリングの瞬間からデータを手にした状態で始まるので、ローディングスピナーの分岐をすっきりと減らせます。

getPost(id) メソッドを持つ平凡な PostServiceObservable<Post> を返すと仮定しましょう。Resolver は ResolveFn 型の関数です。

src/app/posts/post.resolver.ts
import { inject } from '@angular/core';
import { ResolveFn } from '@angular/router';
import { PostService, Post } from './post.service';

export const postResolver: ResolveFn<Post> = (route) => {
  const id = Number(route.paramMap.get('id'));
  return inject(PostService).getPost(id);
};

ルートに登録するときは、resolve オブジェクトに キーと値のペア で渡します。キー名がコンポーネントがデータを取り出すときに使う名前になります。

src/app/app.routes.ts (resolve)
export const routes: Routes = [
  {
    path: 'posts/:id',
    resolve: { post: postResolver },
    component: PostDetailComponent,
  },
];

コンポーネントでは ActivatedRoutedata で結果を読みます。

src/app/pages/post-detail.component.ts
@Component({
  selector: 'app-post-detail',
  standalone: true,
  template: `
    <article>
      <h1>{{ post.title }}</h1>
      <p>{{ post.body }}</p>
    </article>
  `,
})
export class PostDetailComponent {
  private route = inject(ActivatedRoute);
  protected readonly post = this.route.snapshot.data['post'] as Post;
}

画面が描かれる瞬間、post はすでに埋まっています。コンポーネントの中で「ロード中」分岐を置く必要がありません。Resolver が作業中のときは、ルーターが まだルート遷移を始めていない状態 なので、ユーザーには前のページがそのまま見えています。遷移中のスピナーが必要なら、Routerevents を購読し、NavigationStart/NavigationEnd で別途の表示をします。

ヒント
Resolver は 必ず使わなければならない道具ではありません。コンポーネントの中で直接 fetch しつつ @if (loading()) { ... } の分岐を置くほうが、たいていはより単純です。Resolver が真価を発揮するのは — (1) データのない画面を絶対に見せたくないとき、(2) SEO・ソーシャルプレビューのために初回レンダリングでコンテンツが満たされている必要があるとき、(3) 同じデータを親/子ルートが両方とも見なければならないとき — 程度に絞って使うのが実用的です。

Guard vs Resolver #

どちらもルートの流れを横取りしますが、目的が違います

区分GuardResolver
問うこと「通してもよいか?」「データをあらかじめ受け取っておく」
返値boolean / UrlTreeデータ (またはその Observable/Promise)
失敗時ルート遮断・リダイレクトルート自体が進まない
よく使う場面認証・権限詳細ページのメインデータ

実行順序も決まっています。canMatchcanActivate (親→子) → resolve → コンポーネント生成 の順で流れます。すなわち Resolver が回る時点では、すでにすべてのガードが通過した後です。権限のないユーザーに対してデータを取得してしまうことは起こりません。

実務パターン — 1 つのルートに複数のガード + Resolver #

実務では、ガードと Resolver を 1 つのルートに一緒に掛けることが多いです。たとえば「管理者権限があり、記事が存在するときにだけ編集ページに入る」というシナリオです。

src/app/auth/role.guard.ts
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';

export const roleGuard = (role: string): CanActivateFn => {
  return () => {
    const user = inject(AuthService).user();
    return user?.name === role
      ? true
      : inject(Router).createUrlTree(['/forbidden']);
  };
};

ガード関数を ファクトリ (factory) の形で作っておけば、ルートごとに異なる権限を簡単に差し込めます。ルート定義はこうなります。

src/app/app.routes.ts (ガード + Resolver の合奏)
export const routes: Routes = [
  {
    path: 'admin/posts/:id/edit',
    canActivate: [authGuard, roleGuard('admin')],
    canDeactivate: [canLeaveGuard],
    resolve: { post: postResolver },
    component: EditPostComponent,
  },
];

読む順序がそのままシナリオになります。

  1. authGuard — ログインしていなければ /login
  2. roleGuard('admin') — admin でなければ /forbidden
  3. postResolver — 記事データをあらかじめ受け取っておく
  4. コンポーネント進入 — 初回レンダリングから記事データが手にある
  5. 離れるときに canLeaveGuard — 未保存変更の確認

ルートオブジェクト 1 つを見るだけで「このページの進入ポリシー + データポリシー + 離脱ポリシー」が一目で入ってきます。これがガードと Resolver をルートレベルに置く最大の利点です — ポリシーがコンポーネントの中に散らばらないのです

まとめ #

今回の記事では、ルートの流れを横取りする 2 つの道具を見てきました。整理すると:

  • Guard は「通すか」を問う関数。true / false / UrlTree を返す
  • canActivate — もっともよく使う認証・権限ガード
  • 関数型ガード + inject() がモダンな Angular の標準。クラスガードは legacy
  • canMatch — ルートマッチング自体を防ぎ、lazy チャンクのダウンロードまで節約
  • canDeactivate — 離れるときに「保存していないけど離れますか?」のパターン
  • Resolver は画面進入前にデータをあらかじめ受け取る関数。ResolveFn + inject()
  • 1 つのルートに 複数のガードと Resolver を一緒に 掛けて、進入・データ・離脱のポリシーをルートレベルにまとめる

ルートポリシーを扱う道具が揃ったら、次の段階は 「このガードが本当に意図どおりに動くか、Resolver が正しいデータを渡しているか」 を検証することです。次の記事である「Angular中級 #7 テスト」では、コンポーネント、Service、そしてガード/Resolver をどのようにユニットテストでまとめるか — TestBedHttpTestingController を中心に見ていきます。

X