Schematics で始める @ngrx/store

前回までのあらすじ

こんにちは、たーせるです。 夏休みを利用してAngular と仲直り中。

今日は Angular で Redux 的なことをやるにはどうするんだっけ、という話。 手順やお作法が多すぎてよく忘れる。

この記事の対象読者は主に自分であり、前提となる説明やら手順やらもろもろ省いている。よく分かっていないところは逃げている。

そのため、何かの間違いでこの記事に辿り着いてしまった自分以外の誰かにとっては何一つとして得るものはない。

前置き

いろいろ雑だが、とりあえず関係ありそうなキーワードについて復習する。

Redux とは

シングルページアプリケーションの状態を管理するためのライブラリである。

Action だとか Store だとか Reducer だとか、意味不明な登場人物がたくさん出てくる。

偏見に満ち溢れた補足

時は世紀末、 React という UI ライブラリでシングルページアプリケーションを開発する際に、どうしても状態管理のコードがぐちゃーになる問題が深刻化していた。

この問題をエレガントに解決したのが Flux というデザインパターンであり、Redux はその Flux を実装したライブラリの中でも最もポピュラーと言われている。

ちなみに今回の題材である Angular は、ライブラリではなくフレームワークなので、無理やり Redux を適用しなくても React よりは状態管理の基盤が整っている(気がする)。

Schematics とは

Angular でソースコードの雛形を自動生成するツールである。

Redux は、登場人物の役割がいちいち回りくどい上、プログラミングの作法もかなり面倒くさい。 正直、毎回リファレンスを見ないと一から書けない。

しかし、Schematics を導入すれば、この問題が多少マシになる。

コマンドを一発叩くだけでそれっぽいソースファイルが出来上がるのだ。基本的にはその雛形のソースの断片を見ながら「あー、そういえばこんな書き方だったなぁ」と感慨に浸りつつ、継ぎ足したり書き換えたりするだけなので、お手軽度は上がる。

自分用チュートリアル

環境

  • node 8.11.3
  • npm 5.6.0
  • @angular/cli 6.1.2

プロジェクトの作成

コマンドプロンプトを起動して、以下のコマンドを入力する(@angular/cli がインストールされていないと「そんなコマンド無いよ」と叱られるので注意)。

$ng new NgRxSample

f:id:tercel_s:20180810170758p:plain

f:id:tercel_s:20180810170932p:plain

必要なライブラリのインストール

引き続き、コマンドプロンプト上での作業。 プロジェクトのフォルダに cd して、以下のコマンドを入力する。

$cd NgRxSample
$npm i @ngrx/core @ngrx/store @ngrx/store-devtools

f:id:tercel_s:20180810171424p:plain

f:id:tercel_s:20180810171514p:plain

@npm i  @ngrx/schematics -D

f:id:tercel_s:20180810171918p:plain

f:id:tercel_s:20180810171938p:plain

はじめの一歩

@ngrx/schematics を利用する前に以下のコマンドを入力する。

$ng config cli.defaultCollection @ngrx/schematics

f:id:tercel_s:20180810172435p:plain

このコマンドを実行すると、angular.json に以下の設定が追加される。

"cli": {
  "defaultCollection": "@ngrx/schematics"
}

f:id:tercel_s:20180810172758p:plain

この手順を踏むことで、Schematics を利用する際にいちいち @ngrx/schamatics を指定する必要がなくなる。

Store の生成

次に、Store を生成する。 ここで生成されるのは、すべての Reducer を束ねる ActionReducersMap オブジェクト、および State インタフェースである。

$ng g @ngrx/schematics:store State --root --module app.module.ts

ng g State --root --module app.module.ts でも可らしいが、試していない)

f:id:tercel_s:20180810173311p:plain

f:id:tercel_s:20180810173353p:plain

このコマンドによって、src/app/reducers/index.ts ファイルが自動生成される。これこそが Schematics の威力である。

src/app/reducers/index.ts

import {
  ActionReducer,
  ActionReducerMap,
  createFeatureSelector,
  createSelector,
  MetaReducer
} from '@ngrx/store';
import { environment } from '../../environments/environment';

export interface State {
}

export const reducers: ActionReducerMap<State> = {
};

export const metaReducers: MetaReducer<State>[] = !environment.production ? [] : [];

こんなものを毎回手で書いていたら発狂してしまう。

ここまでは、どのプロジェクトでもほぼ共通の手順となる。たぶん。

Action のコード生成と実装

今回は、非常に簡素なカウンタを作成したい。よく Web や雑誌のチュートリアルでよく見かけるおなじみのアレである。 何番煎じだろうか。

f:id:tercel_s:20180810214422p:plain

どこから手を付けたらよいだろうか。

とりあえず、カウンタ値を1増やす imcrement、カウンタ値を1減らす decrement, カウンタ値を0クリアする reset という3つの Type を持った Counter Action から作り始めよう。

コマンドの構文は、ng g action ActionName [options] である。options--group を指定すると、actions ディレクトリの下にファイルを作ってくれる(省略した場合はその場にファイルが生成される)。

$ng g action Counter --group

f:id:tercel_s:20180810175418p:plain

f:id:tercel_s:20180810175500p:plain

