2016年7月12日
Angular Router : そのAPIとメンタルモデル、設計理念について
(2016-06-09)by Victor Savkin
本記事は、原著者の許諾のもとに翻訳・掲載しております。
(訳注:2017/02/16、画像、元記事がリンク切れしていたため修正いたしました。)
状態遷移の管理はアプリケーション構築の上でもっとも難しいとされる部分の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'}
]
図にすると次のとおりです。
outletは、コンポーネントが置かれる場所です。例えば、ノードが同色の(=同じoutletの型を持つ)子を持っている場合、1度にアクティブになれるのは1つのみです。結果として、teamコンポーネントとsummaryコンポーネントが同時に表示されることはありません。
Router状態はコンフィギュレーションツリーのサブツリーなのです。例えば、下の例ではsummaryコンポーネントがアクティブになっています。
この時、Routerの主な仕事は、コンポーネントツリーの更新を含む状態間のナビゲーション管理です。基本的にナビゲーションとは1つの起動中のツリーから別のツリーへと遷移させることなのです。ナビゲーションを実行すると、以下のような結果になります。
summaryコンポーネントがもうアクティブではないため、Routerはこれを削除します。その代わり、detailsコンポーネントをインスタンス化し、teamコンポーネント内で表示します。この時、横にchatコンポーネントも表示されます。
これだけです。Routerはアプリケーションが取り得る全ての状態の表現を簡単に実現し、状態遷移のナビゲーション構造を提供してくれます。
これで、一般的にRouterが何をするのか分かったと思います。では、Angular Routerについて説明しましょう。
注釈
Parse パース
Recognize 認識
Instantiate インスタンス化
navigate/RouterLink ナビゲーション・RouterLink
using 利用
serialize シリアライズ
Angular RouterはURLを取得し、そのURLをURLツリーにパースし、Router状態を認識し、必要なコンポーネント全てをインスタンス化します。そして最後にナビゲーション管理を行います。では、それぞれの処理を詳しく見てみましょう。
URLパースとシリアライズ
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状態の認識
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
のような位置パラメータをサポートしています。
コンポーネントのインスタンス化
この時点では、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;
}
}
ナビゲーション
この時点では、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属性はページのロードにセットされるはずです。
ChatCmp
と DetailsCmp
のルートコンフィギュレーションは、ページのロードでは取得できません。つまり、 '../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ツリーを取得し、それを文字列に変換し、位置プロパティを更新します。
まとめ
今回は、かなり多くのことを学びました。最初にRouterの役割を学びました。Routerによって、アプリケーションが取り得る全ての状態を表すことが可能になり、ある状態から別の状態へナビゲーションする仕組みが提供されます。また、Angular Routerの4つの主要な処理についても学びました。URLパース、状態の認識、コンポーネントのインスタンス化、ナビゲーションです。最後に、E2Eの例を使ってRouterの働きを確認しました。
ナビゲーションの管理、監視、デバッグ、エラー処理、遅延読み込みといった重要なテーマには触れませんでした。これらについては、今後の投稿記事で取り上げる予定です。
こちらのサイト で、Angular Routerで構築された実際のアプリケーションを体験することができます。また、 bootstrap(RootCmp, provideRouter(config, {enableTracing: true}))
で、トレースもできます。これは、コンソールに全てのイベントのログを表示するようにRouterに命令するもので、トラブルシューティングの際に便利です。
Angular Routerに関する最新のニュースと案内をご覧になるには、 私のtwitterをフォローしてください 。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa