CanDeactivate

2025-05-26

AngularにおけるCanDeactivateは、ルーティングガードの一種で、ユーザーが現在のコンポーネントから別のルートへ移動するのを防ぐための仕組みです。

簡単に言うと、ユーザーが入力途中のフォームがあるコンポーネントを離れようとした際に、「保存されていない変更があります。本当にページを離れますか?」といった確認メッセージを表示し、ユーザーの意図しないデータ損失を防ぐためによく利用されます。

CanDeactivateの主な機能

  1. ルーティングの制御: ユーザーが現在のルートから離れることを許可するかどうかを決定します。
  2. データ損失の防止: 特にフォームなどの入力があるコンポーネントで、未保存の変更がある場合にユーザーに警告し、誤ってページを離れるのを防ぎます。
  3. 確認メッセージの表示: ユーザーに「はい/いいえ」などの確認ダイアログを表示し、ナビゲーションを続行するかキャンセルするかを選択させることができます。

CanDeactivateの使い方(基本的な流れ)

  1. CanDeactivateインターフェースを実装するガードを作成する: まず、@angular/routerからCanDeactivateインターフェースをインポートし、それを実装するクラス(ガード)を作成します。このクラスには、canDeactivateというメソッドを実装する必要があります。

    import { Injectable } from '@angular/core';
    import { CanDeactivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
    import { Observable } from 'rxjs';
    
    // このインターフェースは、CanDeactivateガードがチェックするコンポーネントに実装されます。
    // コンポーネントがcanExitメソッドを持つことを保証します。
    export interface CanComponentDeactivate {
      canExit: () => Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
    }
    
    @Injectable({
      providedIn: 'root'
    })
    export class UnsavedChangesGuard implements CanDeactivate<CanComponentDeactivate> {
      canDeactivate(
        component: CanComponentDeactivate,
        currentRoute: ActivatedRouteSnapshot,
        currentState: RouterStateSnapshot,
        nextState?: RouterStateSnapshot
      ): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
        // コンポーネントがcanExitメソッドを持っている場合、それを呼び出してナビゲーションを制御します。
        // 持っていない場合は、デフォルトでtrueを返します(ナビゲーションを許可)。
        return component.canExit ? component.canExit() : true;
      }
    }
    
  2. 対象のコンポーネントでCanComponentDeactivateインターフェースを実装する: CanDeactivateガードによって保護したいコンポーネント(例:フォームを持つコンポーネント)は、先ほど定義したCanComponentDeactivateインターフェースを実装し、canExitメソッドを提供します。このcanExitメソッド内で、未保存の変更があるかどうかのチェックを行い、必要に応じてユーザーに確認を求めます。

    import { Component } from '@angular/core';
    import { Observable, of } from 'rxjs';
    import { CanComponentDeactivate } from './unsaved-changes.guard'; // 作成したガードのインターフェースをインポート
    
    @Component({
      selector: 'app-edit-item',
      template: `
        <h2>アイテム編集</h2>
        <form>
          <input type="text" [(ngModel)]="itemName" name="itemName">
          <button (click)="save()">保存</button>
        </form>
      `
    })
    export class EditItemComponent implements CanComponentDeactivate {
      itemName: string = 'テストアイテム';
      hasUnsavedChanges: boolean = false; // 未保存の変更があるかどうかのフラグ
    
      ngOnInit() {
        // 何らかの変更があったらhasUnsavedChangesをtrueにするロジック
        // 例: inputイベントなどでhasUnsavedChanges = true;
      }
    
      save() {
        // 保存処理
        this.hasUnsavedChanges = false;
        console.log('アイテムを保存しました');
      }
    
      canExit(): Observable<boolean> | boolean {
        if (this.hasUnsavedChanges) {
          // 未保存の変更がある場合、確認ダイアログを表示
          return confirm('未保存の変更があります。本当にページを離れますか?');
        }
        return true; // 変更がない場合はナビゲーションを許可
      }
    }
    
  3. ルーティング設定にCanDeactivateガードを追加する: 最後に、保護したいルートの定義にcanDeactivateプロパティを追加し、作成したガードを指定します。

    import { Routes } from '@angular/router';
    import { EditItemComponent } from './edit-item/edit-item.component';
    import { UnsavedChangesGuard } from './unsaved-changes.guard'; // 作成したガードをインポート
    
    export const routes: Routes = [
      {
        path: 'edit/:id',
        component: EditItemComponent,
        canDeactivate: [UnsavedChangesGuard] // ここでガードを指定
      },
      // 他のルート...
    ];
    

動作の仕組み

ユーザーがedit/:idルートにいるときに、ブラウザの戻るボタンをクリックしたり、別のリンクをクリックして移動しようとすると、AngularルーターはUnsavedChangesGuardcanDeactivateメソッドを呼び出します。

canDeactivateメソッドは、対象のEditItemComponentインスタンスのcanExitメソッドを呼び出します。

  • canExitfalseを返した場合:ナビゲーションはキャンセルされ、ユーザーは現在のコンポーネントに留まります。
  • canExittrueを返した場合:ナビゲーションが許可され、ユーザーは新しいルートに移動できます。
  • Angular v15以降の変更: Angular v15以降では、クラスベースのガードが非推奨になり、関数ベースのガードが推奨されています。上記はクラスベースの例ですが、関数ベースのガードも同様の目的で使用できます。

    // 関数ベースのCanDeactivateガードの例 (Angular v15+)
    import { CanDeactivateFn } from '@angular/router';
    import { EditItemComponent } from './edit-item/edit-item.component';
    
    export const unsavedChangesGuard: CanDeactivateFn<EditItemComponent> = (
      component: EditItemComponent
    ) => {
      if (component.hasUnsavedChanges) {
        return confirm('未保存の変更があります。本当にページを離れますか?');
      }
      return true;
    };
    
    // ルーティング設定
    export const routes: Routes = [
      {
        path: 'edit/:id',
        component: EditItemComponent,
        canDeactivate: [unsavedChangesGuard]
      },
    ];
    
  • 複数のガード: canDeactivateは配列を受け取るため、複数のガードを適用することができます。すべてのガードがtrueを返した場合にのみ、ナビゲーションが許可されます。

  • 非同期処理: canDeactivateメソッドは、Observable<boolean | UrlTree>Promise<boolean | UrlTree>を返すことができます。これにより、非同期の処理(例えば、サーバーへの保存確認や、ユーザーにダイアログを表示して待つなど)を行った後にナビゲーションを制御することが可能です。



CanDeactivateの一般的なエラーとトラブルシューティング

ガードが全く呼び出されない、または機能しない

考えられる原因

  • 対象コンポーネントがインターフェースを実装していない
    ガードの型引数Tに指定したインターフェース(例: CanComponentDeactivate)を、実際にガードされるコンポーネントが実装していない。
  • インターフェースの実装忘れ/間違い
    CanDeactivate<T>インターフェースをガードが実装していない、またはcanDeactivateメソッドのシグネチャが正しくない。
  • プロバイダーの不足
    ガードがDI(Dependency Injection)システムに認識されていない。
  • ルーティング設定の誤り
    canDeactivateプロパティがルート定義に正しく追加されていない、またはガードのクラスが配列として指定されていない。

トラブルシューティング

  • コンポーネントのインターフェース実装
    ガードの型引数として使用しているカスタムインターフェース(例: CanComponentDeactivate)が、ガードされるコンポーネントにimplements CanComponentDeactivateとして正しく実装されており、かつ必要なメソッド(例: canExit())が定義されているか確認してください。
  • メソッドシグネチャの確認
    ガードのcanDeactivateメソッドの引数と戻り値の型が、AngularのCanDeactivateインターフェース(または関数ベースのCanDeactivateFn)の定義と完全に一致しているか確認してください。特に、Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTreeという戻り値の型に注意してください。
  • プロバイダーの確認
    ガードクラスに@Injectable({ providedIn: 'root' })が付いているか、または@NgModuleproviders配列にガードが追加されているか確認してください。関数ベースのガードの場合はプロバイダーは不要です。
  • ルーティング設定の確認
    app-routing.module.tsや対応するルーティングモジュールで、canDeactivate: [YourGuardClass]のように配列で指定されているか確認してください。

非同期処理(Promise/Observable)が解決される前にナビゲーションが進んでしまう

考えられる原因

  • 非同期処理の戻り値の不適切さ
    canDeactivateメソッドが非同期処理(PromiseやObservable)を返しているが、それが解決される前にbooleanが返されている、またはasync/awaitが正しく使用されていない。

トラブルシューティング

  • async/awaitの使用
    Promiseベースの非同期処理の場合、asyncキーワードをcanDeactivateメソッドに付けて、awaitを使ってPromiseの解決を待つことができます。

    async canDeactivate(...): Promise<boolean> {
      if (this.hasUnsavedChanges) {
        const confirmed = await this.dialogService.openConfirmDialog('未保存の変更があります。本当にページを離れますか?');
        return confirmed;
      }
      return true;
    }
    
    • Promise<boolean>を返す場合: return new Promise(resolve => { /* 処理 */ resolve(true); }); のように、resolveが適切に呼び出されていることを確認します。
    • Observable<boolean>を返す場合: return of(true);return dialogRef.afterClosed(); のように、Observableが値を発行して完了することを確認します。特に、ユーザーの入力(確認ダイアログなど)を待つ場合は、その入力によってObservableが完了するように実装する必要があります。

