Angular Router : そのAPIとメンタルモデル、設計理念について

状態遷移の管理はアプリケーション構築の上でもっとも難しいとされる部分の1つです。URLに状態が確実に反映されなければならないという意味ではWeb上では特に難しいと言えます。さらに、アプリケーションを複数のバンドルに分けて要求に応じてロードする処理をする場合がよくありますが、これを透過的に実行するのは大変です。

Angular Routerはこのような問題を解消してくれます。Routerを使えば、アプリケーションの状態を宣言的に特定でき、URLに気を付けながら状態遷移を管理することができ、必要に応じてにコンポーネントをロードできます。この記事では、RouterのAPIについてだけでなく、背後にあるメンタルモデルと設計理念についても考察したいと思います。

では、始めましょう。

概略

  • Routerは何をするのか
  • URLパースとシリアライズ
    • URLツリーへアクセス
  • ルート認識
    • 強力なマッチング構文
    • コンポーネントのインスタンス化
    • パラメータの使用
    • QueryParamとフラグメント
    • スナップショットの使用
  • ナビゲーション
    • 命令型ナビゲーション
    • RouterLink
    • さらなる構文をいくつか
    • ナビゲーションはルートベースではなくURLベース
  • エンドツーエンド(E2E)の例
  • まとめ

Routerは何をするのか

Angular Routerの仕様に入る前にまず一般的にRouterが何をするものなのかを説明しましょう。

ご存じかもしれませんが、Angular2アプリケーションはコンポーネントのツリーです。これらコンポーネントの中には再利用できるUIコンポーネント(例えばリストやテーブル)やアプリケーションコンポーネントがあります。Routerの関心の対象は、アプリケーションコンポーネント、より詳しく言えばそれらの配置です。ひとまずこのようなコンポーネントの配置をRouter状態と呼ぶことにします。Router状態はスクリーン上で見えるものを定義します。

Routerコンフィギュレーションはアプリケーションが取り得る全ての状態を定義します。では、例を見てみましょう。

[
  {path: 'team/:id', component: TeamCmp, children: [
    {path: 'details', component: DetailsCmp},
    {path: 'help', component: HelpCmp, outlet: 'aux'}
  ]},
  {path: 'summary', component: SummaryCmp},
  {path: 'chat', component: ChatCmp, outlet: 'aux'}
]

図にすると次のとおりです。

Router Config
outletは、コンポーネントが置かれる場所です。例えば、ノードが同色の(=同じoutletの型を持つ)子を持っている場合、1度にアクティブになれるのは1つのみです。結果として、teamコンポーネントとsummaryコンポーネントが同時に表示されることはありません。

Router状態はコンフィギュレーションツリーのサブツリーなのです。例えば、下の例ではsummaryコンポーネントがアクティブになっています。

Router Config. Summary Activated
この時、Routerの主な仕事は、コンポーネントツリーの更新を含む状態間のナビゲーション管理です。基本的にナビゲーションとは1つの起動中のツリーから別のツリーへと遷移させることなのです。ナビゲーションを実行すると、以下のような結果になります。

Router Config. Team Activated
summaryコンポーネントがもうアクティブではないため、Routerはこれを削除します。その代わり、detailsコンポーネントをインスタンス化し、teamコンポーネント内で表示します。この時、横にchatコンポーネントも表示されます。

これだけです。Routerはアプリケーションが取り得る全ての状態の表現を簡単に実現し、状態遷移のナビゲーション構造を提供してくれます。

これで、一般的にRouterが何をするのか分かったと思います。では、Angular Routerについて説明しましょう。

Angular Router. Overview
注釈
Parse パース
Recognize 認識
Instantiate インスタンス化
navigate/RouterLink ナビゲーション・RouterLink
using 利用
serialize シリアライズ

Angular RouterはURLを取得し、そのURLをURLツリーにパースし、Router状態を認識し、必要なコンポーネント全てをインスタンス化します。そして最後にナビゲーション管理を行います。では、それぞれの処理を詳しく見てみましょう。

URLパースとシリアライズ

Angular Router. Parsing and Serialization
URLバーはWebアプリケーションにネイティブアプリケーションには無い利点を提供してくれます。状態の参照やブックマーク、友人との共有を可能にしてくれます。正しく機能するWebアプリケーションでは、状態が遷移さればURLが変更され、URLが変更されれば状態が遷移します。つまり、URLはRouter状態がシリアライズされたものなのです。

Routerがまず行うのはURLの文字列をURLツリーにパースすることです。Routerはアプリケーションやコンポーネントに関する情報を必要としません。つまり、パース処理はアプリケーションに関係なく行われるのです。実際にどのように行われるのか例を使って見てみましょう。

