Angularのルーティング
Vue.jsと比較するために、このページではAngularを公式のチュートリアルに従って学んでいます。ここでは主にルーティングについて書いています。Angularにおけるルーティングはアドレスにより表示するコンポーネントを振り分けることです。単純にコンポーネントとアドレスを結びつける記述から、デフォルトルートとしてリダイレクトする方法、プレースホルダを使ってアドレスからidを取得する方法を書いています。
ルーティングとは
Angularにおけるルーティングとは、アクセスしてきたURLにより表示するコンポーネントを分けることです。
SPA(Single Page Application)ではこの機能により、実際にはひとつのページでも複数ページを持ているかのような画面遷移が可能になります。
AppRouteingModule
以前にAngular CLIでアプリケーションを初期化した際にはデフォルト値として読み飛ばしていましたが、じつはこの時ルーティングを使うかどうかをたずねていました。
あとからルーティングモジュールを追加するには次のようにコマンドを入力します。
この実行はsrc¥appフォルダで行ってください。
下線部でルーティングモジュールの名前を指定しているのですが、慣例では「AppRouteingModule」とすることになっています。
ちなみにJavascript内ではケバブケース(-で区切る命名)は演算子と間違われて誤作動を起こすことがあります。そのため、スクリプト内では指定した名前はキャメルケース(単語区切りで先頭を大文字にする:CamelCase)に変換されます。ファイル名をケバブケースにするのは、大文字小文字の区別するか否かで誤作動が起きないようにするためです。
--flatオプションはプロジェクトのトップレベルにファイルを作成する指示です。これを指定しないとapp-routingというフォルダが作成されてその中にモジュールファイルが作成されてしまいます。また、--module=appと指定することで、app.module.tsファイルのimport文と、importsのリストに作成したモジュール名が追加されます。
作成されたファイルを次のように書き換えます。最初と最後の行以外はほぼ全部書き換えです。ちなみにアプリの初期化の際に「Y(ルーターを使う)」と答えるとRoutesに引数を与えるだけになってる同名のファイルが作成されます。
app-routeing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HeroesComponent } from './heroes/heroes.component';
const routes: Routes = [
{ path: 'heroes', component: HeroesComponent }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
上の記述の部分で const routes:にルーティング情報であるRoutesを設定しています。pathとcomponentをプロパティに持つ配列を記述します。
ルーターは、pathの値として記述された部分をページ名と比較して一致したら、componentに記述されているコンポーネントを表示させます。
imports: [RouterModule.forRoot(routes)],の部分ですが、アプリケーションのルートレベル(forRoot)でルーターを設定しているという意味になります。
exports: [RouterModule]の部分で、RouterModlueがアプリで全体で利用できるように指定しています。
このAppRoutingModuleクラスは、RouterModuleのforRootに自身が設定したroutesを与えて実行しながら生成され、RouteModuleをアプリ全体で利用できるようにexportします。
router-outlet
SPA(Single Page Application)のベースとなるコンポーネント(app.compornent.html)にルーターで切り替えて表示させる領域を設定します。
以前作成したHeroesコンポーネントはタグ(ディレクティブ)により表示していましたが、ルーターによる表示にするためapp.compornent.htmlから外します。
代わりにroute-outletというディレクティブを入れると、ルーターはここにコンポーネントを表示するようになります。このディレクティブはRouterModlueインポートすると使えるようになるものですが、app.module.tsでRouteModuleをエクスポートするAppRoutingModuleをインポートしているため追加の記述なしで利用できます。
今後はroute-outletで示した場所にルーターがHeroesコンポーネントを表示するようになります。
そのままの状態ではHeroesコンポーネントをを表示させるためのきっかけがないので、a要素にrouterLink属性でリンクをつけています。このrouteLinkもRouteModuleにより利用可能になっています。
app.component.html
<h1>{{title}}</h1>
<nav><a routerLink="/heroes">Heroes</a></nav>
<router-outlet></router-outlet>
<app-messages></app-messages>
初期ページからa routerLinkで設定したリンクをクリックするとURLが変わり、HeroesComponentが表示されるようになりました。
通常のWebページのように、routerLinkの部分をhrefで設定することもできます。そうした時リンクをクリックした際に最終的に表示されるものは変わりませんが、hrefではページ全体の読み込みが発生するのに対して、routerLinkではそれが発生しません。
ちなみにnavタグはナビゲーションであるというHTML5上の意味で設定しているので必須ではありません。
ここで追加したnavはまだcssによって修飾されていません。そのため単なるテキストリンクになっています。気になるなら、app.component.cssに次の記述をしておきます。
app.component.css
nav a {
padding: 5px 10px;
text-decoration: none;
margin-top: 10px;
margin-right: 5px;
display: inline-block;
background-color: #eee;
border-radius: 4px;
}
nav a:visited, a:link {
color: #334953;
}
nav a:hover {
color: #039be5;
background-color: #cfd8dc;
}
nav a.active {
color: #039be5;
}
デフォルトルート
デフォルトルートに設定するコンポーネント(dashboard)をチュートリアルに従い新たに作成します。
PS> ng generate component dashboard ... More than one module matches. Use the skip-import option to skip importing the component into the closest module or use the module option to specify a module.
エラーが出てしまいました。調べてみると、同じ階層にapp-routing.moduleを作成したので、DashboardComponentをどのモジュールに追加したらいいかわからなくなったようです。
これはapp.moduleに追加したいので、コマンドを次のように変更します。
コンポーネントの作成ができたら.ts、.html、.cssを次のように設定します。
dashbord.compornent.ts
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-dashboard',
templateUrl: './dashboard.component.html',
styleUrls: [ './dashboard.component.css' ]
})
export class DashboardComponent implements OnInit {
heroes: Hero[] = [];
constructor(private heroService: HeroService) { }
ngOnInit() {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes.slice(1, 5));
}
}
dashbord.compornent.html
<h3>Top Heroes</h3>
<div class="grid grid-pad">
<a *ngFor="let hero of heroes" class="col-1-4">
<div class="module hero">
<h4>{{hero.name}}</h4>
</div>
</a>
</div>
dashbord.compornent.css
[class*='col-'] {
float: left;
padding-right: 20px;
padding-bottom: 20px;
}
[class*='col-']:last-of-type {
padding-right: 0;
}
a {
text-decoration: none;
}
*, *::after, *::before {
box-sizing: border-box;
}
h3 {
text-align: center;
margin-bottom: 0;
}
h4 {
position: relative;
}
.grid {
margin: 0;
}
.col-1-4 {
width: 25%;
}
.module {
padding: 20px;
text-align: center;
color: #eee;
max-height: 120px;
min-width: 120px;
background-color: #3f525c;
border-radius: 2px;
}
.module:hover {
background-color: #eee;
cursor: pointer;
color: #607d8b;
}
.grid-pad {
padding: 10px 0;
}
.grid-pad > [class*='col-']:last-of-type {
padding-right: 20px;
}
@media (max-width: 600px) {
.module {
font-size: 10px;
max-height: 75px; }
}
@media (max-width: 1024px) {
.grid {
margin: 0;
}
.module {
min-width: 60px;
}
}
Dashboardのルーティングをapp-routing.module.tsに加えます。
app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HeroesComponent } from './heroes/heroes.component';
import { DashboardComponent } from './dashboard/dashboard.component';
const routes: Routes = [
{ path: 'heroes', component: HeroesComponent },
{ path: 'dashboard', component: DashboardComponent },
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRouteingModule { }
Routesに加えた行のうち下の記述ですが、これはアドレスが空白(path:'')だった場合に'/dashboard'にリダイレクト(redirectTo)するという指示で、これがデフォルトルートの設定となります。
ページのルートにアクセスした際、ルーターは空白のアドレスを受け取っています。そのままだと何も表示されないのでdashboardに移動させます。リダイレクトといってもルータ内での挙動なので、ページ全体の読み込みは発生しません。
「pathMatc:full」の部分はURLチェックの完全一致を指定しています。公式ページにあるように、デフォルトは「prefix(前方一致)」となっていて、空白に対してこれを設定してしまうと無限ループになるのでfullを指定するようにとのことです。設定がprefixのまま実行してみるとすると同じような内容のエラーを出力して止まります。「prefix」は「:id」(後述します)を付けた時、前方一致でルーティングを機能させるモードです。
dashboardもheroes同様に、app.component.htmlにリンクを加えておきます。
app.component.html
<h1>{{title}}</h1>
<nav>
<a routerLink="/heroes">Heroes</a></nav>
<a routerLink="/dashboard">Heroes</a>
</nav>
<router-outlet></router-outlet>
<app-messages></app-messages>
プレースホルダを使ったルーティング
過去に作成したhero-detailコンポーネントを独立させるためにまずルーティングに加えます。
app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HeroesComponent } from './heroes/heroes.component';
import { DashboardComponent } from './dashboard/dashboard.component';
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
const routes: Routes = [
{ path: 'heroes', component: HeroesComponent },
{ path: 'dashboard', component: DashboardComponent },
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'detail/:id', component: HeroDetailComponent }
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRouteingModule { }
上記のコード中のRoutesの「path:detail/:id」の部分ですが、この:(コロン)は続く文字列がプレースホルダ(値の入れ物)であることを示しています。ちなみに、この時 pathMatchの値は「prefix」である必要がありますが「prefix」はデフォルト値なので記述していません。
この行の効果によりアドレスバーの値が「detail/12」でも「detail/snakeoil」でもルーターはHeroDetailComponentを呼び出します。
ただし前方一致といっても/(スラッシュは)別のようで、この時「detail/」だけだったり「detail/12/10」とすると一致しません。ちなみに「/12/10」のようにしたい場合はpathの値を「detail/:id/:subid」とすることで機能します。
ルーティングにhero-detailコンポーネント追加したら今度はそれをheroesコンポーネント(親)から呼び出せるようにします。
heroesコンポーネントではかつてonSelectメソッドを使ってクリック時にhero-detailコンポーネント(子)を呼び出すようにしていましたが、ルーターの機能に置き換えます。
heroes.component.htmlはaタグのrouterLink属性(ディレクティブ)に与える値の中で{{hero.id}}としてリンク先を形成します。
heroes.component.html
<ul class="heroes">
<li *ngFor="let hero of heroes">
<a routerLink="/detail/{{hero.id}}">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
</li>
</ul>
heroes.component.tsの方はそのままでも稼働しますが、以前の余分なコードがあるので削除します。
heroes.component.ts
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HeroService } from '../hero.service';
import { MessageService } from '../message.service';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
heroes: Hero[];
constructor(private heroService: HeroService,private messageService: MessageService) { }
ngOnInit(): void {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(function(heroes) {this.heroes = heroes}.bind(this));
}
}
hero-detailコンポーネントは今まで親コンポーネントから詳細情報を取得していましたが、アドレスから取得したidを使ってheroサービスから直接データを取得するようにします。加えて、使い勝手をよくするために「戻る」ボタンもつけてみます。
ルーターからidの値を取得するために@angular/routerからActivatedRouteを、戻る機能を提供するために@angular/commonからLocationをインポートしています。
heroサービスから値を取得するgetHero()を作成し、idを使ってheroサービスのメソッドを呼び出しています(後から作成します)。
ActivatedRouteのインスタンスからクラスをたどって、snapshot.paramMap.get('id');とすることで元のURLからidという名前のプレースホルダの値を取得できます。
ちなみにconst id=の後にある+thisの+はJavascriptの演算子で後に続く文字列を数値にするために利用されています。parseIntとの違いですが、parseInt("1a",10)は1になりますが、+"1a"はNaNです。
Locationのbackメソッドは履歴にしたがって前のページに戻る機能です。
hero-detail.component.ts
import { Component, OnInit,Input } from '@angular/core';
import { Hero } from '../hero';
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
import { HeroService } from '../hero.service';
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css']
})
export class HeroDetailComponent implements OnInit {
@Input() hero: Hero;
constructor(
private location: Location,
private route: ActivatedRoute,
private heroService: HeroService,
) { }
ngOnInit(): void {
this.getHero();
}
getHero(): void {
const id = +this.route.snapshot.paramMap.get('id');
this.heroService.getHero(id)
.subscribe(hero => this.hero = hero);
}
goBack(): void {
this.location.back();
}
}
hero-detail.htmlにgoBackメソッドを呼ぶボタンを作成します。
heroes.component.html
<div *ngIf="hero">
<h2>{{hero.name | uppercase}} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div>
<label>name:
<input [(ngModel)]="hero.name" placeholder="name"/>
</label>
<button (click)="goBack()">go back</button>
</div>
最後にheroサービスにidを指定してObsarvable<Hero>を返す機能を追加します。
hero.service.ts
import { Injectable } from '@angular/core';
import { Hero } from './hero';
import { HEROES } from './mock-heroes';
import { Observable, of } from 'rxjs';
import { MessageService } from './message.service';
@Injectable({
providedIn: 'root'
})
export class HeroService {
constructor(private messageService: MessageService) { }
getHeroes(): Observable<Hero[]> {
this.messageService.add('HeroService: fetched heroes');
return of(HEROES);
}
getHero(id: number): Observable<Hero> {
this.messageService.add(`HeroService: fetched hero id=${id}`);
return of(HEROES.find(hero => hero.id === id));
}
}
ルーター利用時のリロード対策
先のような設定で本番稼働時、Angularのルーターで表示させたページをブラウザからリロードするとNot Found(404)エラーになります。
ブラウザはリロード処理でルーターで表示させたアドレスを問い合わせに行きますが、問い合わせたファイルは実際には存在しませんのでNot Found(404)となります。
この問題はHashLocationStrategyを利用することで回避できます。
このクラスはAngularのLocationサービスの内部でアドレスを変換してくれるものです。具体的にはHashの名の通り#を挿入します。例えば「localhost/index.html」というアドレスは「「localhost/#/index.html」に変換します。
こうしておくことで、ブラウザリロード時にルーター用内部のアドレスはトップレベルアURLのフラグメントとしてWebサーバーは扱うので、Not Found(404)になるのを防ぐことができます。
リロードが終わった段階でAngularのRouterが適切なコンポーネントを再度表示してくれます。
HashLocationStrategyクラスを設定するには、app.module.tsのprovidersに次のように記述します。
app.module.ts
providers: [
...
{
provide: LocationStrategy,
useClass: HashLocationStrategy
}
],
...