ブラウザの「戻る」ボタンでCanDeactivateが動作しない、または挙動がおかしい

考えられる原因

  • ブラウザ履歴の操作の複雑さ
    CanDeactivateガードは、Angularルーターによってトリガーされるナビゲーションに対して動作します。ブラウザの「戻る」ボタンは、ブラウザ自身の履歴操作であり、Angularルーターの通常のナビゲーションとは少し異なる挙動をする場合があります。特に、location.back()などのJavaScriptでの履歴操作は、CanDeactivateが意図しないタイミングで発火したり、二重に発火したりする可能性があります。

トラブルシューティング

  • UrlTreeの活用
    canDeactivateガードはUrlTreeを返すことができます。これにより、ナビゲーションをキャンセルする代わりに、ユーザーを別のルートにリダイレクトさせることができます。

    import { Router, UrlTree } from '@angular/router';
    
    // ...
    canDeactivate(...): boolean | UrlTree {
      if (this.hasUnsavedChanges) {
        // 確認ダイアログなどでfalseが返されたら、特定のルートにリダイレクト
        return this.router.parseUrl('/some-other-page');
      }
      return true;
    }
    

    ただし、ブラウザの「戻る」ボタンの挙動を完全に制御するのは難しいため、ユーザーが「戻る」ボタンを押したときに意図しない挙動にならないよう、デザイン段階で考慮することが重要です。

  • window.onbeforeunloadの併用(推奨されないが代替策として)
    厳密にはAngularルーターとは関係ありませんが、ブラウザを閉じる、または別のサイトに移動する際に警告を表示するwindow.onbeforeunloadイベントハンドラを使用することもできます。ただし、これはユーザーがページを離れようとするすべてのケースで発動するため、CanDeactivateと併用する際は重複する警告にならないよう注意が必要です。

    // コンポーネント内
    @HostListener('window:beforeunload', ['$event'])
    unloadNotification($event: any) {
      if (this.hasUnsavedChanges) {
        $event.returnValue = true; // 警告メッセージを表示
      }
    }
    

    この方法は古いブラウザではカスタマイズ可能なメッセージが表示されていましたが、現代のブラウザでは固定のメッセージしか表示されません。

コンポーネントインスタンスがnullになる (Angular 4系での既知の問題)

考えられる原因

  • Angularのバージョンが古い
    Angular 4系の一部のバージョンでは、特定の条件下(特に遅延ロードされるモジュールなど)でcanDeactivatecomponent引数がnullになるバグが存在しました。

トラブルシューティング

  • component引数のnullチェック
    予期せぬnull値に備えて、ガード内でcomponent引数がnullでないかチェックするロジックを追加すると安全です。

    canDeactivate(component: CanComponentDeactivate, ...): Observable<boolean> | Promise<boolean> | boolean {
      if (!component || !component.canExit) { // nullチェックとメソッドの存在チェック
        return true;
      }
      return component.canExit();
    }
    
  • Angularのバージョンアップ
    もしAngularの古いバージョンを使用しているのであれば、最新の安定版にアップデートすることを強く推奨します。

ExpressionChangedAfterItHasBeenCheckedErrorが発生する

考えられる原因

  • 非同期処理内で変更検知が発生している
    canDeactivate内で非同期処理を実行し、その処理内でコンポーネントの状態(特にテンプレートにバインドされているプロパティ)が変更されると、ExpressionChangedAfterItHasBeenCheckedErrorが発生することがあります。これは、Angularの変更検知サイクルがすでに完了した後に、コンポーネントの状態が変化したために起こります。

トラブルシューティング

  • RxJSのsetTimeoutまたはqueueSchedulerを利用する
    変更を次の変更検知サイクルに遅延させることでエラーを回避できますが、根本的な解決策ではない場合があります。
  • 非同期処理の後にChangeDetectorRef.detectChanges()を呼び出す
    非同期処理が完了し、コンポーネントの状態が更新された後で、手動で変更検知をトリガーします。
    import { ChangeDetectorRef } from '@angular/core';
    
    // コンポーネントのコンストラクタで注入
    constructor(private cdr: ChangeDetectorRef) {}
    
    async canExit(): Promise<boolean> {
      if (this.hasUnsavedChanges) {
        const confirmed = await this.dialogService.openConfirmDialog('...');
        this.cdr.detectChanges(); // 変更検知をトリガー
        return confirmed;
      }
      return true;
    }
    