簡単なURLで試してみましょう。

/team/3/details

// is parsed into the following URL tree:

new UrlSegment(paths: [], children: // this is root segment
  {
    primary:
      new UrlSegment(paths: [
        new UrlPathWithParams(path: 'team', parameters: {}),
        new UrlPathWithParams(path: '3', parameters: {}),
        new UrlPathWithParams(path: 'details', parameters: {})
      ], children: {})
  }
)

見て分かるように、URLツリーはURLセグメントで構成されています。そして、それぞれのURLセグメントはパスと子を持っています。

では、最初のパスフラグメントにextraパラメータ設定trueが追加されている次の例を見てみましょう。

/team;extra=true/3

// is parsed into the following URL tree:

new UrlSegment(paths: [], children:
  {
    primary:
      new UrlSegment(paths: [
        new UrlPathWithParams(path: 'team', parameters: {extra: 'true'})
        new UrlPathWithParams(path: '3', parameters: {})
      ], children: {})
  }
)

では、teamセグメントに1つではなく、2つの子があった場合を見てみましょう。

/team/3(aux:/chat;open=true)

// is parsed into the following URL tree:

new UrlSegment(paths: [], children:
  {
    primary:
      new UrlSegment(paths: [
        new UrlPathWithParams(path: 'team', parameters: {})
        new UrlPathWithParams(path: '3', parameters: {})
      ], children: {}),

    aux:
      new UrlSegment(paths: [
        new UrlPathWithParams(chat: 'team', parameters: {open: 'true'})
      ], children: {})
  }
)

見てお分かりのとおり、シリアライズした複数の子を持つセグメントには括弧、outletを特定するためはコロン構文、そしてルートに特化したパラメータを特定するために”;parameter=value”構文(例えばopen=true)を使用します。

URL文字列のみを使うのではなくにURLツリーを使う理由は複数あります。第1にURLツリーが豊かなデータ構造になっているのと共通の処理を実現してくれるアフォーダンスを多く持っているからです。第2にユーザ独自のURLシリアライズ戦略を作ることを可能にしてくれるからなのです。

bootstrap(MyComponent, [{provider: RouterURLSerializer, useClass: MyCustomSerializer}]);

現在のURLにアクセス

Routerサービスから現在のURLを取得することができます。

class TeamCmp {
  constructor(router: Router) {
    const url: string = router.url;

    // we can parse it into UrlTree
    const tree: UrlTree = router.parseUrl(url);
  }
}

URLの保存

URLツリーとURLセグメントは不変であるため、安心して保存できます。面白いナビゲーションやアンドゥ・パターンの実装に特に便利です。

Router状態の認識

Angular Router. Router State Recognition
URLをパースする際アプリケーション情報は必要ないため、作成したURLツリーはアプリケーションの論理構造を表していません。しかし、Router状態はアプリケーションの論理構造を表します。Router状態を作成する際にRouterは与えられた設定を使ってURLツリーにマッチングします。ここでもいくつかの例を見てみましょう。

与えられたRouterコンフィギュレーションが次のとおりと仮定しましょう。

[
  { path: 'team/:id', component: TeamCmp,
    children: [{ path: 'details', component: DetailsCmp }] },
  { path: 'chat', component: ChatCmp, outlet: 'chat' }
]

そして、次のURLへとナビゲーションします。

/team/3/details

new UrlSegment(paths: [], children:
  {
    primary:
      new UrlSegment(paths: [
        new UrlPathWithParams(path: 'team', parameters: {}),
        new UrlPathWithParams(path: '3', parameters: {}),
        new UrlPathWithParams(path: 'details', parameters: {})
      ], children: {})
  }
)

RouterはまずURLの文字列をURLツリーにパースします。それから、1つずつ設定アイテムをパースしたURLツリーにマッチングしていきます。上の場合、1番目のアイテムが一致しています。その後に子のアイテムをマッチングします。

URL全体を一致させることが不可能な場合、ナビゲーションはエラーになります。しかし、一致した場合はアプリケーションの今後の状態を表すRouter状態が構築されます。

その場合は、次のようになります。

/team/3/details

// will create the following activated routes:

new ActivatedRoute(component: TeamCmp, url: [
  new UrlPathWithParams(path: 'team', parameters: {}),
  new UrlPathWithParams(path: '3', parameters: {})
])

new ActivatedRoute(component: DetailsCmp, url: [
  new UrlPathWithParams(path: 'details', parameters: {})
])

RouterState:

ActivatedRoute(component: RootCmp)
  -> ActivatedRoute(component: TeamCmp)
    -> ActivatedRoute(component: DetailsCmp)

Router状態はアクティブなRouterで構成されます。それぞれのアクティブなRouterは1つのコンポーネントに紐付けされています。さらに、アプリケーションのrootコンポーネントに関連するアクティブなRouterが必ず存在することに注意してください。

もう1つ例を見てみましょう。

/team/3(aux:/chat;open=true)

// will create the following activated routes:

new ActivatedRoute(component: TeamCmp, outlet: primary, url: [
  new UrlPathWithParams(path: 'team', parameters: {}),
  new UrlPathWithParams(path: '3', parameters: {})
])

new ActivatedRoute(component: ChatCmp, outlet: 'aux', url: [
  new UrlPathWithParams(path: 'chat', parameters: {open: 'true'})
])

RouterState

ActivatedRoute(component: RootCmp, outlet: primary)
  -> ActivatedRoute(component: TeamCmp, outlet: primary)
  -> ActivatedRoute(component: ChatCmp, outlet: aux)

上ではTeamCmp、ChatCmpと異なる名前のoutletを持つ兄弟が存在し、同時にアクティブになっています。

強力なマッチング構文

マッチングに使われる構文は強力です。例えば'**'のようなワイルドカードや/team/:idのような位置パラメータをサポートしています。

コンポーネントのインスタンス化

Angular Router. Component Instantiation
この時点では、Router状態を取得しています。各コンポーネントをインスタンス化し、コンポーネントツリーを組み立て、各コンポーネントを適切なRouterのoutletに配置することにより、Routerは現在の状態に適合できます。

この処理を理解するために、以下の例を見てみましょう。

// Router Configuration:
[
  { path: 'team/:id', component: TeamCmp,
    children: [{ path: 'details', component: DetailsCmp }] },
  { path: 'chat', component: ChatCmp }
]

// Components:
@Component({
  selector: chat,
  template: `
    Chat
  `
})
class ChatCmp {}

@Component({
  selector: 'team',
  template: `
    Team
    primary: { <router-outlet></router-outlet> }
  `
})
class TeamCmp {}

@Component({
  selector: 'root',
  template: `
    Root
    primary: { <router-outlet></router-outlet> }
    aux:   { <router-outlet name='aux'></router-outlet> }
  `
})
class RootCmp {}

これは、以下のRouter状態に対応する'/team/3(aux:/chat)/detailsというURLへナビゲーションしています。

ActivatedRoute(component: RootCmp)
  -> ActivatedRoute(component: TeamCmp, parameters: {id: 3}, outlet: primary)
    -> ActivatedRoute(component: DetailsCmp, parameters: {}, outlet: primary)
  -> ActivatedRoute(component: ChatCmp, parameters: {}, outlet: aux)

最初に、RouterはTeamCmpをインスタンス化し、rootコンポーネントの最初のoutletに配置します。その後、ChatCmpの新規インスタンスを”aux”outletへ配置します。最後に、DetailsCmpの新規インスタンスをインスタンス化し、teamコンポーネントの最初のoutletに配置します。

パラメータの使用

コンポーネントは多くの場合、URLにキャプチャされた状態に左右されます。例えば、teamコンポーネントはidパラメータにアクセスする必要がありますが、ActivatedRouteオブジェクトを注入することによって、idパラメータを取得することができます。

@Component({
  selector: 'team',
  template: `
    Team Id: {{id | async}}
    primary: { <router-outlet></router-outlet> }
  `
})
class TeamCmp {
  id: Observable<string>;
  constructor(r: ActivatedRoute) {
    //r.params is an observable
    this.id = r.params.map(r => r.id);
  }
}

/team/3/detailsから/team/4/detailsへとナビゲーションする場合は、params observableはパラメータの新たなマップを放出します。そうすると、teamコンポーネントはTeam Id: 4と表示します。

ここでteam idが変わると、それに応じてdetailsコンポーネントも変わる必要があります。以下のような方法で処理できます。

@Component({
  selector: 'team',
  template: `
    Team Id: {{id | async}}
    <router-outlet></router-outlet>
  `
})
class TeamCmp {
  id:Observable<string>;
  constructor(r: ActivatedRoute) {
    this.id = r.params.map(r => r.id);
  }
}

@Component({
  selector: 'details',
  template: `
    Details for Team Id: {{teamId | async}}
  `
})
class DetailsCmp {
  teamId:Observable<string>;
  constructor(r: ActivatedRoute, router: Router) {
    const teamActivatedRoute = router.routerState.parent(r);
    this.teamId = teamActivatedRoute.params.map(r => r.id);
  }
}

