Angular4 お勉強メモ(Angular4 + Redux)

Angular4 + Redux

仕事で Angular を使うことになりそうなので、ちょっとずつ勉強している。

このたび、興味深いチュートリアルを見つけたので適当に機械翻訳した。 言ってみればコピペ記事である。 ここには何ら新しい情報はない上、日本語訳の内容にも責任は負えない。

出典:

github.com

Installation

まず Angular-CLI を使用して、シンプルな Angular アプリケーションを生成しよう。

# Install Angular CLI
npm install -g @angular/cli

# Use it to spin up a new app.
ng new angular-redux-quickstart
cd angular-redux-quickstart
ng serve

ブラウザを開いて http://localhost:4200 にアクセスしてみるとよい。アプリが動作していることを確認できるはずだ。

次に Redux を新しいアプリケーションにインストールしよう。

npm install redux @angular-redux/store

これで Redux と @angular-redux/store (Angular の Redux バインディング)がインストールされる。

Importing @angular-redux/store into your App.

まず最初に、NgReduxModule をアプリケーションにインポートする。 src/app/app.module.ts を開き、次の行を追加しよう:

src/app/app.module.ts:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

import { NgReduxModule, NgRedux } from '@angular-redux/store'; // <- New

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

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    NgReduxModule, // <- New
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

これにより、@ angular-redux/store のサービスを私たちのアプリに注入injectできるようになる。

A Concrete Example

カウンタアプリを作ろう。 値をインクリメントするためのボタンとデクリメントするボタンの2つのボタンだけがあるシンプルなやつだ。

src/app/app.component.html を開き、次のコードを追加する:

<div>
  Count: {{ count }}
  <button (click)="increment()">+</button>
  <button (click)="decrement()">-</button>
</div>

次に 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 = 'app works!';
  count: number; // <- New

  increment() {} // <- New
  decrement() {} // <- New
}

Modeling the App

今のところ、カウンターの UI は何もしない。 どのような状態やロジックにも関連づいていないからだ。 ちょっと戻って、カウンタを作るために必要なものについて考えてみよう。

Application State

まず、カウンタのコンポーネントはある状態 ── つまりカウンタの現在の値 ── を保持する必要がある。 Redux では、UI コンポーネント自体にアプリケーションの状態を保持せず、代わりに Store に保管する。そうすれば、簡単に見つけることができ、Redux アーキテクチャの不変性の保証によって保護される。

したがって、状態を表現する型は次のように定義できる。

interface IAppState {
  count: number;
}

Store 用のインターフェースを定義することは、この単純な例では大げさかもしれないが、より大きなアプリでは、 combineReducers を使って Store の状態を管理可能な部分に分割する。強い型はすべてを整然と保つのに役立つ。

Actions

アプリケーションに応答させる2つのイベント ── インクリメントボタンとデクリメントボタンのクリック ── が必要だ。これらを Redux の action としてモデル化する。

与えられた時点で、カウントの現在の値は、トリガされた INCREMENT アクションと DECREMENT アクションのシーケンスに対する reduce としてモデル化される。

すなわち、私たちのアプリケーションは概念的に次のように考えることができる:

// Pseudocode
const nextValueOfCount = streamOfActions.reduce(
  (currentValueOfCount, action) => {
    switch(action.type) {
      case 'INCREMENT': return state + 1;
      case 'DECREMENT': return state - 1;
    }

    return state;
  },
  { count: 0 });

私たちは、このシンプルで可変的な2アクションのアプリケーションのために、rootReducer が必要とするものの本質を表現できたことになる。

コードベースでこれを正式化しよう。2つの新しいファイルを作成する。

src/app/app.actions.ts:

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

@Injectable()
export class CounterActions {
  static INCREMENT = 'INCREMENT';
  static DECREMENT = 'DECREMENT';

  increment(): Action {
    return { type: CounterActions.INCREMENT };
  }

  decrement(): Action {
    return { type: CounterActions.DECREMENT };
  }
}

src/store.ts:

import { Action } from 'redux';
import { CounterActions } from './app/app.actions';

export interface IAppState {
  count: number;
}

export const INITIAL_STATE: IAppState = {
  count: 0,
};

export function rootReducer(lastState: IAppState, action: Action): IAppState {
  switch(action.type) {
    case CounterActions.INCREMENT: return { count: lastState.count + 1 };
    case CounterActions.DECREMENT: return { count: lastState.count - 1 };
  }

  // We don't care about any other actions right now.
  return lastState;
}
Hooking it up to Angular

Redux では、アプリケーション状態のすべてではないにしても、ほとんどが「Store」と呼ばれるものに集められる。これは、アプリケーションで使用されている現在のデータを含むクライアントサイドの DB と考えることができる。つまり、UI はいつでもストアの現在の状態の純粋な関数である。