遅延ロードされるモジュールでCanDeactivateが動作しない

考えられる原因

  • モジュールの依存関係の誤り
    ガードが定義されているモジュールと、ガードされるコンポーネントが含まれるモジュールの間で、プロバイダーの可視性に関する問題がある可能性があります。

トラブルシューティング

  • ガードを共有モジュールで提供
    複数のモジュールでガードを使用する場合、共有モジュールを作成し、そこでガードを提供して、その共有モジュールを必要な他のモジュールにインポートします。ただし、providedIn: 'root'が推奨される方法です。
  • providedIn: 'root'の使用
    ガードをルートインジェクターで提供することで、アプリケーション全体で利用可能になり、遅延ロードされるモジュールでも問題なく機能します。
  • ドキュメントの再確認
    Angularの公式ドキュメント(特にルーティングとガードのセクション)を再度確認し、最新のベストプラクティスや非推奨になった機能がないか確認します。特にAngular v15以降では関数ベースのガードが推奨されている点に注意が必要です。
  • 最小限の再現コードを作成
    問題が発生した場合、その問題を再現する最小限のコード(StackBlitzなど)を作成することで、原因の特定と解決が容易になります。
  • Angular DevToolsの活用
    ブラウザのAngular DevTools拡張機能を使って、ルーターの状態やコンポーネントのプロパティを検査し、デバッグに役立てます。
  • コンソールログの活用
    canDeactivateメソッドや、コンポーネントのcanExitメソッドの開始と終了時にconsole.log()を仕込み、いつ、どのような値が返されているかを確認します。


この例では、ユーザーがフォームの編集中に、保存せずに他のページへ移動しようとした場合に、確認ダイアログを表示してデータ損失を防ぐシナリオを想定します。

シナリオ

  1. 商品編集コンポーネント (ProductEditComponent): 商品の情報を編集するフォームを持ちます。
  2. 未保存の変更: ユーザーがフォームの内容を変更したが、まだ「保存」ボタンを押していない状態を「未保存の変更がある」とみなします。
  3. CanDeactivateガード: ProductEditComponentから他のルートへ移動する際に、未保存の変更があるかをチェックし、確認ダイアログを表示します。
  4. ルーティング: ProductEditComponentへのルートにCanDeactivateガードを設定します。

コード例

can-component-deactivate.interface.ts (カスタムインターフェース)

まず、CanDeactivateガードがチェックするコンポーネントが実装すべきメソッドを定義するインターフェースを作成します。これは、ガードが特定のメソッド(例: canExit)をコンポーネントに持っていることを保証するために便利です。

// src/app/shared/can-component-deactivate.interface.ts
import { Observable } from 'rxjs';
import { UrlTree } from '@angular/router';

export interface CanComponentDeactivate {
  // このメソッドが、コンポーネントがdeactivate(非活性化)できるかを判断します。
  // true: 移動を許可
  // false: 移動を阻止
  // UrlTree: 特定のURLにリダイレクト
  canExit: () => Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree;
}

unsaved-changes.guard.ts (CanDeactivate ガード)

次に、CanDeactivateインターフェースを実装するガードを作成します。このガードは、ルーティングを許可するかどうかを決定するロジックを含みます。

Angular v15+ の関数ベースのガード (推奨)

// src/app/guards/unsaved-changes.guard.ts
import { CanDeactivateFn } from '@angular/router';
import { CanComponentDeactivate } from '../shared/can-component-deactivate.interface';

// CanDeactivateFn<T> の T は、ガードがチェックするコンポーネントの型です。
export const unsavedChangesGuard: CanDeactivateFn<CanComponentDeactivate> = (
  component: CanComponentDeactivate
) => {
  // コンポーネントが canExit メソッドを持っているか、かつ実際にそのメソッドが定義されているかを確認
  // (メソッドが存在しない場合も考慮し、デフォルトで true を返す)
  if (component && typeof component.canExit === 'function') {
    // コンポーネントの canExit メソッドを呼び出す
    return component.canExit();
  }
  // canExit メソッドがない場合、またはコンポーネントが null の場合は、移動を許可する
  return true;
};
// src/app/guards/unsaved-changes.guard.ts (クラスベースの例)
import { Injectable } from '@angular/core';
import { CanDeactivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angular/router';
import { Observable } from 'rxjs';
import { CanComponentDeactivate } from '../shared/can-component-deactivate.interface';

@Injectable({
  providedIn: 'root' // アプリケーション全体で利用可能にする
})
export class UnsavedChangesGuard implements CanDeactivate<CanComponentDeactivate> {
  canDeactivate(
    component: CanComponentDeactivate,
    currentRoute: ActivatedRouteSnapshot,
    currentState: RouterStateSnapshot,
    nextState?: RouterStateSnapshot
  ): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
    // コンポーネントが canExit メソッドを持っているかを確認
    if (component.canExit) {
      return component.canExit();
    }
    // canExit メソッドがない場合は、移動を許可する
    return true;
  }
}

