noxi雑記

.NET、Angularまわりの小ネタブログ

Angularのページ遷移に応じて何かを行う

Angularを使用してSPAを開発すると、ページの再ローディング無しで画面遷移を行うことができます。ルーティングイベントを使用して、Angular内での画面遷移に応じて何か処理を差し込む方法を紹介します。

前提条件

この記事は以下のAngularバージョンで作成されています。またこの記事ではAngularでルーティングする方法についての紹介は行いません。

Angular CLI: 6.1.5
Node: 8.9.2
OS: darwin x64
Angular: 6.1.6
... animations, common, compiler, compiler-cli, core, forms
... http, language-service, platform-browser
... platform-browser-dynamic, router

Package                           Version
-----------------------------------------------------------
@angular-devkit/architect         0.7.5
@angular-devkit/build-angular     0.7.5
@angular-devkit/build-optimizer   0.7.5
@angular-devkit/build-webpack     0.7.5
@angular-devkit/core              0.7.5
@angular-devkit/schematics        0.7.5
@angular/cli                      6.1.5
@ngtools/webpack                  6.1.5
@schematics/angular               0.7.5
@schematics/update                0.7.5
rxjs                              6.3.0
typescript                        2.7.2
webpack                           4.9.2

ルーティングイベントの種類

AngularのRouterはページ遷移の状態に応じて各種イベントを発火させています。イベントの種類はと飛んでくる順番はAPIリファレンスにも記載があります。そのうち私がよく使うものをここで紹介します。

種別 発火タイミング
NavigationStart ナビゲーションが開始された時。
ActivationStart ナビゲーション先のコンポーネントが決まった時(GuardやResolveの前)。
ActivationEnd ナビゲーション先のコンポーネントインスタンス化終了時(GuardやResolveが終わって、遷移先コンポーネントインスタンスが作られた後)。
NavigationEnd ナビゲーションが終了した時(正常に終了した場合)。
NavigationCancel ナビゲーションが終了した時(ナビゲーション処理の途中でキャンセルされた場合)。
NavigationError ナビゲーションが終了した時(ナビゲーション先が存在しないなど、エラーが発生した場合)。

NavigationEndNavigationCancelNavigationError はページナビゲーションの最後に呼ばれるため、finallyブロックの感覚でよく使っています。また ActivationStart は遷移先のコンポーネント情報が取れるため、あまり頻度は多くありませんが、使用するときがあります。

実装サンプル

ページ遷移でページタイトルを初期化したい

ページ遷移時にコンテンツに応じてページタイトル(HTMLのtitleタグ)の中身を書き換えることをしますが、開発時など、ページタイトルが決まっていないものが混在している時はとても面倒です。そんな時はルーティングイベントを拾って、毎回ページ遷移時にページタイトルを初期化してしまうのが楽で便利です。

これを実現するにはページタイトルを管理する PageTitlteService を作成して、ページタイトルをこのサービス経由で設定します。

import { Injectable } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';

/**
 * ページタイトルを設定するサービス
 */
@Injectable({
  providedIn: 'root'
})
export class PageTitleService {

  private _pageTitle: string = '';

  constructor(
    private readonly _router: Router,
    private readonly _title: Title
  ) {
    // ナビゲーション終了時にタイトルをリセットする
    this._router.events
      .pipe(filter(e => e instanceof NavigationEnd))
      .subscribe(() => this.setTitle(''));
  }

  setTitle(title: string): void {
    this._pageTitle = title;
    title = title ? `${title} - SampleSite` : 'SampleSite';
    this._title.setTitle(title);
  }

  getTitle(): string {
    return this._pageTitle;
  }

}

この PageTitleService はコンストラクタで Router#events から NavigationEnd のイベントだけを絞ってSubscribeし、ナビゲーションが正常に終了した時のみページタイトルをデフォルト値に設定します。 NavigationEnd のイベントは遷移先のコンポーネントのコンストラクタが実行されてからコンポーネントOnInit が実装される間に発火されるイベントです。遷移先コンポーネントOnInit でページタイトルを設定すると、ページ遷移に応じてページタイトルのリセットと設定が実装できます。

ApplicationInsightsにPageViewを送信する

SPAではページロードが初回以外発生しないため、ApplicationInsights等の解析ツールにPageViewイベントを送信するときはPageViewに該当するイベントを手動で送信せねばなりません。ググればAngularにApplicationInsightsを組み込むライブラリくらい見つかりそうですが、今回はナビゲーションイベントから手動でPageViewイベントを送信します。

import { Injectable } from '@angular/core';
import {
  ActivationStart,
  NavigationCancel,
  NavigationEnd,
  NavigationError,
  NavigationStart,
  Router,
} from '@angular/router';
import { Observable } from 'rxjs';
import { filter, map, withLatestFrom } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class PageTrackingService {

  constructor(private readonly _router: Router) {
    // NavigationStartから最後に発生したActivationStartイベントの取得
    const start$: Observable<ActivationStart | null> = this._router.events.pipe(
      filter(e => e instanceof NavigationStart || e instanceof ActivationStart),
      map(e => e instanceof ActivationStart ? e as ActivationStart : null)
    );

    // NavigationEnd/Cancel/Errorと最後の発生したActivationStartを組み合わせる
    this._router.events
      .pipe(
        filter(e => e instanceof NavigationEnd || e instanceof NavigationCancel || e instanceof NavigationError),
        withLatestFrom(start$)
      )
      .subscribe(([end, start]) => {
        // コンポーネント名の取得
        const component: any = !start ? null : start.snapshot.component;
        const name = !component ? '' : (typeof component === 'string' ? component : component.name);

        // ナビゲーション正常終了時
        if (end instanceof NavigationEnd) {
          appInsights.trackPageView(name, end.url);
          return;
        }

        // ナビゲーションキャンセル時(Guardで拒否された場合など)
        if (end instanceof NavigationCancel) {
          appInsights.trackEvent('PageNavigation cancelled', {
            url: end.url,
            component: name,
            reason: end.reason
          });
          return;
        }

        // ナビゲーションエラー
        if (end instanceof NavigationError) {
          appInsights.trackException(end.error, 'Navigation', { url: end.url });
          return;
        }
      });
  }

}

結局のところナビゲーション終了系のイベントと ActivationStart を組み合わせているだけなのですが、入れ子のルーティング設定にしていると ActivationStart入れ子の階層分一気に飛んでくる可能性があります。 combineLatestzip を使うとそんな時に対応できないため、 withLatestFrom で確実に最後の ActivationStart をセットで処理するように気を付けます。