生成されたファイルの中身を以下のように改造する(予め一つ、参考の LoadCounters なる雛形サンプルが用意されているので、それを徹底的にパクるTTPだけである)。

src/app/actions/counter.action.ts

import { Action } from '@ngrx/store';

export enum CounterActionTypes {
  Increment = '[Counter] Increment Counter',
  Decrement = '[Counter] Decrement Counter',
  Reset     = '[Counter] Reset Counter'
}

export class IncrementCounter implements Action {
  readonly type = CounterActionTypes.Increment;
  constructor() { };
}

export class DecrementCounter implements Action {
  readonly type = CounterActionTypes.Decrement;
  constructor() { };
}

export class ResetCounter implements Action {
  readonly type = CounterActionTypes.Reset;
  constructor() { };
}

export type CounterActions = IncrementCounter
  | DecrementCounter
  | ResetCounter;

Reducer のコード生成と実装

次に、Reducer のコード生成を行う。

コマンドの構文は ng generate reducer ReducerName [options] である。

$ng g reducer Counter --group --reducer reducers/index.ts

--group オプションをつけると、reducers フォルダの配下に Reducer が生成される。

また、--reducer reducers/index.ts を付けると、生成した Reducer が Store (src/app/reducers/reducers/index.ts) に自動的に登録される。

f:id:tercel_s:20180810183958p:plain

f:id:tercel_s:20180810184005p:plain

続いて、生成した src/app/reducers/counter.reducer.ts を改造する。 Reducer は、引き渡されてきた action と現在の state に基づき、次状態を返す。

state 変数を直接更新することは道徳的に許されず、常に新たなオブジェクトを作って返す必要がある。

src/app/reducers/counter.reducer.ts

import { Action } from '@ngrx/store';
import { CounterActionTypes } from '../actions/counter.actions';

export interface State {
  counter: number;
}

export const initialState: State = {
  counter: 0
};

export function reducer(state = initialState, action: Action): State {
  switch (action.type) {
    case CounterActionTypes.Increment:
      return Object.assign({}, { ...state, counter: state.counter + 1 });
    case CounterActionTypes.Decrement:
      return Object.assign({}, { ...state, counter: state.counter - 1 });
    case CounterActionTypes.Reset:
      return Object.assign({}, { ...state, counter: 0 });
    default:
      return state;
  }
}

さらに、src/app/reducers/index.ts を修正する。

とはいえ、Schematics によってほとんどのコードが生成済みであり、追加するのは getCounterFeatureState および getCounter の宣言くらいである。これらは、Angular の Component から state の中身にアクセスできるようにするための getter である。

src/app/reducers/index.ts

import {
  ActionReducer,
  ActionReducerMap,
  createFeatureSelector,
  createSelector,
  MetaReducer
} from '@ngrx/store';
import { environment } from '../../environments/environment';
import * as fromCounter from './counter.reducer';

export interface State {
  counter: fromCounter.State;
}

export const reducers: ActionReducerMap<State> = {
  counter: fromCounter.reducer,
};

export const getCounterFeatureState = createFeatureSelector<State, fromCounter.State>('counter');
export const getCounter = createSelector(getCounterFeatureState, state => state.counter);

export const metaReducers: MetaReducer<State>[] = !environment.production ? [] : [];

createFeatureSelectorcreateSelector はぶっちゃけ正確なところをよく分かっていない。

子 State にアクセスできるようにするためのものだということくらいは解る。引数に指定している文字列 ('counter')は何でもよいというわけではなく、Store で State インタフェースに存在するメンバ変数名と一致していないと実行時に怒られる(一致させるのは、白い下線の箇所)。

f:id:tercel_s:20180811140308p:plain

画面と連携させる

最後に、画面にボタンを追加して IncrementAction, DecrementAction, ResetAction の各 Action を発行してみる。

  • Store を DI するため、コンストラクタの引数に Store<State> を指定する。
  • Store の状態にアクセスするには、this.store.select() を利用する。戻り値は Observable<T> 型になるので、テンプレート側では async パイプが必要である。
  • Action を発行するには this.store.dispatch() を利用する。引数には Action クラスを作って渡す。

src/app/app.component.ts

import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';

import * as AppStore from './reducers';
import * as CounterActions from './actions/counter.actions';

@Component({
  selector: 'app-root',
  template: `
    <div>
      <button (click)="increment()">+</button>
      <button (click)="decrement()">-</button>
      <button (click)="reset()">!</button>
    </div>
    <div>Counter: {{counter$ | async}}</div>
  `
})
export class AppComponent {
  counter$: Observable<number>;

  constructor(private store: Store<AppStore.State>) {
    this.counter$ = this.store.select(AppStore.getCounter)
  }

  increment() {
    this.store.dispatch(new CounterActions.IncrementCounter());
  }

  decrement() {
    this.store.dispatch(new CounterActions.DecrementCounter());
  }

  reset() {
    this.store.dispatch(new CounterActions.ResetCounter());
  }
}

余談だが、メンバ変数の counter$ の末尾の「$」はRx における一種の慣例によるものである。jQuery とは関係ない。

なんかここまで書いて力尽きた。

帰省中に @ngrx/effect を試したい。