flux
Angular
Angular2

Angular2のRouting, Formsの簡単なサンプル。ついでにFlux。(2.0)

More than 1 year has passed since last update.

(追記)2.0に対応しました。(2016/09/15)

Angular2の開発環境、俺ならこうやる 2016年7月25日版(2.0)のリポジトリを基本にしつつ、RoutingとFormsを簡単に理解できるようなサンプルを作りました。

今回のGitHubリポジトリ→ovrmrw/ng2-heroes-editor

https://ovrmrw-ng2-heroes-editor-e87d0.firebaseapp.com/は実際にこのリポジトリをデプロイしたものです。


Routingの設定

ネーミングがわかりにくいのですが、今回のサンプルはPage1はHero List、Page2はAdd Hero/Edit Heroの構成です。

Page2に入るとき、idが渡されればEditing、渡されなければAddingと判断します。

公式チュートリアルのRoutingの章を読むと理解が深まります。


src/app/app.routing.ts

// 抜粋

const appRoutes: Routes = [
{
path: '',
redirectTo: '/page1',
pathMatch: 'full'
},
{
path: 'page1',
component: Page1Component
},
{
path: 'page2',
component: Page2Component
},
{
path: 'page2/:id',
component: Page2Component
}
];

Page2のconstructorでActivatedRouteのparamsを受け取り、EditingかAddingの条件分岐をしています。


src/page2/page2.component.ts

// 抜粋

constructor(
public service: Page2Service,
public route: ActivatedRoute,
public router: Router,
public cd: ChangeDetectorRef,
public el: ElementRef
) { }

ngOnInit() {
this.route.params.forEach(async (params: Params) => {
const heroes: Hero[] = await this.service.heroes$.take(1).toPromise();
if (params['id']) { // Editing Mode
const id: number = +params['id'];
const selectedHero: Hero | undefined = heroes.find(hero => hero.id === id);
if (selectedHero) {
this.hero = selectedHero;
} else {
alert('no hero for the explicit id.');
this.router.navigate(['/page1']);
}
} else { // Adding Mode
const newId: number = heroes.length > 0 ? lodash.maxBy(heroes, 'id').id + 1 : 1;
this.hero = new Hero();
this.hero.id = newId;
this.isAdding = true;
}
this.cd.markForCheck();
});
}



Formsを使う

@angular/forms の使い方は僕自身まだよくわかっていませんが、公式ドキュメントを一通り読んで書いたみたのがこちら。


src/page2/page2.component.ts

// 抜粋

@Component({
selector: 'my-page2',
template: `
<h3>{{modeName}}</h3>
<form *ngIf="hero" (ngSubmit)="onSubmit()" #heroForm="ngForm">
<div class="form-group row">
<label for="id" class="col-xs-2 col-form-label">Id: </label>
<div class="col-xs-10">
<input class="form-control" type="number" id="id" [(ngModel)]="hero.id" name="id" #id="ngModel" required [disabled]="!isAdding" #spy>
<div [hidden]="id.valid || id.pristine" class="alert alert-danger">
Id is required
</div>
<pre>className: {{spy.className}}</pre>
</div>
</div>
<div class="form-group row">
<label for="name" class="col-xs-2 col-form-label">Name: </label>
<div class="col-xs-10">
<input class="form-control" type="text" id="name" [(ngModel)]="hero.name" name="name" #name="ngModel" required #spy>
<div [hidden]="name.valid || name.pristine" class="alert alert-danger">
Name is required
</div>
<pre>className: {{spy.className}}</pre>
</div>
</div>
<pre>{{hero | json}}</pre>
<button type="submit" class="btn btn-outline-primary" [disabled]="!heroForm.form.valid">Submit</button>
<pre>heroForm.form.valid: {{heroForm.form.valid | json}}</pre>
</form>
`
,
styles: [`
.ng-valid[required] {
border-left: 10px solid #42A948; /* green */
}
.ng-invalid {
border-left: 10px solid #a94442; /* red */
}
`
],
changeDetection: ChangeDetectionStrategy.OnPush
})