product-edit.component.ts (対象コンポーネント)

CanDeactivateガードによって保護したいコンポーネントです。ここでは、canExitメソッドを実装し、未保存の変更があるかどうかをチェックします。

// src/app/products/product-edit/product-edit.component.ts
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { Observable, of } from 'rxjs';
import { CanComponentDeactivate } from '../../shared/can-component-deactivate.interface'; // 作成したインターフェースをインポート

@Component({
  selector: 'app-product-edit',
  templateUrl: './product-edit.component.html',
  styleUrls: ['./product-edit.component.css']
})
export class ProductEditComponent implements OnInit, CanComponentDeactivate {
  productName: string = '';
  productPrice: number = 0;
  hasUnsavedChanges: boolean = false; // 未保存の変更があるかのフラグ

  constructor(private router: Router) { }

  ngOnInit(): void {
    // 実際のアプリケーションでは、ここに商品データをロードするロジックが入ります。
    // 初期値設定
    this.productName = '初期商品名';
    this.productPrice = 1000;
  }

  // フォームの入力フィールドが変更されたときに呼び出す想定
  onInputChange(): void {
    this.hasUnsavedChanges = true;
    console.log('未保存の変更があります。');
  }

  // 保存ボタンがクリックされたとき
  saveProduct(): void {
    // 実際の保存処理 (API呼び出しなど)
    console.log('商品を保存しました:', this.productName, this.productPrice);
    this.hasUnsavedChanges = false; // 保存したのでフラグをリセット
    this.router.navigate(['/products']); // 保存後、商品一覧ページへ移動
  }

  // CanComponentDeactivate インターフェースのメソッドを実装
  canExit(): Observable<boolean> | Promise<boolean> | boolean {
    if (this.hasUnsavedChanges) {
      // 未保存の変更がある場合、ユーザーに確認ダイアログを表示
      const confirmResult = confirm('未保存の変更があります。本当にページを離れますか?');
      if (confirmResult) {
        console.log('ユーザーはページを離れることを選択しました。');
        return true; // ページを離れることを許可
      } else {
        console.log('ユーザーはページを離れないことを選択しました。');
        return false; // ページを離れることを阻止
      }
    }
    // 未保存の変更がない場合は、無条件に移動を許可
    return true;
  }
}

product-edit.component.html (対象コンポーネントのテンプレート)

簡単なフォームのテンプレートです。

<div class="product-edit-container">
  <h2>商品編集</h2>
  <form>
    <div class="form-group">
      <label for="productName">商品名:</label>
      <input type="text" id="productName" [(ngModel)]="productName" name="productName" (input)="onInputChange()">
    </div>
    <div class="form-group">
      <label for="productPrice">価格:</label>
      <input type="number" id="productPrice" [(ngModel)]="productPrice" name="productPrice" (input)="onInputChange()">
    </div>
    <button type="button" (click)="saveProduct()">保存</button>
  </form>

  <p *ngIf="hasUnsavedChanges" class="unsaved-warning">
    未保存の変更があります。
  </p>

  <hr>
  <a routerLink="/home">ホームに戻る</a>
  <br>
  <a routerLink="/products">商品一覧へ</a>
</div>

app-routing.module.ts (ルーティング設定)

最後に、ProductEditComponentのルートにCanDeactivateガードを設定します。

// src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
import { ProductsListComponent } from './products/products-list/products-list.component';
import { ProductEditComponent } from './products/product-edit/product-edit.component';
// 関数ベースのガードをインポート (Angular v15+の場合)
import { unsavedChangesGuard } from './guards/unsaved-changes.guard';
// クラスベースのガードをインポート (古いAngularの場合)
// import { UnsavedChangesGuard } from './guards/unsaved-changes.guard';

const routes: Routes = [
  { path: '', redirectTo: '/home', pathMatch: 'full' },
  { path: 'home', component: HomeComponent },
  { path: 'products', component: ProductsListComponent },
  {
    path: 'products/edit/:id', // IDはパスに含まれるが、この例では使用しない
    component: ProductEditComponent,
    canDeactivate: [unsavedChangesGuard] // ここでガードを設定!
    // クラスベースの場合: canDeactivate: [UnsavedChangesGuard]
  },
  { path: '**', redirectTo: '/home' } // 存在しないパスはホームへリダイレクト
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

これらのコンポーネントはシンプルなので、ここではテンプレートのみ示します。

// src/app/home/home.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-home',
  template: `
    <h2>ホーム</h2>
    <p>ようこそ!</p>
    <p><a routerLink="/products/edit/1">商品編集ページへ</a></p>
    <p><a routerLink="/products">商品一覧ページへ</a></p>
  `
})
export class HomeComponent { }
// src/app/products/products-list/products-list.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-products-list',
  template: `
    <h2>商品一覧</h2>
    <ul>
      <li>商品 A</li>
      <li>商品 B</li>
    </ul>
    <p><a routerLink="/products/edit/1">商品編集ページへ</a></p>
    <p><a routerLink="/home">ホームへ戻る</a></p>
  `
})
export class ProductsListComponent { }

app.module.ts

必要なコンポーネントとモジュールをインポートし、宣言します。

// src/app/app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms'; // [(ngModel)] を使用するために必要

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import { ProductsListComponent } from './products/products-list/products-list.component';
import { ProductEditComponent } from './products/product-edit/product-edit.component';

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    ProductsListComponent,
    ProductEditComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    FormsModule // FormsModule をインポート
  ],
  providers: [], // ガードは providedIn: 'root' で提供されるため、ここに追加不要
  bootstrap: [AppComponent]
})
export class AppModule { }

実行手順

  1. 上記のようにファイルをプロジェクトの適切なパスに配置します。
  2. src/app/products/ フォルダと src/app/guards/src/app/shared/ フォルダを新しく作成する必要があるかもしれません。
  3. ターミナルで ng serve --open を実行し、アプリケーションを起動します。
  1. ブラウザでアプリケーションが開かれたら、「商品編集ページへ」のリンクをクリックして ProductEditComponent に移動します。
  2. 商品名や価格の入力フィールドを少し変更します(例: 「テスト」と入力)。この時点で、hasUnsavedChangestrue になり、テンプレートに「未保存の変更があります。」と表示されます。
  3. ページ上の「ホームに戻る」または「商品一覧へ」のリンクをクリックします。
  4. 「未保存の変更があります。本当にページを離れますか?」という確認ダイアログが表示されるはずです。
    • 「OK」をクリックすると、未保存の変更があるにもかかわらず、ページ移動が許可されます。
    • 「キャンセル」をクリックすると、ページ移動が阻止され、ProductEditComponent に留まります。
  5. 次に、フォームの内容を変更し、「保存」ボタンをクリックします。hasUnsavedChangesfalse にリセットされ、自動的に商品一覧ページへ移動します。
  6. この状態で「ホームに戻る」などのリンクをクリックしても、確認ダイアログは表示されません。


コンポーネント内の直接的なロジック (Routerサービスと条件付きナビゲーション)

最も直接的な代替手段は、CanDeactivateガードを使用せずに、コンポーネント内で直接ナビゲーションのロジックを管理することです。

仕組み

  • ユーザーの選択に基づいて、this.router.navigate()またはthis.router.navigateByUrl()を条件付きで呼び出します。
  • 未保存の変更があるかをチェックし、必要に応じてconfirm()ダイアログを表示します。
  • ユーザーがページを離れようとするイベント(例:リンククリック、フォーム送信など)を捕捉します。
  • コンポーネント内でRouterサービスを注入します。


// src/app/products/product-edit-manual/product-edit-manual.component.ts
import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';

@Component({
  selector: 'app-product-edit-manual',
  template: `
    <h2>商品編集 (手動ナビゲーション)</h2>
    <form>
      <div class="form-group">
        <label for="productName">商品名:</label>
        <input type="text" id="productName" [(ngModel)]="productName" name="productName" (input)="onInputChange()">
      </div>
      <button type="button" (click)="saveProduct()">保存</button>
    </form>

    <p *ngIf="hasUnsavedChanges" class="unsaved-warning">
      未保存の変更があります。
    </p>

    <hr>
    <button (click)="navigateToHome()">ホームに戻る (手動)</button>
    <button (click)="navigateToProducts()">商品一覧へ (手動)</button>
  `,
  styleUrls: ['./product-edit-manual.component.css']
})
export class ProductEditManualComponent implements OnInit {
  productName: string = '';
  hasUnsavedChanges: boolean = false;

  constructor(private router: Router) { }

  ngOnInit(): void {
    this.productName = '初期商品名';
  }

  onInputChange(): void {
    this.hasUnsavedChanges = true;
    console.log('未保存の変更があります (手動)。');
  }