Router状態のツリーを上下に自由に行き来することができます。また、アクティブになったルートがそれぞれ独自のパラメータを持つことも分かります。

アクティブ化された異なるルートから複数のobservableのコンビネーションを開始すれば、いかに柔軟に対応できるか、簡単に想像できますね。

QueryParamとフラグメント

Router状態は、アクティブ化された特定のルートとは関係無い、QueryParamとフラグメントを持っています。

以下の例は、クエリ(id=3)であり、かつフラグメント(open=true)であるURL/team?id=3#open=trueです。

@Component({
  selector: 'team',
  template: ` `
})
class MyCmp {
  constructor(r: Router) {
    const q: Observable<{[k:string]:string}> = r.routerState.queryParams;
    const f: Observable<string> = r.routerState.fragment;
  }
}

スナップショットの使用

ご覧のように、Routerによって、ルートとobservableとしてのクエリパラメータが分かります。これは、大抵の場合に便利ですが、いつもそうとは限りません。すぐにチェックが可能な、状態のスナップショットが必要な場合もあります。

スナップショットは以下の方法で取得します。

@Component({
  selector: 'team',
  template: `
    Team Id: {{id}}
  `
})
class TeamCmp {
  id:string;
  constructor(r: ActivatedRoute, router: Router) {
    const s: ActivatedRouteSnapshot = r.snapshot;
    // matrix params of a particular route
    this.id = s.params.id;

    const ss: RouterStateSnapshot = router.routerState.snapshot;
    // query params are shared
    const q: {[k:string]:string} = ss.queryParams;
  }
}

ナビゲーション

Angular Router. Navigation
この時点では、Routerは既にURLをパースし、Router状態を認識し、コンポーネントをインスタンス化しています。次に、このRouter状態を他のRouter状態にナビゲーションできるようにする必要があります。この処理を行うには2つの方法があります。router.navigateを呼び出す方法か、RouterLinkディレクティブを使う方法です。

命令型ナビゲーション

命令的にナビゲーションをするには、Routerサービスを注入し、navigateを呼び出します。

class TeamCmp {
  teamId: number;
  userName: string;
  constructor(private router: Router) {}

  onClick(e) {
    this.router.navigate(['/team', this.teamId, 'user', this.userName]).then(_ => {
       //navigation is done
    }); //e.g. /team/3/user/victor
  }
}

このコードで処理は可能ですが、問題は、ナビゲーションが絶対になってしまうことです。これでは、コンポーネントの再利用とテストが難しくなります。この問題を解決するには、以下のように、単にアクティブ化されたルートをナビゲートメソッドに渡します。

class TeamCmp {
  private teamId;
  private userName;
  constructor(private router: Router, private r: ActivatedRoute) {}

  onClick(e) {
    this.router.navigate(['../', this.teamId, 'user', this.userName], {relativeTo: this.r});
  }
}

RouterLink

もう1つのナビゲーションの方法は、RouterLinkディレクティブを使う方法です。

@Component({
  selector: 'team',
  directives: ROUTER_DIRECTIVES,
  template: `
     <a [routerLink]="['../', this.teamId, 'user', this.userName]">Navigate</a>
  `
})
class TeamCmp {
  private teamId;
  private userName;
}

このディレクティブを使うと、リンク要素<a>への適用時にhref属性を更新することもできます。ですので、検索エンジン最適化には良い手法であり、右クリックで新しいタブでページを開く際に、通常のリンク機能が期待できます。

さらに構文をいくつか

ナビゲーションとRouterLinkにどんなものを渡せるのでしょうか。以下の例を見てみましょう。

// absolute navigation
this.router.navigate(['/team', this.teamId, 'details']);

// you can collapse static parts into a single element
this.router.navigate(['/team/3/details']);

// also set query params and fragment
this.router.navigate(['/team/3/details'], {queryParams: newParams, fragment: 'fragment'});

// e.g., /team/3;extra=true/details
this.router.navigate(['/team/3', {extra: true}, 'details']);

// relative navigation to /team/3/details
this.router.navigate(['./details'], {relativeTo: this.route});

// relative navigation to /team/3
this.router.navigate(['../', this.teamId], {relativeTo: this.route});

ナビゲーションはルートベースではなくURLベース

ナビゲーションは、ルートベースではなくURLベースです。”../ “は、URLツリーのセグメントをスキップすることを表しています。なぜそうなるのか、以下のリンクを見てみましょう。

<a [routeLink]='['../chat', this.chatId, 'details']'>Chat</a>

