福山です。前回の「生のJavaScriptでReduxを理解する」に続いて、counterアプリを ngrx を使って作ってみましょう。見た目ももうちょっと素敵にしたいので Material Design も突っ込んで見ましょう。ここでいっきに

  • TypeScript
  • Angular
  • Yarn (npm)
  • Angular-CLI
  • ngrx
  • RxJS

などなど登場人物が増えますが、それぞれについて詳しく解説してもまったく前に進めないので、主に ngrx にしか焦点をあてません。他の登場人物についてはググったらインターネット上にたくさんの良記事があるのでそちらを参考にしてください。

準備

まずは準備をします。

# npmじゃなくてyarn使いたいから --skip-install
ng new counter --skip-install
# 移動
cd counter
# yarnでインストール
yarn
# ついでにMaterial Designも入れちゃいましょう
yarn add @angular/material
yarn add @angular/animations

# 必要なディレクトリを作成
mkdir src/app/components
mkdir src/app/reducers

とりあえずはこの時点で起動するか見てみましょう。

yarn start
# ** NG Live Development Server is running on http://localhost:4200. **
# Hash: 1c9f59c56ece0ff1326b
# Time: 9377ms
# chunk    {0} main.bundle.js, main.bundle.map (main) 4.73 kB {2} [initial] [rendered]
# chunk    {1} styles.bundle.js, styles.bundle.map (styles) 9.7 kB {3} [initial] [rendered]
# chunk    {2} vendor.bundle.js, vendor.bundle.map (vendor) 2.84 MB [initial] [rendered]
# chunk    {3} inline.bundle.js, inline.bundle.map (inline) 0 bytes [entry] [rendered]
# webpack: Compiled successfully.

これで http://localhost:4200 にアクセスして「app works!」の画面が出てきたらまずはOKです。

ngrxインストール

ngrx の中でもコアな部分となる @ngrx/core@ngrx/store をインストールします。

yarn add @ngrx/core
yarn add @ngrx/store

counter reducerを作る

では、 reducer を作りましょう!これは処理としては 前回 とほとんど一緒です。

// src/app/reducers/index.ts
import { Action } from '@ngrx/store';

export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';

export default (state = 0, action: Action) => {
    switch (action.type) {
        case INCREMENT:
            return state + 1;
        case DECREMENT:
            return state - 1;
        default:
            return state;
    }
};

注目したいのは @ngrx/storeAction という interface が用意されていて、それを使えばしっかり TypeScript の型の恩恵を受けれることです。

typescript

storeの初期化

reducer が完成したところで、今度は store を初期化しましょう。

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { StoreModule } from '@ngrx/store'; // Store用のモジュール

import counterReducer from './reducers'; // counter reducer

import { AppComponent } from './app.component';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    StoreModule.provideStore({ counter: counterReducer }), // ここでStoreを初期化している
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

↓こんな感じなものが出来上がってると思ってください。

store

Counter Componentを作る

次に、Counterのコンポーネントを作りましょう。

# 移動
cd src/app/components
# angular-cliでcounter component作成
ng g c counter

# 移動
cd counter
# 不要なファイル削除
rm counter.component.html counter.component.css

コンポーネントの実装は↓のようになります。