  saveProduct(): void {
    console.log('商品を保存しました (手動):', this.productName);
    this.hasUnsavedChanges = false;
    this.router.navigate(['/products-list-manual']);
  }

  // 手動でナビゲーションを制御するメソッド
  private confirmNavigation(targetUrl: string): void {
    if (this.hasUnsavedChanges) {
      if (confirm('未保存の変更があります。本当にページを離れますか?')) {
        this.router.navigateByUrl(targetUrl);
      }
    } else {
      this.router.navigateByUrl(targetUrl);
    }
  }

  navigateToHome(): void {
    this.confirmNavigation('/home-manual');
  }

  navigateToProducts(): void {
    this.confirmNavigation('/products-list-manual');
  }
}

利点

  • 直接的な制御
    コンポーネント内で直接状態とナビゲーションを管理できます。
  • シンプルさ
    ルーティングガードの概念を導入する必要がないため、小規模なアプリケーションや特定のコンポーネントでのみ必要な場合に実装が簡単です。

欠点

  • 網羅性の低さ
    routerLinkディレクティブを使ったナビゲーションや、ブラウザの「戻る/進む」ボタン、URL直接入力など、コンポーネントが直接制御できないナビゲーションイベントには対応できません。これは重大な欠点です。
  • 再利用性の低さ
    同じロジックを複数のコンポーネントで繰り返すことになります。

window.onbeforeunloadイベントハンドラ

これはWeb標準の機能で、ユーザーが現在のページを離れようとするとき(ブラウザタブを閉じる、新しいURLに移動する、戻る/進むボタンなど)に警告を表示できます。

仕組み

  • イベントハンドラ内で、条件(例:未保存の変更)が満たされた場合、イベントオブジェクトのreturnValueプロパティを設定します(または、レガシーブラウザ向けに文字列を返します)。
  • コンポーネントのライフサイクル中にwindow.onbeforeunloadイベントにイベントリスナーを追加します。


// src/app/products/product-edit-beforeunload/product-edit-beforeunload.component.ts
import { Component, OnInit, HostListener } from '@angular/core';

@Component({
  selector: 'app-product-edit-beforeunload',
  template: `
    <h2>商品編集 (onbeforeunload)</h2>
    <form>
      <div class="form-group">
        <label for="productName">商品名:</label>
        <input type="text" id="productName" [(ngModel)]="productName" name="productName" (input)="onInputChange()">
      </div>
      <button type="button" (click)="saveProduct()">保存</button>
    </form>

    <p *ngIf="hasUnsavedChanges" class="unsaved-warning">
      未保存の変更があります。
    </p>

    <hr>
    <a routerLink="/home-beforeunload">ホームに戻る</a>
    <a routerLink="/products-list-beforeunload">商品一覧へ</a>
  `,
  styleUrls: ['./product-edit-beforeunload.component.css']
})
export class ProductEditBeforeunloadComponent implements OnInit {
  productName: string = '';
  hasUnsavedChanges: boolean = false;

  constructor() { }

  ngOnInit(): void {
    this.productName = '初期商品名';
  }

  onInputChange(): void {
    this.hasUnsavedChanges = true;
    console.log('未保存の変更があります (onbeforeunload)。');
  }

  saveProduct(): void {
    console.log('商品を保存しました (onbeforeunload):', this.productName);
    this.hasUnsavedChanges = false;
  }

  // ユーザーがページを離れようとしたときに呼び出される
  @HostListener('window:beforeunload', ['$event'])
  unloadNotification($event: any): boolean {
    if (this.hasUnsavedChanges) {
      // 現代のブラウザでは、カスタムメッセージは表示されず、
      // ブラウザが提供するデフォルトのメッセージが表示されます。
      // イベントオブジェクトの returnValue を設定することで警告が表示されます。
      $event.returnValue = true;
      return true; // または true を返すだけでも機能する場合があります
    }
    return false; // 未保存の変更がなければ警告を表示しない
  }
}

利点

  • Web標準
    Angular固有の機能ではなく、ブラウザの標準機能です。
  • 網羅性
    routerLink、URL直接入力、ブラウザの戻る/進むボタン、タブ/ウィンドウを閉じる操作など、すべてのナビゲーションイベントに対して機能します。

欠点

  • CanDeactivateとの重複
    CanDeactivateガードと併用すると、二重に警告が表示される可能性があります。
  • 強制終了
    ユーザーが警告を無視してページを強制的に離れる選択をした場合、Angularアプリケーションのクリーンアップやデータ保存の機会が失われる可能性があります。
  • 確認フローの制限
    confirm()のような「はい/いいえ」の選択肢を提供できますが、カスタムモーダルダイアログを表示してよりリッチなUXを提供することはできません(イベントハンドラの実行が非同期操作をブロックするため)。
  • UXの制限
    現代のブラウザでは、表示される警告メッセージをカスタマイズできません。常にブラウザが提供する固定のメッセージが表示されます。

状態管理ライブラリ (例: NgRx, Akita, NGXS) と連携

これは直接的な代替手段というよりは、CanDeactivateガードと組み合わせて使用されることが多いアプローチです。

仕組み

  • CanDeactivateガード内で、ストアから未保存の状態を取得し、それに基づいてナビゲーションを判断します。
  • アプリケーションの状態(例:フォームの未保存状態)を状態管理ストアで管理します。

例 (概念)

// (状態管理ストアのサービス)
// state.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

@Injectable({ providedIn: 'root' })
export class AppStateService {
  private _hasUnsavedChanges = new BehaviorSubject<boolean>(false);
  hasUnsavedChanges$: Observable<boolean> = this._hasUnsavedChanges.asObservable();

  setUnsavedChanges(value: boolean): void {
    this._hasUnsavedChanges.next(value);
  }
}

// (CanDeactivate ガード)
// unsaved-changes-state.guard.ts
import { CanDeactivateFn } from '@angular/router';
import { AppStateService } from '../services/state.service';
import { inject } from '@angular/core'; // Angular v14+ で導入された inject 関数

export const unsavedChangesStateGuard: CanDeactivateFn<any> = (
  component: any
) => {
  const appState = inject(AppStateService); // inject を使用してサービスを取得

  return appState.hasUnsavedChanges$.pipe(
    // map を使用して、現在の未保存の状態に基づいて確認ダイアログを表示
    // confirmation の結果を Observable として返す
    map(hasUnsavedChanges => {
      if (hasUnsavedChanges) {
        return confirm('状態管理経由で未保存の変更があります。本当にページを離れますか?');
      }
      return true;
    }),
    take(1) // Observable が完了するように take(1) を追加
  );
};

// (コンポーネント)
// product-edit-state/product-edit-state.component.ts
import { Component, OnInit } from '@angular/core';
import { AppStateService } from '../../services/state.service';

@Component({
  selector: 'app-product-edit-state',
  template: `... (通常のフォーム)`,
})
export class ProductEditStateComponent implements OnInit {
  productName: string = '';

  constructor(private appState: AppStateService) { }

  ngOnInit(): void {
    this.productName = '初期商品名';
    // 初期状態をセット
    this.appState.setUnsavedChanges(false);
  }

  onInputChange(): void {
    this.appState.setUnsavedChanges(true);
  }

  saveProduct(): void {
    this.appState.setUnsavedChanges(false); // 保存後はフラグをリセット
    // ... 保存ロジック ...
  }
}

利点

  • CanDeactivateとの組み合わせ
    CanDeactivateガードはストアの状態をチェックするだけなので、ガード自体はシンプルに保てます。
  • スケーラビリティ
    大規模なアプリケーションや複雑な状態管理が必要な場合に有利です。
  • 単一の情報源
    アプリケーションの未保存の状態が中央のストアで管理されるため、一貫性とデバッグが容易になります。
  • オーバーヘッド
    小規模なアプリケーションでは、不必要に複雑になる可能性があります。
  • 学習曲線
    状態管理ライブラリの導入には、追加の概念と学習コストがかかります。
  • 状態管理ライブラリとの連携: これはCanDeactivateの代替というよりは、CanDeactivateの基盤となる未保存の状態をより効果的に管理するための手法です。大規模なアプリケーションで状態の一貫性を保つために非常に有効です。

  • window.onbeforeunload: ブラウザレベルでの最も網羅的な警告メカニズムですが、UXのカスタマイズが不可能であり、Angularアプリケーションのライフサイクルとの統合が難しいため、CanDeactivateの代替としては一般的に推奨されません。ただし、CanDeactivateが捕捉できないブラウザの閉じる操作などにも対応できるため、CanDeactivateと併用して二重の保護を提供する場合があります(ただし、UXの重複に注意)。

  • コンポーネント内の直接ロジック: routerLink以外の方法でのナビゲーション(ブラウザの戻る/進むボタンなど)には対応できないため、限定的なユースケース(例:フォーム送信後の特定のリンククリックのみを制御する場合)にのみ適しています。

  • CanDeactivateガード (推奨): ほとんどのAngularアプリケーションで、ルーティングに関するデータ損失防止の最も堅牢でAngularらしい方法です。ルーティングシステムと密接に統合されており、非同期処理、複数のガード、再利用性をサポートします。