このリンクがChatCmpコンポーネントとDetailsCmpコンポーネントにナビゲーションするとしましょう。ユーザがリンクをクリックした場合にのみ、これらのコンポーネントを遅延読み込みします。それと同時に、リンクのhref属性はページのロードにセットされるはずです。

ChatCmpDetailsCmpのルートコンフィギュレーションは、ページのロードでは取得できません。つまり、'../chat', this.chatId, 'details'が、1つのルートセグメントなのか、または、2つ、もしくは3つのルートセグメントなのかは不明です。これが、3つに別れたURLセグメントであることしか分かりません。

これはRouter構築における、デザイン上の制約の1つです。上記のような情報が無くても、遅延読み込みされるルートへ深部までリンクができなければなりません。更にそのリンクはhref属性のセットを持っていなければなりません。こうした理由から、ナビゲーションは全てルートベースではなくURLベースなのです。

エンドツーエンド(E2E)の例

ここまで、Angular Routerの4つの主要な処理を全て確認してきました。次に、実例を使ってこれらの処理を見てみましょう。

私たちのアプリケーションのRouterコンフィギュレーションが、以下のようなものであるとします。

bootstrap(RootCmp, provideRouter([
  { path: 'team/:id', component: TeamCmp,
    children: [{ path: 'details', component: DetailsCmp }] }
]);

ブラウザが/team/3/detailsをロードする際、Routerは次の処理を行います。
最初に、URLツリー内でこのURL文字列をパースします。

new UrlSegment(paths: [], children: // this is root segment
  {
    primary:
      new UrlSegment(paths: [
        new UrlPathWithParams(path: 'team', parameters: {}),
        new UrlPathWithParams(path: '3', parameters: {}),
        new UrlPathWithParams(path: 'details', parameters: {})
      ], children: {})
  }
)

次に、新たなRouter状態を構築するために、このURLツリーを使用します。

ActivatedRoute(component: RootCmp, parameters: {}, outlet: primary)
  -> ActivatedRoute(component: TeamCmp, parameters: {id: '3'}, outlet: primary)
   -> ActivatedRoute(component: DetailsCmp, parameters: {}, outlet: primary)

次に、Routerはteamコンポーネントとdetailsコンポーネントをインスタンス化します。
これで、teamコンポーネントは自分のテンプレート内に以下のリンクを持つことになります。

<a [routerLink]="['../', 4, 'details']">Team 4</a>

ユーザがリンクをクリックして、ナビゲーションが起動したとします。

Routerは['../', 4, 'details']の配列を取得し、新たなURLツリーを構築します。

new UrlSegment(paths: [], children: // this is root segment
  {
    primary:
      new UrlSegment(paths: [
        new UrlPathWithParams(path: 'team', parameters: {}),
        new UrlPathWithParams(path: '4', parameters: {}),
        new UrlPathWithParams(path: 'details', parameters: {})
      ], children: {})
  }
)

これにより、新たなRouter状態が認識されます。

ActivatedRoute(component: RootCmp, parameters: {}, outlet: primary)
  -> ActivatedRoute(component: TeamCmp, parameters: {id: '4'}, outlet: primary)
    -> ActivatedRoute(component: DetailsCmp, parameters: {}, outlet: primary)

最後に、teamコンポーネントとdetailsコンポーネントが既に配置されていることが認識されます。これにより再利用が可能となり、新たなパラメータのセットをteamコンポーネントのparams observableに入れるだけです。

一度この処理を行えば、Routerは新たなURLツリーを取得し、それを文字列に変換し、位置プロパティを更新します。

Angular Router. Overview

まとめ

今回は、かなり多くのことを学びました。最初にRouterの役割を学びました。Routerによって、アプリケーションが取り得る全ての状態を表すことが可能になり、ある状態から別の状態へナビゲーションする仕組みが提供されます。また、Angular Routerの4つの主要な処理についても学びました。URLパース、状態の認識、コンポーネントのインスタンス化、ナビゲーションです。最後に、E2Eの例を使ってRouterの働きを確認しました。

ナビゲーションの管理、監視、デバッグ、エラー処理、遅延読み込みといった重要なテーマには触れませんでした。これらについては、今後の投稿記事で取り上げる予定です。

こちらのサイトで、Angular Routerで構築された実際のアプリケーションを体験することができます。また、bootstrap(RootCmp, provideRouter(config, {enableTracing: true}))で、トレースもできます。これは、コンソールに全てのイベントのログを表示するようにRouterに命令するもので、トラブルシューティングの際に便利です。

Angular Routerに関する最新のニュースと案内をご覧になるには、私のtwitterをフォローしてください