Componentがすっきりする代わりにtemplateがごちゃごちゃするのが特徴。ただしちゃんとFormsが使えるとValidationが効くようになるので早い段階で慣れた方が良さそうですね。


Storeを作ってみる

Angular2に限らずSPAをやるときにはデータを如何に管理するかが課題となります。

そして今回作ったStoreがこちら。


src/store/store.ts

import { Injectable } from '@angular/core';

import { Observable, Subject, BehaviorSubject, ReplaySubject } from 'rxjs/Rx';
import lodash from 'lodash';

import { Hero } from '../types';
import { Action, EditHero, AddHero, DeleteHero } from './actions';
import { logger } from '../helper';

interface AppState {
heroes: Hero[];
}

export class Dispatcher<T> extends Subject<T> { }

@Injectable()
export class Store {
private stateSubject$: Subject<AppState>;

constructor(
dispatcher$: Dispatcher<Action>
) {
const initState: AppState = {
heroes: initialHeroes
};
this.stateSubject$ = new BehaviorSubject(initState);

Observable
.zip(
heroReducer(initState.heroes, dispatcher$),
(heroes) => {
return { heroes } as AppState;
}
)
.subscribe(appState => {
this.stateSubject$.next(appState);
});
}

private get returner$() { return this.stateSubject$.asObservable().map(state => lodash.cloneDeep(state)); }

get state$() { return this.returner$; }
get heroes$() { return this.returner$.map(state => state.heroes); }
}

function heroReducer(initHeroes: Hero[], dispatcher$: Observable<Action>): Observable<Hero[]> {
return dispatcher$
.scan((heroes: Hero[], action: Action) => {
const oldHeroes = heroes;
if (action instanceof EditHero) {
const editedHero = action.hero;
heroes = lodash.uniqBy([editedHero, ...heroes], 'id');
} else if (action instanceof AddHero) {
const newHero = action.hero;
heroes = lodash.uniqBy([newHero, ...heroes], 'id');
} else if (action instanceof DeleteHero) {
const deleteId = action.id;
heroes = lodash.reject(heroes, { id: deleteId });
}
return lodash.orderBy(heroes, ['id'], ['asc']);
}, initHeroes);
}

const initialHeroes: Hero[] = [
{ id: 11, name: 'Mr. 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' }
];


こちらのActionsを使ってStoreを叩きます。


src/store/actions.ts

import { Hero } from '../types';

export class EditHero {
constructor(public hero: Hero) { }
}

export class AddHero {
constructor(public hero: Hero) { }
}

export class DeleteHero {
constructor(public id: number) { }
}

export type Action = EditHero | AddHero | DeleteHero;


Page1とPage2のserviceからStoreを叩くのですが、page2.service.ts の中身はこんな感じ。


src/page2/page2.service.ts

// 抜粋

constructor(
public store: Store,
public dispatcher$: Dispatcher<Action>
) { }

save(hero: Hero, isAdding: boolean) {
if (typeof hero.id === 'number' && typeof hero.name === 'string') {
if (isAdding) {
this.dispatcher$.next(new AddHero(hero));
} else {
this.dispatcher$.next(new EditHero(hero));
}
} else {
console.error(hero);
console.error('type of hero is not allowed to save.');
}
}


this.dispatcher$.next(new AddHero(hero));からstore.tsheroReducer関数にrxjsのストリームが流れます。

そして巡り巡ってComponentにデータが戻ってくるという単方向の流れなんですね。


src/page2/page2.service.ts

// 抜粋

get heroes$() { return this.store.heroes$; }


src/page2/page2.component.ts

// 抜粋