上記の成分を使って Redux Store を作成し、 NgRedux.configureStore を使ってAngularに接続しよう。

src/app/app.module.ts:

// ... imports as above

import { rootReducer, IAppState, INITIAL_STATE } from '../store'; // < New
import { CounterActions } from './app.actions'; // <- New

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    NgReduxModule,
  ],
  providers: [CounterActions], // <- New
  bootstrap: [AppComponent]
})
export class AppModule {
  constructor(ngRedux: NgRedux<IAppState>) {
    // Tell @angular-redux/store about our rootReducer and our initial state.
    // It will use this to create a redux store for us and wire up all the
    // events.
    ngRedux.configureStore(
      rootReducer,
      INITIAL_STATE);
  }
}

コードベースにすでに Angular 以外のコードで Redux Store が設定されている場合は、 ngRedux.configureStore の代わりに ngRedux.provideStore を使用してNgReduxに登録することができる。

What's a Reducer Anyway?

Redux の Store は、JavaScript オブジェクトに過ぎない。しかし、それは不変 ──つまり、通常のようにフィールドを設定することが不可能なインターフェイスにラップされる。

アプリケーションの状態に対するすべての変更は、1つまたは複数の「還元機能」を使用して行われる。

実際には、アプリケーションの動作をイベントのコレクション(またはアクション)として、初期状態と組み合わせてモデリングしている。

アクションは通常、ユーザーが行ったことを表す。ただし、外部ソース(たとえば、ネットワークからのデータなど)からアプリケーションに影響を及ぼすイベントを表すこともできる

新しいアクションが来るたびに、 rootReducer はアプリケーションの最後の状態をとり、アクションによって提供される情報を考慮し、ストアの次の状態を計算します。これが完了すると、新しい状態が UI にブロードキャストされ、新しい状態から再計算される。

あなたが Array.prototype.reduce に慣れているなら、基本的にアプリケーションは次のように概念的に見える:

// Pseudocode
const finalAppState:IAppState = actionsOverTime.reduce(
  rootReducer,
  INITIAL_STATE);

または、おそらくもっと便利です:

// Pseudocode
const nextState = rootReducer(lastState, mostRecentAction);
UI.render(nextState);

Generating Actions

私たちは Store を定義し、それを引きつけました。しかし、カウンターのボタンはまだ何も機能していない。今、それをフックしてみよう。

必要なことは、それらのボタンを Redux Store に dispatch アクションとして送出することだ。 src / app / app.actions.tsINCREMENTDECREMENT アクションを定義したことを思い出してほしい。ユーザがボタンをクリックしたときにそれらが送出されるようにしよう:

src/app/app.component.ts:

// Imports as before.

import { NgRedux } from '@angular-redux/store'; // <- New
import { CounterActions } from './app.actions'; // <- New
import {IAppState} from "../store"; // <- New


@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app works!';
  count: number;

  constructor(                           // <- New
    private ngRedux: NgRedux<IAppState>, // <- New
    private actions: CounterActions) {}  // <- New

  increment() {
    this.ngRedux.dispatch(this.actions.increment()); // <- New
  }

  decrement() {
    this.ngRedux.dispatch(this.actions.decrement()); // <- New
  }
}

Displaying State

最後に行うべきことは、カウンターコンポーネントcount の現在の値を伝えることだ。

我々はこれを、NgRedux Store から「Observable」として選択selectすることによって行う。Observable は、時間とともに変化するものの最新の価値を得ることができる。 src/app/app.component.ts に戻り、コンポーネントcount プロパティを select しよう:

// Imports as before

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

// Decorator as before
export class AppComponent implements OnDestroy { // <- New
  title = 'app works!';
  count: number;
  subscription; // <- New;

  constructor(
    private ngRedux: NgRedux<IAppState>,
    private actions: CounterActions) {
      this.subscription = ngRedux.select<number>('count') // <- New
        .subscribe(newCount => this.count = newCount);    // <- New
  }

  ngOnDestroy() {                    // <- New
    this.subscription.unsubscribe(); // <- New
  }                                  // <- New

  // Rest of class as before.
}

ここでは、アクションが発生するたびに count の新しい値を受け取る選択された observable をリスンしているlistening。また、コンポーネントがDOMからアンマウントされたときにそれらのイベントに対して「un-listen」できるように、 ngOnDestroy を追加した。

この時点であなたのカウンタは機能するはずだ。ボタンをクリックし、表示された番号の更新を確認してみよう。

前回の続き。

But Wait... There's More!

これは NgRedux を使用する本質だ。しかし、Observables を Angular で使用する利点の1つは、Angular には、async パイプ というコンストラクトを使用してレンダリングするファーストクラスの最適化サポートがあることだ。

選択selectした ovservable を手動で購読subscribe購読解除unsubscribe する代わりに、 | async をテンプレートに追加した。これにより、Angular はサブスクリプションを管理し、さらに Angular の変更検出レベルでいくつかの最適化を行うことができる。

// Imports as before.

import { Observable } from 'rxjs/Observable';

// Decorator as before
export class AppComponent {
  title = 'app works!';
  readonly count$: Observable<number>; // <- New

  constructor(
    private ngRedux: NgRedux
    private actions: CounterActions) {
      this.count$ = ngRedux.select<number>('count'); // <- New
  }

  // Delete ngOnDestroy: it's no longer needed.
  // Rest of class as before.
}

ここでは、プッシュされる値( count: number)ではなく、observable自身(count$: Observable<number>)への参照を保存している。最後の $ は、あなたのコードを読んでいる人に、この値が静的な値ではなく、あるもののObservableであることを知らせるための規約だ。

これで | async を使用し、Angularは count$ を購読して、値が入ってくるとその値を展開する:

app/app.component.html:

<!-- As before -->

  Count: {{ count$ | async }} <!-- New -->

<!-- As before -->

But Wait... There's Even More!

ngRedux.select は Observables を保存するための自由なアクセスを得るための強力な方法です。より複雑な UI に必要なものにストアデータを送信する RxJS 演算子を使用して、多くの変換を行うこともできる。しかし、このシナリオではストアの現在の値を表示しているだけに過ぎない。

このような単純な場合、 @angular-redux/store@select デコレータの形式で選択の省略形を公開します。 @select を使うと、コンポーネント全体を以下のように煮詰めることができる:

src/app/app.component.ts に対して以下の変更を行う。

import { Component } from '@angular/core';
import { NgRedux, select } from '@angular-redux/store'; // <- Changed
import { CounterActions } from './app.actions';
import { IAppState } from '../store';
import { Observable } from 'rxjs/Observable';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app works!';
  @select() readonly count$: Observable<number>; // <- Changed

  constructor(
    private actions: CounterActions,
    private ngRedux: NgRedux<IAppState>) {} // <- Changed

  increment() {
    this.ngRedux.dispatch(this.actions.increment());
  }

  decrement() {
    this.ngRedux.dispatch(this.actions.decrement());
  }
}

引数なしで呼び出された場合、 @select はそれが装飾するプロパティをstoreプロパティのObservableで、問題のメンバ変数と同じ名前に置き換える。

名前またはネストされたストアパスを手動で指定することもできる。

class MyComponent {
  @select('count') readonly differentVarNameInComponent$: Observable<number>
  @select(['deeply', 'nested', 'store', 'property']) readonly deeplyNested$: Observable<any>;
}

実際には、 @selectngRedux.select でできることはかなりたくさんある。API docs を見てね。

Unit Testing Selections

my-component.spec.ts:

import { NgReduxTestingModule, MockNgRedux } from '@angular-redux/store/testing';
import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/toArray';

import { MyComponent } from './my-component';
import { IAppState } from '../store';
import { CounterActions } from './app.actions';

describe('MyComponent', () => {
  beforeEach(() => {
    // Configure your testBed to use NgReduxTestingModule; this test the DI
    // in the test environment to use mock versions of NgRedux and DevToolsExtension.
    TestBed.configureTestingModule({
      declarations: [MyComponent],
      imports: [NgReduxTestingModule],
      providers: [CounterActions]
    }).compileComponents();

    // Reset the mock to start from a clean slate in each unit test.
    MockNgRedux.reset();
  });

  it('Selects the current count value from Redux', done => {
    // Create an instance of MyComponent using Angular's normal unit test features.
    const fixture = TestBed.createComponent(MyComponent);
    const componentUnderTest = fixture.debugElement.componentInstance;

    // Get a stub we can use to drive the `@select('count')` observable used by
    // MyComponent (above). This stub will be supplied to any relevant `.select`
    // or `@select` calls used by the component under test by MockNgRedux.
    const countStub: Subject<number> = MockNgRedux.getSelectorStub<IAppState, number>('count');

    // Determine a sequence of values we'd like to test the Redux store with.
    const expectedValues = [ 1, 2, 3, 4, 3, 4, 3, 2, 1];

    // Drive those values through our stub.
    expectedValues.forEach(value => countStub.next(value));

    // toArray only deals with completed streams
    countStub.complete();

    // Make sure MyComponent's selected count$ variable receives these values.
    componentUnderTest.count$
      .toArray()
      .subscribe(
        actualValues => expect(actualValues).toEqual(expectedValues),
        null,
        done);
  });

Unit Testing Action Dispatches

it('dispatches INCREMENT when ...', () => {
  const spy = spyOn(MockNgRedux.mockInstance, 'dispatch');

  // Run your test code ...

  // Perform your expectations
  expect(spy).toHaveBeenCalledWith({type: CounterActions.INCREMENT });
  // ... etc.
});