import { Component, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
  <p>
    クリック: {{ value }} 回
    <button (click)="onIncrement()">+</button>
    <button (click)="onDecrement()">-</button>
    <button (click)="onIncrementIfOdd()">奇数ならインクリメント</button>
    <button (click)="onIncrementAsync()">非同期インクリメント</button>
  </p>
  `,
})
export class CounterComponent {
  // ↓このInputとOutputがあるおかげで、親から
  // <app-counter [value]="counter$ | async" (increment)="onIncrement()" (decrement)="onDecrement()"></app-counter>
  // こういった感じで値を渡したり、イベントを監視できるようになります
  @Input() value: number;
  @Output() increment = new EventEmitter();
  @Output() decrement = new EventEmitter();

  constructor() { }

  onIncrement() {
    // 興味ある人に「Incrementされたよ!」って教えるだけ
    this.increment.emit();
  }

  onDecrement() {
    // 興味ある人に「Decrementされたよ!」って教えるだけ
    this.decrement.emit();
  }

  onIncrementIfOdd() {
    // 奇数だったら
    if (this.value % 2 !== 0) {
      // 興味ある人に「Incrementされたよ!」って教えるだけ
      this.increment.emit();
    }
  }

  onIncrementAsync = () => {
    // 1秒後に興味ある人に「Incrementされたよ!」って教えるだけ
    setTimeout(() => this.increment.emit(), 1000);
  }
}

ポイントとしては、このコンポーネントは親から与えられた値を出力するだけだし、コンポーネント内でボタンがクリックしても、何かの値を変えるわけではなくて、「ボタンクリックされたよ!」ということしか親に連絡しない(this.increment.emit()の部分)という点です。こういったコンポーネントを Dumb Component 、「おバカなコンポーネント」と呼びます。ロジックも少なく、自分以外の誰かに極力依存しない形になっていて、再利用性が非常に高いコンポーネントになります。

App Componentを編集

Counter Componentが完成したので、その親となるApp Componentから値を渡せるように実装します。

# まずは不要なファイルを削除
rm app.component.html app.component.css

App Componentの実装は↓のようになります。

import { Component } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { INCREMENT, DECREMENT } from 'app/reducers';

// Storeの中のstateの型
interface AppState {
  counter: number;
}

@Component({
  selector: 'app-root',
  template: `
    <app-counter [value]="counter$ | async" (increment)="onIncrement()" (decrement)="onDecrement()"></app-counter>
  `,
})
export class AppComponent {

  // counterのstateのObservable
  counter$: Observable<number>;

  constructor(private store: Store<AppState>) {
    // storeの中で自分が興味のある'counter'の部分を切り取ってくる
    this.counter$ = store.select('counter');
  }

  onIncrement() {
    // INCREMENT Actionを発火させる
    this.store.dispatch({ type: INCREMENT });
  }

  onDecrement() {
    // DECREMENT Actionを発火させる
    this.store.dispatch({ type: DECREMENT });
  }
}

ngrx を触ったことが無い人にとっては見慣れない登場人物やメソッドが目に入ると思います。まず、AppComponentにInjectされている store: Store<AppState> ですが、これは今までの話でも出てきた store と同一人物だと思ってくれてOKです。結局は View に表示したい statestore からとってきたいので DI(Dependency Injection) しているのです。そして、その store の中でも自分が興味のある部分だけをとってきたいので store.select('counter') を呼んでいます。今回は counter という state しか無いのでいまいち便利さは伝わらないでしょうけど、大規模なアプリになるほどこの 切り取り は重要になってきます。さらに、その切り取った部分は RxJSObservable になっている点も注目してください。 Observable と聞いて完全に「???」となっている人は RxJS の基礎についてまずは学ぶことをおすすめします。洋書ですが Lee Campbellさんの Introduction to Rx はかなり良書です。この counter$observe しておけば、 counter の値が変わるたびに新しい値が counter$ に流れてきます。

※ちなみに counter$$ ですが、「この変数はObservableですよ」という意味でつける人が多いです。(強制ではありません)

↓は切り取っているイメージです。
observable

AppComponent ⇔ CounterComponentの連携

では、この observe している値をどうやってCounter Componentに渡すかと言うと、App Componentの template に注目します。

<app-counter [value]="counter$ | async" (increment)="onIncrement()" (decrement)="onDecrement()"></app-counter>

この [value]="counter$ | async" という部分が重要です。 [value] については、単純に CounterComponentInput として定義している部分にセットしているだけですが、 counter$ | async の部分は結構うれしいことをしてくれる重要な部分です。 counter$asyncパイプ に渡しているのですが、この asyncパイプ 、とてもうれしいことに渡した Observable に自動で subscribe してくれるだけでなく、Componentが死ぬときにはちゃんと unsubscribe もしてくれます。なので不要な subscribe が残ってメモリリークすることも無いんです!コードもその分綺麗になりますしね。なので、 CounterComponent@Input() value: number; と定義していたように、 value はただの number型 であり、 Observable を意識する必要がないのです。

それでは、最後に incrementdecrement を連携します。 CounterComponent は 「Increment(またはDecrement)しましたよ!」、と教えてくれるだけなので、そのタイミングで親である AppComponent は必要な処理を実行します。それぞれのタイミングでやりたいことは store にしかるべきアクションを dispatch したいだけなので、 onIncrementonDecrement の定義をする感じです。

  onIncrement() {
    // INCREMENT Actionを発火させる
    this.store.dispatch({ type: INCREMENT });
  }

  onDecrement() {
    // DECREMENT Actionを発火させる
    this.store.dispatch({ type: DECREMENT });
  }

store.dispatch(Action) することで、 reducer までその action が渡っていくようになっています。全体の流れは↓のようになります。

overall

yarn start

を実行して http://localhost:4200 にアクセスして、ちゃんと機能しているか見てみましょう!

Material Designを適用

せっかくなのでMaterial Designを使って画面の見た目を整えてみましょう。

準備

公式ドキュメント に沿ってAppModuleにアニメーション関連の追加Moduleを加えます。あと、今回は MdCardModuleMdButtonModule を使おうと思いますのでそれも合わせて追加しておきます。

// src/app/app.module.ts
import {BrowserAnimationsModule} from '@angular/platform-browser/animations';

import { MdCardModule, MdButtonModule } from '@angular/material';

@NgModule({
  ...
  imports: [
  ...
    BrowserAnimationsModule,
    MdCardModule,
    MdButtonModule,
  ...
  ],
  ...
})
export class AppModule { }

CounterComponent更新

では CounterComponent にMaterial Designの部品を適用しましょう。 template の部分を↓のようにします。

// src/app/components/counter/counter.component.ts
...
  template: `
  <md-card class="example-card">
  <md-card-content>
    <p>
    クリック: {{ value }} 回
    </p>
  </md-card-content>
  <md-card-actions>
    <button md-mini-fab (click)="onIncrement()">+</button>
    <button md-mini-fab (click)="onDecrement()">-</button>
    <button md-raised-button (click)="onIncrementIfOdd()">奇数ならインクリメント</button>
    <button md-raised-button (click)="onIncrementAsync()">非同期インクリメント</button>
  </md-card-actions>
</md-card>
  `,
...

完成!

これで ngrx を使ったCounterアプリの完成です!

counter

こちら にソースを公開していますので、全容をチェックしたい場合は御覧ください。

© 2018. SuperSoftware Co., Ltd. All Rights Reserved.