this.service.heroes$.take(1).toPromise().then(heroes => { .....

更新されたStoreのデータはこのようにserviceを通じてcomponentで取得しています。

この手法はAngularチームのVictor Savkinが書いたTackling Stateを参考にしており、僕自身これを理解することでrxjsをきちんと理解することができました。


Page1(Hero List)を眺めてみる

今までPage2ばかり見てきましたが、実はPage1の方がシンプルで理解しやすいです。


src/page1/page1.component.ts

import { Component, OnInit, ChangeDetectorRef, ChangeDetectionStrategy } from '@angular/core';

import { Router } from '@angular/router';

import { Page1Service } from './page1.service';
import { Hero } from '../types';

@Component({
selector: 'my-page1',
template: `
<h3>Hero List</h3>

<div>
<ul class="list-group">
<li *ngFor="let hero of heroes | async" class="list-group-item">
<button class="btn btn-outline-primary btn-sm" (click)="editHero(hero)">Edit</button>
<button class="btn btn-outline-warning btn-sm" (click)="deleteHero(hero)">Delete</button>
<span>id: {{hero.id}} / name: {{hero.name}}</span>
</li>
</ul>
</div>
<div>
<button class="btn btn-outline-primary" (click)="addHero()">Add Hero</button>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class Page1Component implements OnInit {

constructor(
public service: Page1Service,
public router: Router,
public cd: ChangeDetectorRef
) { }

ngOnInit() { }

addHero() {
this.router.navigate(['/page2']);
}

editHero(hero: Hero) {
this.router.navigate(['/page2', hero.id]);
}

deleteHero(hero: Hero) {
this.service.deleteHero(hero);
}

get heroes() { return this.service.heroes$; }
}



src/page1/page1.service.ts

import { Injectable } from '@angular/core';

import { Store, Dispatcher, Action, EditHero, AddHero, DeleteHero } from '../store';
import { Hero } from '../types';

@Injectable()
export class Page1Service {

constructor(
public store: Store,
public dispatcher$: Dispatcher<Action>
) { }

deleteHero(hero: Hero) {
this.dispatcher$.next(new DeleteHero(hero.id));
}

get heroes$() { return this.store.heroes$; }
}


Serviceを通じてComponentがStoreのheroesを受け取り、templateでAsyncPipeを通してる流れがわかるでしょうか。

AsyncPipe内では自動的に.subscribe()されるので、このときにStoreからデータが流れてくることになります。(rxjsは基本的にsubscribeしないとデータが流れない)


Flux的なデータの流れをわかりやすく

まずComponentからStoreへの流れ。(コードを大分簡略化しています)


src/page2/page2.component.ts

onSubmit() {

this.service.save(this.hero);
}


src/page2/page2.service.ts

save(hero: Hero) {

this.dispatcher$.next(new AddHero(hero));
}


src/store/store.ts

function heroReducer(initHeroes: Hero[], dispatcher$: Observable<Action>): Observable<Hero[]> {

return dispatcher$
.scan((heroes: Hero[], action: Action) => {
if (action instanceof AddHero) {
const newHero = action.hero;
heroes = lodash.uniqBy([newHero, ...heroes], 'id');
}
return lodash.orderBy(heroes, ['id'], ['asc']);
}, initHeroes);
}


src/store/store.ts

Observable

.zip(
heroReducer(initState.heroes, dispatcher$),
(heroes) => {
return { heroes } as AppState;
}
)
.subscribe(appState => {
this.stateSubject$.next(appState);
});

このような流れで最終的にStoreのstateSubject$にデータが収まります。


次にStoreからComponent(template)への流れ。


src/store/store.ts

private get returner$() { return this.stateSubject$.asObservable().map(state => lodash.cloneDeep(state)); }

get heroes$() { return this.returner$.map(state => state.heroes); }



src/page1/page1.service.ts

get heroes$() { return this.store.heroes$; }



src/page1/page1.component.ts

get heroes() { return this.service.heroes$; }



src/page1/page1.component.ts

<li *ngFor="let hero of heroes | async">


このような流れでtemplateにデータが流れます。前述しましたがAsyncPipeの中で.subscribe()しています。

Component → Service → Store → Service → Component の流れがしっかり出来ていますね。


最後に

rxjsのSubjectを駆使するとAngular2で扱いやすいStoreが簡単に作れます。

rxjsがよくわからないという方には公式ドキュメントを一読するのをお勧めします。