Angularでページ離脱確認を実装する


りんごです。
この記事は[Angular Advent Calendar 2017](https://qiita.com/advent-calendar/2017/angular)の12月15日の記事です。

## はじめに

よくあるWebの入力フォームのある画面や何かしらのデータを編集するWebアプリの画面など、不意に誤ってブラウザのタブを閉じてしまうと悲しい思いをするケースがあります。
そういった画面を実装するには、次のようにwindowの[beforeunload](https://developer.mozilla.org/ja/docs/Web/API/WindowEventHandlers/onbeforeunload)イベントを使うことで離脱確認ダイアログを表示するのが一般的です。

```js
// beforeunloadイベントのreturnValueにtrueを設定し離脱確認ダイアログを表示する
window.addEventListener('beforeunload', function(event) {
    event.returnValue = true;
});
```

![beforeunload.png](https://qiita-image-store.s3.amazonaws.com/0/25045/7f82ad5d-9396-b77d-2e4d-08ec2402e313.png "beforeunload.png")

しかし、[Angular](https://angular.io/)ではwindowのイベントだけでなく[Router](https://angular.io/guide/router)によって画面遷移を制御します。そのため、beforeunloadイベントだけではページ離脱確認を実装しきれません。
今回はAngularでのページ離脱確認の実装方法を紹介します。

## CanDeactivateを使う

Angularでページ離脱確認を行うには、beforeunloadイベントを処理するだけでは足りません。
Angular内での画面遷移をキャンセルできるCanDeactivateという機能を組み合わせることで、Routerでの画面遷移時にもページ離脱確認を行えます。

CanDeactivateについては、入力を保存していないまま画面遷移してしまわないようにする方法を公式のドキュメントで紹介しています。
https://angular.io/guide/router#candeactivate-handling-unsaved-changes

本記事ではさらにbeforeunloadイベントを組み合わせる方法を紹介します。

## 実装例

beforeunloadイベントの発火時は従来通りブラウザが表示する離脱確認ダイアログに任せ、Routerの画面遷移はconfirmダイアログで離脱確認を行う実装を作ってみました。
実装の要点になるのは次の3箇所です。次節以降で解説していきます。

- CanDeactivateの実装
- 画面コンポーネント側の実装
- ルーティングの設定にCanDeactivateを追加

今回紹介するサンプルコードが動作するページとリポジトリは次のURLにあります。

- https://ng-beforeunload-sample.firebaseapp.com/sample
- https://github.com/mstssk/ng-beforeunload-sample


### CanDeactivateの実装

次のコードはCanDeactivateの実装です。コンポーネントから「ページ離脱確認を行うかどうか」を受け取るOnBeforeunloadインターフェイスも一緒に実装しています。
[canDeactivate](https://angular.io/api/router/CanDeactivate#canDeactivate)メソッドは第1引数に離脱しようとしているコンポーネントのインスタンスを受け取ります。このコードではコンポーネントから`shouldConfirmOnBeforeunload`メソッド経由で「ページ離脱確認を行うかどうか」をbooleanで受け取り、離脱確認としてconfirmダイアログを表示します。
Routerは`canDeactivate`メソッドがtrueを返せば画面遷移処理を行い、falseを返せば現在の画面に留まります。

```beforeunload.guard.ts
export interface OnBeforeunload {
  shouldConfirmOnBeforeunload: () => boolean;
}

@Injectable()
export class BeforeunloadGuard implements CanDeactivate<OnBeforeunload> {
  canDeactivate(component: OnBeforeunload) {
    if (component.shouldConfirmOnBeforeunload()) {
      const msg = 'このページを離れてもよろしいですか？'
        + '\n行った変更が保存されない可能性があります。';
      return confirm(msg);
    }
    return true;
  }
}
```

今回はブラウザのconfirmを使って確認ダイアログを表示していますが、独自に作ったダイアログなどを用いても問題ありません。
`canDeactivate`メソッドの戻り値はbooleanのPromiseやObservableでもよいので、必要に応じて作り変えましょう。

### 画面コンポーネント側の実装

次はコンポーネント側の実装です。
コンポーネントでは「ページ離脱確認を行うかどうか」を返す`shouldConfirmOnBeforeunload`と、windowのbeforeunloadイベントのハンドリングを実装します。
このコードの場合は、コンポーネントの`data`プロパティに何かしらの入力があれば`shouldConfirmOnBeforeunload`がtrueを返すため、CanDeactivateでconfirmダイアログによる離脱確認が表示されます。
一方、windowのbeforeunloadの処理もこのコードで行っています。ブラウザのイベントリスナを登録する`@HostListener`を使い、windowのbeforeunloadイベントの`returnValue`にtrueを与えブラウザの離脱確認ダイアログを表示させています。

```sample.component.ts
@Component({
  selector: 'app-sample',
  templateUrl: './sample.component.html',
  styleUrls: ['./sample.component.css']
})
export class SampleComponent implements OnBeforeunload {

  data = '';

  shouldConfirmOnBeforeunload() {
    return !!this.data;
  }

  @HostListener('window:beforeunload', ['$event'])
  beforeUnload(e: Event) {
    if (this.shouldConfirmOnBeforeunload()) {
      e.returnValue = true;
    }
  }
}
```

### ルーティングの設定にCanDeactivateを追加

最後に、コンポーネントに対してCanDeactivateを指定するルーティング設定を記述します。
ルーティングの設定にて、パスとコンポーネントの対応を記述している箇所に`canDeactivate: [BeforeunloadGuard]`を追記します。

```app.routing.ts
export const ROUTES: Routes = [
  // ...
  { path: 'sample', component: SampleComponent, canDeactivate: [BeforeunloadGuard] },
  // ...
];
```

### その他の実装方法

ここまでで紹介したコードはあくまで実装の一例です。

今回は試みの意味もありconfirmダイアログをCanDeactivate内で呼び出しましたが、confirmダイアログをコンポーネント側で呼び出しても同じ挙動を実装することはできます。コンポーネント側の入力状況によってダイアログのメッセージを差し替えたい場合はこちらの方がよいでしょう。

逆にCanDeactivateの側に極力隠蔽してしまう方法もあります。
力技ですが、CanDeactivateから呼び出すコンポーネントのメソッドを`shouldConfirmOnBeforeunload`ではなく`beforeUnload`にしてしまい、引数に渡したダミーのEventオブジェクトにreturnValueが設定されるかどうかで離脱確認の表示有無を切り替えます。
この方法は気持ち悪いものの、離脱確認ダイアログの表示に関する実装をできるだけCanDeactivateに寄せることで、個々のコンポーネントの実装がすっきりします。


## 注意点

Angularで離脱確認を実装する際の注意点をいくつか紹介しておきます。

### ブラウザのhistoryが壊れることがある

CanDeactivateによって遷移をキャンセルすると、ブラウザ側のhistoryがおかしな挙動をするケースが報告されています。
執筆時点（2017/12/14）では、まだ解決されておらず回避策も無いようです。

- https://github.com/angular/angular/issues/13586

### 離脱確認ダイアログに独自メッセージは使用できない

古いブラウザではbeforeunloadイベントのreturnValueに任意の文字列を設定することで離脱確認ダイアログのメッセージを制御できました。
しかし、最近のブラウザでは独自のメッセージを表示することは出来ず、入力内容を履きしてページを閉じることを確認する旨のブラウザ固定のメッセージが表示されるだけです。
次のURLの注記も参照してください。

- https://developer.mozilla.org/ja/docs/Web/API/WindowEventHandlers/onbeforeunload#Notes

### Mobile Safariではbeforeunloadイベントが使えない

Mobile Safariではbeforeunloadイベントが使えません。
[Appleの公式ドキュメントの対応イベント一覧](https://developer.apple.com/library/content/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html#//apple_ref/doc/uid/TP40006511-SW5)にも記載がありません。

似たイベントとして`pagehide`というイベントがありますが、あくまで別の種類のイベントでありbeforeunloadの代替にはできません。

Mobile Safariでページ離脱確認をしなければいけない場合は完全に制御することは出来ないということに気をつけましょう。

## さいごに

ページ離脱確認が求められるのは、重要な情報を登録する画面などでユーザーの操作ミスを防ぐためなど重要なケースです。
いくつかの問題はあるものの、ちょっとした実装の追加によりエンドユーザーに辛い思いをさせずに済みます。
本記事が皆さんのお役に立てれば幸いです。