Angularでヒーローを表示するアプリ(CRUDモデルの練習)を開発。(チュートリアル)
公式サイト:Angular
$ ng new angular-app
$ cd angular-app
$ ng serve --open
これでhttp://localhost:4200/
にアクセスできる。(開発者サーバの立ち上げ)
公式サイト:Angular powered Bootstrap
$ ng add @ng-bootstrap/ng-bootstrap
src/app/app.component.ts
:アプリケーションのタイトルを変更
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'Tour of Heroes';
}
src/app/app.component.html
:コンポーネントのテンプレートファイル。
Angular CLIで生成されたデフォルトのテンプレートを削除する。代わりに以下のHTMLを置く。
<h1>{{ title }}</h1>
AugularではVueと同様に{{}}
で変数を挿入する。この際、ブラウザがページを更新して新しいアプリのタイトルが表示される。
大半のアプリケーションは、アプリ全体で一貫した見た目を担保している。その際、空のsrc/style.css
を追加する。
src/styles.css
:アプリ全体のデザインを書く。
/* Application-wide Styles */
h1 {
color: #369;
font-family: Arial, Helvetica, sans-serif;
font-size: 250%;
}
h2, h3 {
color: #444;
font-family: Arial, Helvetica, sans-serif;
font-weight: lighter;
}
body {
margin: 2em;
}
body, input[type="text"], button {
color: #333;
font-family: Cambria, Georgia, serif;
}
/* everywhere else */
* {
font-family: Arial, Helvetica, sans-serif;
}
Angular CLIを活用して、heroes
という名前の新しいコンポーネントを生成する。
$ ng generate component heroes
CLIはsrc/app/heroes/
という新しいフォルダを作成し、HeroesComponent
に関する3つのファイルをテストファイルと一緒に生成する。
HeroesComponent
のクラスファイルは以下の通り。
app/heroes/heroes.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
constructor() { }
ngOnInit(): void {
}
}
このとき、常にAngularコアライブラリからComponent
シンボルをインポートし、コンポーネントクラスに@Component
で注釈をつける。
@Component
はコンポーネントのAngularメタデータを指定するデコレータ関数である。
CLIは次の3つのメタデータプロパティを生成する。
selector
―コンポーネントのCSS要素セレクタtemplateUrl
―コンポーネントのテンプレートファイルの場所styleUrls
―コンポーネントのプライベートCSSスタイルの場所
ngOnInit()
はライフサイクルフックである。ライフサイクルフックとは、Angularがコンポーネントクラスをインスタンス化してコンポーネントビューとその子ビューをレンダリングするときに開始する機能である。
src/app/heroes/heroes.component.ts
import { Component, OnInit } from '@angular/core';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
hero = 'Windstorm'
constructor() {}
ngOnInit(): void {
}
}
hero.component.html
テンプレートファイルを開く。Angular CLIで生成されたデフォルトのテキストを削除し、それを新しいhero
プロパティへのデータバインディングに置換する。
<p>heroes works!</p>
<h2>{{ hero }}</h2>
HeroesComponent
を表示するには、それをアプリケーションシェルのAppComponent
のテンプレートに追加する必要がある。
AppComponent
のテンプレートファイルで、タイトルの直下に<app-heroes>
要素を追加する。
<h1>{{ title }}</h1>
<app-heroes></app-heroes>
これでコンポーネントビューを表示できる。
src/app
フォルダ内にhero.ts
を作成し、Heroインターフェイスを作成する。それにid
とname
をそれぞれ与える。
src/app/hero.ts
export interface Hero {
id: number
name: string
}
HeroesComponent
クラスに戻って、Hero
インターフェイスをインポート。コンポーネントのhero
プロパティをHero
型に**リファクタリング(ソフトウェアの挙動を変えることなく、その内部構造を整理すること)**する。それを1
というid
とWindstorm
というname
で初期化する。
HeroesComponent
のクラスファイルは以下の通り。
src/app/heroes/heroes.component.ts
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero'; //hero.tsからHeroインターフェイスをインポート
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
// この際、ヒーローを文字列からオブジェクトに変更したので、ページが正しく表示されない。
export class HeroesComponent implements OnInit {
hero: Hero = {
id: 1,
name: 'Windstorm'
}
constructor() {}
ngOnInit(): void {
}
}
heroes.component.html
<p>heroes works!</p>
<h2>{{ hero.name }} Details</h2>
<div>
<span>id: </span>{{ hero.id }}
</div>
<div>
<span>name: </span>{{ hero.name }}
</div>
hero.name
のバインディングを以下のように修正する。
heroes.component.html
<h2>{{ hero.name | uppercase }} Details</h2>
ブラウザが更新され、ヒーローの名前が大文字で表示されるようになる。
パイプは、文字列、通貨金額、日付やその他の表示データの書式設定に最適。Angularには複数のパイプが備わっているので、オリジナルのパイプを作成できる。
Angularでは、アプリケーションの商品がどのように合わさるか、アプリケーションが必要としている他のファイルやライブラリを知る必要がある。この情報をメタデータと呼ぶ。
一部のメタデータは、コンポーネントクラスに追加した@Component
デコレータ内にある。最も重要な@NgModule
デコレータは、トップレベルのAppModuleクラスに注釈をつける。
Angular CLIでは、プロジェクトを新規作成する際にsrc/app/app.module.ts
にAppModule
クラスを作成する。ここでFormsModule
をオプトインする。
AppModule
(app.module.ts
)を開いて、@angular/forms
ライブラリからFormsModule
シンボルをインポートする。
app.module.ts
import { FormsModule } from '@angular/forms';
それから、FormModule
を@NgModule
メタデータをimports
配列に追加する。この配列には、アプリケーションに必要な外部モジュールのリストが含まれる。
app.module.ts
imports: [
BrowserModule,
FormsModule
],
HeroesComponent
テンプレートの詳細エリアをリファクタリングすると、以下のようになる。
heroes.component.html
<div>
<label for="name">Hero name: </label>
<input id="name" [(ngModel)]="hero.name" placeholder="name">
</div>
[(ngModel)]
はAngularの双方向データバインディング構文である。
これでhero.name
プロパティをHTMLのテキストボックスにバインドするので、hero.name
プロパティからテキストボックスへ、テキストボックスからhero.name
プロパティへ双方向へデータを流せる。
すべてのコンポーネントは、たった一つのNgModuleで宣言される必要がある。しかし、HeroesComponent
を宣言していないのに、どうしてアプリは作動したのか?
それは、AngularがHeroesComponent
を生成した際に、AppModule
でそのコンポーネントの宣言を行っていたから。
src/app/app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';
import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
import { HeroesComponent } from './heroes/heroes.component'; // 自動追加
@NgModule({
declarations: [
AppComponent,
HeroesComponent
],
imports: [
BrowserModule,
FormsModule,
AppRoutingModule,
NgbModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
最終的には、リモートのデータサーバからそれらのヒーローを取得する。
src/app/mock-heroes.ts
を作成し、以下のプログラムを書く。
import { Hero } from './hero';
export const HEROES: Hero[] = [
{ id: 11, name: 'Dr Nice' },
{ id: 12, name: 'Narco' },
{ id: 13, name: 'Bombasto' },
{ id: 14, name: 'Celeritas' },
{ id: 15, name: 'Magneta' },
{ id: 16, name: 'RubberMan' },
{ id: 17, name: 'Dynama' },
{ id: 18, name: 'Dr IQ' },
{ id: 19, name: 'Magma' },
{ id: 20, name: 'Tornado' }
];
HeroesComponent
クラスのファイルを開いて、HEROES
モックをインポートする。
heroes.component.ts
import { Component, OnInit } from '@angular/core';
import { HEROES } from '../mock-heroes';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
heroes = HEROES
constructor() {}
ngOnInit(): void {
}
}
HeroesComponent
テンプレートを開き、次のように変更する。
heroes.component.html
<h2>My Heroes</h2>
<ul class="heroes">
<li>
<span class="badge">{{ hero.id }}</span> {{ hero.name }}
</li>
</ul>
これは一つのヒーローしか表示しないので、リスト化して表示するにはヒーローのリストを反復処理する必要がある。*ngFor
を<li>
要素に追加。
<li *ngFor="let hero of heroes">
この際、プロパティhero
が存在しないので、エラーが表示されます。この際、ngFor
の前の*
(アスタリスク)を必ずつける。これがないと動かないので注意しよう。
ヒーローをCSSファイル等で装飾する際には、CSSファイルとして特定の@Component.styleUrls
配列の中で識別されるCSSファイルとして定義する。
src/app/heroes/heroes.component.ts
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
クリックイベントのバインディングを<li>
にこのように追加する。
heroes.component.html
<h2>My Heroes</h2>
<ul class="heroes">
<!--追加-->
<li *ngFor="let hero of heroes" (click)="onSelect(hero)">
<span class="badge">{{ hero.id }}</span> {{ hero.name }}
</li>
</ul>
コンポーネントのhero
プロパティをselectedHero
にリネームするが、この場合はまだ割り当てない。
以下のようにしてonSelect()
メソッドを追加し、クリックされたヒーローをテンプレートからコンポーネントのselectedHero
に割り当てる。
src/app/heroes/heroes.component.ts
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HEROES } from '../mock-heroes';
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
// 追加
heroes = HEROES
selectedHero?: Hero
onSelect(hero: Hero):void {
this.selectedHero = hero
}
// 追加ここまで
constructor() {}
ngOnInit(): void {
}
}
現在、コンポーネントテンプレートにはリストがある。リストのヒーローをクリックして、そのヒーローの詳細を表示するには、それをテンプレートでレンダリングするための詳細セクションを追加する必要がある。
heroes.component.html
のリストセクションの下に以下を追加。
<h2>{{selectedHero.name | uppercase}} Details</h2>
<div><span>id: </span>{{selectedHero.id}}</div>
<div>
<label for="hero-name">Hero name: </label>
<input id="hero-name" [(ngModel)]="selectedHero.name" placeholder="name">
</div>
アプリケーションを実行すると、エラーが表示されてしまう。これを修正するには、*ngIf
を使って空のdetails
を非表示にする必要がある。この際も、ngIf
の前にある*
を忘れないようにする。
src/app/heroes/heroes.component.html
<div *ngIf="selectedHero">
<h2>{{selectedHero.name | uppercase}} Details</h2>
<div><span>id: </span>{{selectedHero.id}}</div>
<div>
<label for="hero-name">Hero name: </label>
<input id="hero-name" [(ngModel)]="selectedHero.name" placeholder="name">
</div>
</div>
この際、ブラウザを更新すると名前の一覧が再度表示される。詳細のエリアは空白になっている。ヒーローのリストの中からヒーローをクリックし、詳細を表示する。
seelctedHero
が定義されていない時、ngIf
はDOMからヒーローの詳細を削除する。心配するselectedHero
へのバインディングは存在しない。
ユーザがヒーローを選択するとselectedHero
は値を持ってngIf
はヒーローの詳細をDOMの中に挿入する。
選択されたヒーローを装飾するために、先に追加したスタイルの中にある.selected
というCSSクラスを追加できる。ユーザがクリックした時に.selected
クラスを<li>
に適用するためには、クラスバインディングを使用する。
Angularのクラスバインディングは条件に応じてCSSクラスを追加したり削除したりできる。装飾したい要素に[class.some-css-class]="some-condition"
を追加するだけで動く。
heroes.component.html
<h2>My Heroes</h2>
<ul class="heroes">
<li *ngFor="let hero of heroes" [class.selected]="hero === selectedHero" (click)="onSelect(hero)">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</li>
</ul>
<div *ngIf="selectedHero">
<h2>{{selectedHero.name | uppercase}} Details</h2>
<div><span>id: </span>{{selectedHero.id}}</div>
<div>
<label for="hero-name">Hero name: </label>
<input id="hero-name" [(ngModel)]="selectedHero.name" placeholder="name">
</div>
</div>
src/app/heroes/heroes.component.css
.heroes {
margin: 0 0 2em 0;
list-style-type: none;
padding: 0;
width: 15em;
}
.heroes li {
cursor: pointer;
position: relative;
left: 0;
background-color: #EEE;
margin: .5em;
padding: .3em 0;
height: 1.6em;
border-radius: 4px;
}
.heroes li:hover {
color: #2c3a41;
background-color: #e6e6e6;
left: .1em;
}
.heroes li.selected {
background-color: black;
color: white;
}
.heroes li.selected:hover {
background-color: #505050;
color: white;
}
.heroes li.selected:active {
background-color: black;
color: white;
}
.heroes .badge {
display: inline-block;
font-size: small;
color: white;
padding: 0.8em 0.7em 0 0.7em;
background-color:#405061;
line-height: 1em;
position: relative;
left: -1px;
top: -4px;
height: 1.8em;
margin-right: .8em;
border-radius: 4px 0 0 4px;
}
input {
padding: .5rem;
}
これで選択されたヒーローをハイライト表示し、その詳細を表示するSPAを簡単に開発できる。
単一のコンポーネントにすべての機能を保持しておくと、アプリケーションが成長するにつれて維持できなくなる。大きなコンポーネントを、特定のタスクやワークフローに焦点を当てた小さなサブコンポーネントに分割したいと考えることがある。
Angular CLIを使用して、hero-detail
という新しい名前のコンポーネントを作成する。
$ ng generate component hero-detail
ヒーローの詳細が記されているHeroesComponent
のテンプレートの下部から切り取り、HeroDetailComponent
テンプレートに生成されたボイラープレートへ貼り付ける。
この際に貼り付けられたHTMLはselectedHero
を参照する。新しいHeroDetailComponent
は、選択されたヒーローだけではなく、どんなヒーローにも表示される。したがって、テンプレート内すべてのselectedHero
をhero
に置換してください。
src/app/hero-detail/hero-detail.component.html
<div *ngIf="hero">
<h2>{{ hero.name | uppercase }} Details</h2>
<div><span>id: </span>{{ hero.id }}</div>
<div>
<label for="hero-name">Hero name: </label>
<input id="hero-name" [(ngModel)]="hero.name" placeholder="name">
</div>
</div>
HerodetailComponent
クラスのファイルを追加して、Hero
シンボルをインポートする。
src/app/hero-detail/hero-detail.component.ts
import { Hero } from '../hero';
hero
プロパティは@Input()
デコレータで注釈されたinputプロパティでなければなりません。これは、外側のHeroesComponent
がこのようにバインドするため。
<app-hero-detail [hero]="selectedHero"></app-hero-detail>
Input
シンボルを含めるために、@angular/core
のimport文を修正する。
hero-detail.component.ts
import { Component, OnInit, Input } from '@angular/core';
@Input
デコレータが前についたhero
プロパティを追加する。
src/app/hero-detail/hero-detail.component.ts
import { Component, OnInit, Input } from '@angular/core';
import { Hero } from '../hero';
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css']
})
export class HeroDetailComponent implements OnInit {
@Input() hero?: Hero; // 追加
constructor() { }
ngOnInit(): void {
}
}
HeroDetailComponent
のセレクターはapp-hero-detail
だ。
ヒーローの詳細ビューがかつて存在したHeroesComponent
テンプレートの下部に<app-hero-detail>
を追加する。
以下のように、HeroesComponent.selectedHero
を、この要素のhero
プロパティにバインドさせる。
heroes.component.html
<app-hero-detail [hero]="selectedHero"></app-hero-detail>
[hero]="selectedHero"
はAngularのプロパティバインディング。
これは、HeroesComponent
のselectedHero
プロパティから、ターゲット要素のhero
プロパティへの単方向データバインディングである。これは、HeroDetailComponent
のhero
プロパティがマッピングされる。
ユーザがリスト内のヒーローをクリックすると、selectedHero
が変更される。selectedHero
が変更されると、プロパティバインディングはhero
を更新して、HeroDetailComponent
は新しいヒーローを表示する。
heroes.component.html
<h2>My Heroes</h2>
<ul class="heroes">
<li *ngFor="let hero of heroes"
[class.selected]="hero === selectedHero"
(click)="onSelect(hero)">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</li>
</ul>
<app-hero-detail [hero]="selectedHero"></app-hero-detail>
以前は、ユーザがヒーロー名をクリックする際に、ヒーローのリストの下にヒーローの詳細が表示されていた。
今ではHeroDetailComponent
がHeroesComponent
の代わりにそれらの詳細を示している。
HeroComponent
のスコープを減少。- 親の
HeroesComponent
に触れることなく、HeroDetailComponent
をリッチなヒーローエディタに進化。 - ヒーローの詳細ビューに触れることなく。
HeroesComponent
を進化。 - 将来のコンポーネントのテンプレートで、
HeroDetailComponent
を再利用。
アプリケーションを開発する際に、コンポーネント内で直接データの保存や取得を行うべきではない。コンポーネントはデータの受け渡しだけに集中し、その他の処理はサービスクラスへ委譲するべき。
Angular CLIを作成し、Hero Service
を作成する。
$ ng generate service hero
このコマンドはHeroService
のスケルトンファイルをsrc/app/hero.serive.ts
に以下のように保存する。
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class HeroService {
constructor() { }
}
生成されたファイルの中でAngularのInjectableシンボルがインポートされ、@Injectable()
デコレータとしてクラスを注釈することに注目する。これは、クラスを依存関係システムに参加するものとしてマークする。HeroSerive
クラスは、注入可能なサービスを提供する予定で、それ自身が依存関係を持てる。
HeroService
は様々な場所からヒーローデータを取得することがある。
コンポーネントからデータ取得ロジックを切り離すことで、そのようなサービス側の事情に関係なくいつでも実装方針の変更ができます。コンポーネント側は、サービスがどのように動いているのか関係ありません。
Hero
とHEROES
をインポート。
src/app/hero.service.ts
import { Hero } from './hero';
import { HEROES } from './mock-heroes';
getHeroes
メソッドを追加し、モックヒーローを完成する。
src/app/hero.service.ts
import { Injectable } from '@angular/core';
import { Hero } from './hero';
import { HEROES } from './mock-heroes';
@Injectable({
providedIn: 'root'
})
export class HeroService {
// 追加
getHeroes(): Hero[] {
return HEROES
}
constructor() { }
}
AngularがHeroesComponent
へ注入する前に、プロバイダを登録することでHeroService
が依存性の注入システムで利用できる必要がある。プロバイダーとは、サービスを作成あるいは提供できるもの。この場合は、HeroService
クラスをインスタンス化してサービスを提供する。
HeroService
をインジェクター(必要な場所でプロバイダーを選択して注入するためのオブジェクト)に登録することで、サービスを提供できるようになる。
デフォルトでは、Angular CLIコマンドng generate service
は、プロバイダーのメタデータ、言い換えればprovidedIn: 'root'
を@Injectable()
デコレータに含めることで、プロバイダーをサービスのルートインジェクターに登録できる。
@Injectable({
providedIn: 'root',
})
ルートレベルでサービスを提供すると、AngularはHeroService
の単一の共有インスタンスを作成し、それを要求する任意のクラスに注入する。@Injectable
メタデータでプロバイダーを登録すると、Angularはサービスが使用されなくなった際にそれを削除することでアプリケーションを最適化できる。
heroes.component.ts
import { Component, OnInit } from '@angular/core';
import { Hero } from '../hero';
import { HeroService } from '../hero.service'; // HEROESのインポートを削除し、HeroServiceを代わりにインポートする。
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
heroes: Hero[] = [] // heroesプロパティの定義を宣言に置換する
selectedHero?: Hero
onSelect(hero: Hero):void {
this.selectedHero = hero
}
constructor(private heroService: HeroService) {} // HeroServiceの注入
getHeroes(): void {
this.heroes = this.heroService.getHeroes() // サービスからヒーローデータを取得するためのメソッドを作成する
}
ngOnInit(): void {
this.getHeroes() // 作成したメソッドはngOnInit()で呼び出す。
}
}
【補足】
getHeroes()
はコンストラクターでも呼び出せるが、これは最適な方法ではない。
原則、Angularにおけるコンストラクターではプロパティ定義の簡単な初期化だけを行い、それ以外は何もするべきではない。実際にデータを取得する際に行うサーバへのHTTPリクエストを行う関数は呼び出すべきではない。
getHeroes()
はコンストラクターではなく、ngOnInit
ライフサイクルフックで呼び出す。
HeroService.getHeroes()
は同期的なメソッドで、これはHeroService
が即座にヒーローデータを取得できることを意味する。
また、HeroesComponent
はgetHeroes()
の返り値がまるで同期的に取得できるかのように扱える。
src/app/heroes/heroes.component.ts
this.heroes = this.heroService.getHeroes();
しかし、これは本番環境のアプリケーションでは機能しない。
今のアプリケーションはモックヒーローを返しているのでこれを免れていますが、リモートサーバからヒーローデータを取得するにあたってこの処理は非同期ということに気づく。
**HeroService
はサーバのレスポンスを持つ必要があり、getHeroes()
は即座にヒーローデータを返せない。**そしてそのサービスが待機している間は、ブラウザはブロックされないだろう。
HeroService.getHeroes()
は何らかの非同期処理を実装する必要がある。
Observable
はRxJSライブラリで重要なクラスの一つ。RxJSのof()
を使ってサーバからのデータ取得を行う。
HeroService
を開いて、Observable
及びof
をRxJS
からインポートする。
src/app/hero.service.ts
import { Observable, of } from 'rxjs';
getHeroes()
を以下のように書き直す。
src/app/hero.service.ts
getHeroes(): Observable<Hero[]> {
const heroes = of(HEROES);
return heroes;
}
of(HEROES)
は一つの値、すなわちモックヒーローの配列を出力するObservable<Hero[]>
を返す。
HeroService.getHeroes
メソッドはHero[]
を返していたが、現在の返り値はObservable<Hero[]>
である。そのため、これらの違いを修正する必要がある。
getHeroes
を開いて、以下のコードに変更する。
heroes.component.ts
(Observable)
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes)
}
heroes.component.ts
(Original)
getHeroes(): void {
this.heroes = this.heroService.getHeroes();
}
Observable.subscribe()
は重要な違い。
変更後のバージョンでは、Observable
がヒーローの配列を出力するのを待つ。これは現在あるいは数分後に起こる可能性が高い。
そのとき、subscribe()
メソッドは出力された配列をコールバックに渡し、コンポーネントのheroes
プロパティを設定する。
この非同期的手法は、HeroService
がサーバからヒーローを取得する際に正常に動作。
このセクションでは、以下の方法について詳細に説明する。
- メッセージを表示するための
MessageComponent
を画面下部に表示 - 表示するメッセージを送信するために、アプリケーション全体で注入可能な
MessageService
を作成 HeroService
にMessageService
を注入HeroService
のデータ取得成功時にメッセージを表示
Angular CLIでMessagesComponent
の作成。
$ ng generate component messages
Angular CLIはsrc/app/messages
配下にコンポーネントファイル群を生成し、AppModule
内にMessagesComponent
を宣言する。
作成したMessagesComponent
を表示するために、AppComponent
のテンプレートを修正する。
src/app/app.component.html
<h1>{{title}}</h1>
<app-heroes></app-heroes>
<app-messages></app-messages>
Angular CLIを作成し、src/app
配下にMessageService
を作成する。
$ ng generate service message
MessageService
を開いて、以下のコードへ修正する。
src/app/message.service.ts
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root',
})
export class MessageService {
messages: string[] = [];
add(message: string) {
this.messages.push(message);
}
clear() {
this.messages = [];
}
}
HeroService
でMessageService
をインポート。
src/app/hero.service.ts
import { MessageService } from './message.service';
プライベートなmessageService
プロパティを宣言するパラメータを使ってコンストラクタを変更する。AngularはHeroService
を生成する際に、そのプロパティへシングルトンなMessageService
を注入する。
src/app/hero.service.ts
constructor(private messageService: MessageService) { }
ヒーローが取得された時にメッセージを送信するようにgetHeroes()
メソッドを変更。
src/app/hero.service.ts
getHeroes(): Observable<Hero[]> {
const heroes = of(HEROES);
this.messageService.add('HeroService: fetched heroes');
return heroes;
}
MessagesComponent
はHeroService
がヒーローを取得した際に送信するメッセージを含めて、全てのメッセージを表示しなければならない。
MessagesComponent
を開いて、MessageService
をインポート。
src/app/messages/messages.component.ts
import { MessageService } from '../message.service';
Angular CLIで生成されたMessagesComponent
のテンプレートを下記コードへ置き換えましょう。
src/app/messages/messages.component.html
<div *ngIf="messageService.messages.length">
<h2>Messages</h2>
<button class="clear"
(click)="messageService.clear()">Clear messages</button>
<div *ngFor='let message of messageService.messages'> {{message}} </div>
</div>
messages.component.css
をコンポーネントのスタイルに追加すると、このメッセージのUIの外観はより良いものになるだろう。
ユーザがヒーローをクリックする際に、メッセージを送信、表示してユーザの選択履歴を表示する方法を示す。
src/app/heroes/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 {
selectedHero?: Hero;
heroes: Hero[] = [];
constructor(private heroService: HeroService, private messageService: MessageService) { }
ngOnInit(): void {
this.getHeroes();
}
onSelect(hero: Hero): void {
this.selectedHero = hero;
this.messageService.add(`HeroesComponent: Selected hero id=${hero.id}`);
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes);
}
}
ヒーローリストを見るためにブラウザを更新し、一番下までスクロールするとHeroService
からのメッセージが表示される。
ヒーローをクリックするたびに、新しいメッセージが選択を登録して表示されるようになる。
以下の機能を実装する。
- ダッシュボードビューを追加
- ヒーローズビューとダッシュボードビューのあいだで行き来できる機能を追加
- ユーザが各ビューでヒーロー名をクリックした際に、選択されたヒーローの詳細ビューを表示
- ユーザがemail上でリンクをクリックした時、特定のヒーローの詳細ビューを表示
Angularのベストプラクティスは、ルートのAppModule
からインポートされるルーティング専用のトップレベルモジュールで、ルーターをロードして管理すること。
モジュールのクラス名はAppRoutingModule
とし、src/app
フォルダのapp-routing.module.ts
に書く。
CLIで生成できる。
$ ng generate module app-routing --module=app
生成されたファイルは以下のようになる
src/app/app-routing.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
declarations: [],
imports: [
CommonModule
]
})
export class AppRoutingModule { }
こちらのプログラムを以下のように書き換える。
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 { }
最初にアプリケーションにルーティング機能をもたせられるRouterModule
とRoutes
をインポートする。次のインポートであるHeroesComponent
は、ルートを設定することでルーターに向かう場所を教える。
CommonModule
の参照とdeclarations
配列は不要。
ファイルの次の部分は、ルートを構成する場所である。ルートは、ユーザがリンクをクリックした際に、またはURLをブラウザのアドレスバーに貼り付けた際に表示するビューをルーターに伝える。
app-routing.module.ts
はすでにHeroesComponent
をインポートしているので、routes
配列で使える。
src/app/app-routing.module.ts
const routes: Routes = [
{ path: 'heroes', component: HeroesComponent }
];
典型的なAngularのRoute
は2つの要素を持つ。
path
:ブラウザのアドレスバーにあるURLにマッチする配列component
:そのルートに遷移する際にルーターが作成すべきコンポーネント
これによって、ルーターはそのURLをpath: 'heroes'
に一致させて、URLがlocalhost:4200/heroes
のようなものに限ってHeroesComponent
を表示できる。
@NgModule
メタデータはルーターを初期化してブラウザのロケーションの変更を待機する。
以下の行は、RouterModule
をAppRoutingModule
のimports
配列に追加し、RouterModule.forRoot()
を呼び出してワンステップでroutes
に追加。
src/app/app-routing.module.ts
imports: [ RouterModule.forRoot(routes) ],
【解説】
アプリケーションのルートのレベルでルーターを設定しているので、このメソッドは
forRoot()
と呼ばれる。このメソッドは、ルーティングに必要なサービスやプロバイダーとディレクティブを提供し、ブラウザの現在のURLをベースに最初の遷移を行う。
次に、AppRoutingModule
はRouterModule
をエクスポートして、アプリケーション全体で利用できるようにする。
src/app/app-routing.module.ts
exports: [ RouterModule ]
AppComponent
テンプレートを開いて、<app-heroes>
要素を<router-oulet>
に置換
src/app/app.component.html
<h1>{{title}}</h1>
<router-outlet></router-outlet>
<app-messages></app-messages>
こちらのCLIコマンドを入力
$ ng serve
ブラウザを更新するとアプリケーションのタイトルは表示されるが、ヒーローのリストは表示されない。
ブラウザのアドレスバーを見ると、URLが/
で終了。HeroesComponent
へのルーターのパスは/heroes
。これを入力すればおなじみのヒーローのマスター/詳細ビューが表示されるはず。(実際には表示されていない)
ナビゲーションのリンクを追加(routerLink
)
理想的には、ルートのURLをアドレスバーに貼り付けるのではなく、ユーザがリンクをクリックして遷移できるようにする必要がある。
<nav>
要素を追加して、その中にクリックされるとHeroesComponent
へ遷移するトリガーになるアンカー要素を追加。修正されたAppComponent
テンプレートは以下のようになる。
src/app/app.component.html
<h1>{{title}}</h1>
<nav>
<a routerLink="/heroes">Heroes</a>
</nav>
<router-outlet></router-outlet>
<app-messages></app-messages>
この際、routerLink
属性は、ルーターがHeroesComponent
へのルートとして一致する文字列である"/heroes"
に設定される。このrouterLink
は、ユーザのクリックをルーターのナビゲーションへ変換するRouterLink
ディレクティブのためのセレクターである。
このとき、ブラウザを更新するとアプリケーションのタイトルとヒーローのリンクは表示されるが、ヒーローのリストは表示されない。
ルーティングは、複数のビューがある場合に更に意味を持つ。CLIを使ってDashBoardComponent
を追加する。
$ ng generate component dashboard
CLIは、DashBoardComponent
のためのファイルを生成し、AppModule
の中でそれを宣言する。これら3つのファイルのデフォルト内容を以下のように書き換える。
src/app/dashboard/dashboard.component.html
<h2>Top Heroes</h2>
<div class="heroes-menu">
<a *ngFor="let hero of heroes">
{{hero.name}}
</a>
</div>
src/app/dashboard/dashboard.component.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(): void {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes.slice(1, 5));
}
}
src/app/dashboard/dashboard.component.css
/* DashboardComponent's private CSS styles */
h2 {
text-align: center;
}
.heroes-menu {
padding: 0;
margin: auto;
max-width: 1000px;
/* flexbox */
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
align-content: flex-start;
align-items: flex-start;
}
a {
background-color: #3f525c;
border-radius: 2px;
padding: 1rem;
font-size: 1.2rem;
text-decoration: none;
display: inline-block;
color: #fff;
text-align: center;
width: 100%;
min-width: 70px;
margin: .5rem auto;
box-sizing: border-box;
/* flexbox */
order: 0;
flex: 0 1 auto;
align-self: auto;
}
@media (min-width: 600px) {
a {
width: 18%;
box-sizing: content-box;
}
}
a:hover {
background-color: #000;
}
ダッシュボードに遷移するには、ルーターに適切なルートが必要である。app-routiung.module.ts
でDashboardComponent
をインポート。
src/app/app-routing.module.ts
import { DashboardComponent } from './dashboard/dashboard.component';
routes
配列に、DashboardComponent
へのパスにマッチするルートを追加。
src/app/app-routing.module.ts
{ path: 'dashboard', component: DashboardComponent },
アプリケーションを起動すると、ブラウザのアドレスバーはWebサイトのルートを指す。これは既存のルートと一致しないので、ルーターはどこにも移動しない。<router-outlet>
の下のスペースが空白になっているからだ。
アプリケーションをダッシュボードに自動的に遷移するには、以下のルートをroutes
配列に追加。
src/app/app-routing.module.ts
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
このルートは、空のパスと完全に一致するURLを、パスが/dashboard
であるルートにリダイレクトする。
ブラウザが更新されると、ルーターはDashboardComponent
をロードし、ブラウザのアドレスバーには/dashboard
のURLが表示される。
ユーザはページのトップにあるナビゲーション領域のリンクをクリックすることで、DashboardComponent
とHeroesComponent
のあいだを行き来することができます。
Heroesリンクの上、AppComponent
シェルテンプレートにダッシュボードのナビゲーションリンクを追加する。
src/app/app.component.html
<h1>{{title}}</h1>
<nav>
<a routerLink="/dashboard">Dashboard</a>
<a routerLink="/heroes">Heroes</a>
</nav>
<router-outlet></router-outlet>
<app-messages></app-messages>
ブラウザが更新されると、リンクをクリックすることで二つのビューの間を自由に遷移できるようになる。
HeroDetailComponent
は選択されたヒーローの詳細を表示する。
- ダッシュボードのヒーローをクリックする
- ヒーローリストのヒーローをクリックする
- 表示するヒーローを識別するブラウザのアドレスバーにディープリンクURLを貼り付ける
ユーザがHeroesComponent
で一つのヒーローをクリックすると、アプリはHeroDetailComponent
に遷移する必要があり、ヒーローリストビューをヒーロー詳細ビューに置換する。
HeroesComponent
テンプレート(heroes/heroes.component.html
)を開いて、<app-hero-detail>
要素を一番下から削除する
app-routing.module.ts
を開いて、HeroDetailComponent
をインポートする。
src/app/app-routing.module.ts
import { HeroDetailComponent } from './hero-detail/hero-detail.component';
次に、ヒーロー詳細ビューへのパスのパターンと一致するパラメータ付きルートをroutes
配列に追加する。
src/app/app-routing.module.ts
{ path: 'detail/:id', component: HeroDetailComponent },
▲上記のプログラムと同じファイル
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'detail/:id', component: HeroDetailComponent },
{ path: 'heroes', component: HeroesComponent }
];
現時点ではDashboardComponent
ヒーローへのリンクは何もしない。
ルーターはHeroDetailComponent
へのルートを持っているので、ダッシュボードのリンクを修正してパラメータ付きダッシュボードのルート経由で遷移する。
src/app/dashboard/dashboard.component.html
<a *ngFor="let hero of heroes"
routerLink="/detail/{{hero.id}}">
{{hero.name}}
</a>
*ngFor
リピーター内でAngularの補間バインディングを活用し、現在の繰り返しのhero.id
を個々のrouterLink
に挿入する。
HeroesComponent
のヒーローのアイテムは、コンポーネントのonSelect()
メソッドにバインディングされたクリックイベントを持つ<li>
要素。
src/app/heroes/heroes.component.html
<ul class="heroes">
<li *ngFor="let hero of heroes"
[class.selected]="hero === selectedHero"
(click)="onSelect(hero)">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</li>
</ul>
こちらの<li>
要素を*ngFor
だけを持つように戻して、アンカー要素(<a>
)でバッジと名前を囲み、ダッシュボードのテンプレートと同じようにアンカーにrouterLink
要素を追加。
src/app/heroes/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.css
)を修正して、これまでと同じようにリストが見えるようにする。
HeroesComponent
クラスはまだ動作するが、onSelect()
メソッドとselectedHero
プロパティはもはや使われない。
不要なコードを削除する。(必要最低限の機能で実装するため)
src/app/heroes/heroes.component.ts
export class HeroesComponent implements OnInit {
heroes: Hero[] = [];
constructor(private heroService: HeroService) { }
ngOnInit(): void {
this.getHeroes();
}
getHeroes(): void {
this.heroService.getHeroes()
.subscribe(heroes => this.heroes = heroes);
}
}
HeroesComponent
は表示するヒーローを取得するための新しい方法が必要。
- それを作成したルートを取得
- ルートから
id
を抽出 HeroService
を経由してサーバからそのid
でヒーローを取得する
src/app/hero-detail/hero-detail.component.ts
import { ActivatedRoute } from '@angular/router';
import { Location } from '@angular/common';
import { HeroService } from '../hero.service';
ActivatedRoute
、HeroService
、Location
サービスをコンストラクタに入れて、それらの値をプライベートフィールドに保存。
src/app/hero-detail/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 route: ActivatedRoute, // HeroDetailComponentのインスタンスのルートに関する情報を保持
private heroService: HeroService, // リモートサーバからヒーローのデータを取得し、このコンポーネントはそれを使用して表示するヒーローを取得
private location: Location // ブラウザと対話するためのAngularサービス。
) { }
ngOnInit(): void {
}
}
ngOnInit()
ライフサイクルハックで、getHero()
を呼び出して以下のように定義する。
src/app/hero-detail/hero-detail.component.ts
ngOnInit(): void {
this.getHero();
}
getHero(): void {
const id = Number(this.route.snapshot.paramMap.get('id'));
this.heroService.getHero(id)
.subscribe(hero => this.hero = hero);
}
route.snapshot
は、コンポーネントが作成された直後のルート情報の静的イメージ。paramMap
は、URLから抽出されたルートパラメータ値の辞書。id
キーは、fetchするヒーローのid
を返す。
ルートパラメータは常に文字列。JavaScriptのNumber
関数は文字列を数値に変換。
しかし、上記のプログラムではプライベート変数heroService
にgetHero()
メソッドが定義されていないので、コンパイルエラーが表示される。
HeroService
を開いて、getHeroes()
メソッドの後にid
とともに次のgetHero()
メソッドを追加する。
src/app/hero.service.ts
getHero(id: number): Observable<Hero> {
// 現時点では、このプログラムではHeroとidの型が一致しない型エラーが発生する。対処法は後述
const hero = HEROES.find(h => h.id === id)!;
this.messageService.add(`HeroService: fetched hero id=${id}`);
return of(hero);
}
このとき、id
を埋め込むためのJavaScriptのテンプレートリテラルを定義するバッククォートに注意すること。
上記のプログラムでは、getHero()
を呼び出すHeroDetailComponent
を変更することなく、実際のHttp
リクエストとしてgetHero()
を再実装できる。
ブラウザの戻るボタンをクリックすると、詳細ビューに来た時の経路によって、ヒーローリストまたはダッシュボード画面に戻れる。
HeroDetail
ビュー上にそのような挙動を実装できるボタンを表示。コンポーネントのテンプレートの最後に戻るボタンを追加して、コンポーネントのgoBack()
メソッドにバインド。
src/app/hero-detail/hero-detail.component.html
<button (click)="goBack()">go back</button>
前述の通り導入したLocation
サービスで、ブラウザの履歴の一つ前にナビゲートするgoBack()
メソッドをコンポーネントのクラスに追加。
src/app/hero-detail/hero-detail.component.ts
goBack(): void {
this.location.back();
}
最後に、独自のCSSスタイルをhero-detail.component.css
に追加すると、詳細がより美しく表示される
src/app/hero-detail/hero-detail.component.css
/* HeroDetailComponent's private CSS styles */
label {
color: #435960;
font-weight: bold;
}
input {
font-size: 1em;
padding: .5rem;
}
button {
margin-top: 20px;
background-color: #eee;
padding: 1rem;
border-radius: 4px;
font-size: 1rem;
}
button:hover {
background-color: #cfd8dc;
}
button:disabled {
background-color: #eee;
color: #ccc;
cursor: auto;
}
AngularのHttpClient
を使ってCRUDモデルを構築する
HeroService
はHTTPリクエストを介してヒーローデータを取得- ユーザはヒーロー情報を追加、編集、削除でき、その変更をHTTP経由で伝達できる。
- ユーザは名前でヒーロー情報を検索できる
HttpClient
はHTTPを通してリモートサーバと通信するための仕組み。
HttpClient
をインポートしてルートのAppModule
に追加。
src/app/app.module.ts
import { HttpClientModule } from '@angular/common/http';
また、AppModule
でHttpClientModule
をimports
配列に追加する。
src/app/app.module.ts
import { HttpClientModule } from '@angular/common/http';
...
@NgModule({
imports: [
HttpClientModule,
],
})
In-memory Web APIでリモートサーバとの通信を再現する。
このモジュールをインストールすると、アプリケーションはインメモリWeb APIがリクエストをインターセプトして、そのリクエストをインメモリデータストアに適用してシミュレートされたレスポンスを返えさずにHttpClient
でリクエストを送信、レスポンスを受信できます。
以下のコマンドを使って、npmからAPIをインストール。
npm install angular-in-memory-web-api --save
AppModule
で、HttpClientInMemoryWebApiModule
と、これからすぐに作成するInMemoryDataService
クラスをインポート。
src/app/app.module.ts
import { HttpClientInMemoryWebApiModule } from 'angular-in-memory-web-api';
import { InMemoryDataService } from './in-memory-data.service';
HttpClientModule
の後に、HttpClientInMemoryWebApiModule
をAppModule
のimports
配列に追加し、InMemoryDataService
で設定。
HttpClientModule,
HttpClientInMemoryWebApiModule.forRoot(
InMemoryDataService, { dataEncapsulation: false }
)
forRoot()
設定メソッドは、インメモリデータベースを準備するInMemoryDataService
クラスを取る。
以下のコマンドでsrc/app/in-memory-data.service.ts
クラスを生成
ng generate service InMemoryData
in-memory-data.service.ts
のデフォルトの内容を以下のものに置き換える。
src/app/in-memory-data.service.ts
import { Injectable } from '@angular/core';
import { InMemoryDbService } from 'angular-in-memory-web-api';
import { Hero } from './hero';
@Injectable({
providedIn: 'root',
})
export class InMemoryDataService implements InMemoryDbService {
createDb() {
const heroes = [
{ id: 11, name: 'Dr Nice' },
{ id: 12, name: 'Narco' },
{ id: 13, name: 'Bombasto' },
{ id: 14, name: 'Celeritas' },
{ id: 15, name: 'Magneta' },
{ id: 16, name: 'RubberMan' },
{ id: 17, name: 'Dynama' },
{ id: 18, name: 'Dr IQ' },
{ id: 19, name: 'Magma' },
{ id: 20, name: 'Tornado' }
];
return {heroes};
}
// Overrides the genId method to ensure that a hero always has an id.
// If the heroes array is empty,
// the method below returns the initial number (11).
// if the heroes array is not empty, the method below returns the highest
// hero id + 1.
genId(heroes: Hero[]): number {
return heroes.length > 0 ? Math.max(...heroes.map(hero => hero.id)) + 1 : 11;
}
}
この際、in-memory-data.service.ts
ファイルはmock-heroes.ts
の機能を継承。しかし、まだmock-heroes.ts
は削除しない。
サーバが用意できたら、アプリケーションのリクエストはサーバに送信される。
Angularはコマンド入力だけで機能を実装するのに必要なプロジェクトを簡単に出力できるのが最大の特徴。フロントエンドフレームワークと呼ばれているので、柔軟性は低いけど簡単にフロントエンドを構築できる。
最近の開発でハマっているNestはAngularのバックエンドバージョンみたいなものである。