【 Ionic + @ngrx 入門】 Ionic + @ngrx/store でカウンターアプリを作ってみる
AngularのReactiveライブラリである@ngrxに手を出したので、まずはIonic + @ngrx/storeでカウンターアプリを作ってみました。ある程度Fluxアーキテクチャ周りの知識が必要になります。@ngrx/storeのバージョンは4.0.0です。
目次
@ngrx/storeについてざっくりと
@ngrx/storeはGithubに
RxJS powered state management for Angular applications, inspired by Redux
とあるように、Reduxでやるような状態管理をAngularが使用しているRxJSの力を使って実現しましょうというものです。ということでRedux同様、Store, Reducer, Actionでもって状態管理をします。ReduxではMiddleware等で実現していた非同期処理は、@ngrxでは@ngrx/effectsで処理されます。
Ionicで新規プロジェクトの作成
まずはIonicで新規プロジェクトを作成します。Ionicをすでにインストールしている場合はionic, cordovaのインストールは必要ありません。npmからyarnに乗り換えた人は適宜読み替えてください。
# Ionic, Cordovaのインストール
npm install -g ionic cordova
# Ionicのblankテンプレートを使ってcounterという名前のアプリを作成
ionic start counter blank
# counterディレクトリが作成されるのでそこに移動
cd counter
# @ngrx/storeをインストール
npm install @ngrx/store
# ローカルサーバを立ち上げる
ionic serve
ちなみにインストール先でionic info
して得られる環境情報は以下の通りです。
cli packages:
@ionic/cli-plugin-ionic-angular : 1.4.0 (...)
@ionic/cli-utils : 1.6.0 (...)
ionic (Ionic CLI) : 3.6.0 (...)
local packages:
@ionic/app-scripts : 2.1.3
Ionic Framework : ionic-angular 3.6.0
System:
Node : v8.2.1
OS : macOS Sierra
Xcode : Xcode 8.3.3 Build version 8E3004b
ios-deploy : 1.9.1
ios-sim : 6.0.0
npm : 5.3.0
Stateがプリミティブ型の例
@ngrxのドキュメントページに載っている、stateがプリミティブ型であるような一番簡単な例から始めます。
State/Action/Reducerの定義
簡単なカウンターアプリなのでディレクトリ構成をどうするかは特に考えず、以下の内容のcounter.tsファイルをsrc/appに追加します。
// src/app/counter.ts
import { Action } from '@ngrx/store';
/**
* actionのタイプを定義
*/
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const RESET = 'RESET';
/**
* stateの初期状態を定義
*/
const initialState: number = 0;
/**
* reducerの定義
*/
export function reducer(state: number = initialState, action: Action): number {
switch (action.type) {
case INCREMENT:
return state + 1;
case DECREMENT:
return state - 1;
case RESET:
return 0;
default:
return state;
}
}
reducer関数はReduxと同様に初期状態とactionを引数にとります。関数内ではaction.type
に応じてswitch
で処理を分けます。ここではstate
を1だけ増やす・1だけ減らす・0にリセットするの3つだけです。それ以外はstate
をそのまま返します。カウンターアプリのように簡単な場合だとpayloadの出番はありませんが、実用的なアプリになるとstate.payload
を新しい状態の一部として返します。
AppModuleにStoreを登録
作成したreducerをsrc/app/app.module.tsで読み込み、StoreModule.forRoot
で登録します。
// src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
import { StoreModule } from '@ngrx/store';
import { reducer } from './counter';
import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
@NgModule({
declarations: [
MyApp,
HomePage
],
imports: [
BrowserModule,
IonicModule.forRoot(MyApp),
StoreModule.forRoot({counter: reducer})
],
bootstrap: [IonicApp],
entryComponents: [
MyApp,
HomePage
],
providers: [
StatusBar,
SplashScreen,
{provide: ErrorHandler, useClass: IonicErrorHandler}
]
})
export class AppModule {}
ここではStoreModule.forRoot({counter: reducer})
とすることで、stateの中で、読み込んだreducerで管理できる部分にcounter
という名前をつけています。コンポーネントからはstore.select('counter')
でこのstate(今回はプリミティブな数字)を取得することができるようになります。
Homeコンポーネントに実装
コンポーネントからstoreを利用する準備ができたので、Homeコンポーネントに諸々の処理を書いていきます。
// src/pages/home/home.ts
import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
// counter.tsからactionを読み込む
import { INCREMENT, DECREMENT, RESET } from '../../app/counter';
/**
* stateのためのインターフェースを定義
* StoreModule.forRootで渡したオブジェクトのプロパティ名と同じ
*/
interface AppState {
counter: number;
}
@Component({
selector: 'page-home',
templateUrl: 'home.html'
})
export class HomePage {
// カウンターを保持するための内部変数
count$: Observable<number>;
constructor(public navCtrl: NavController, private store: Store<AppState>) {
// store.selectでstoreに保存されているcounterを取得
this.count$ = store.select('counter');
}
increment() {
this.store.dispatch({ type: INCREMENT });
}
decrement() {
this.store.dispatch({ type: DECREMENT });
}
reset() {
this.store.dispatch({ type: RESET });
}
}
home.htmlにはボタンとcount$
を表示する要素を追加します。
<!-- src/pages/home/home.html -->
<ion-header>
<ion-navbar>
<ion-title>
Ionic Blank
</ion-title>
</ion-navbar>
</ion-header>
<ion-content padding>
<div class="wrapper">
<span>{{ count$ | async }}</span>
<div>
<button ion-button (click)="increment()">↑</button>
<button ion-button (click)="reset()">Reset Counter</button>
<button ion-button (click)="decrement()">↓</button>
</div>
</div>
</ion-content>
パイプを通してasync
に渡すことで、observableな非同期オブジェクトをそのままテンプレートに表示させています。
スタイルは適当につけておきます。
// src/pages/home/home.scss
page-home {
.wrapper {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
& > span {
display: block;
font-size: 50px;
}
}
}
ローカルサーバで確認
ionic serve
でローカルサーバを立ち上げてhttp://localhost:8100/にアクセスし、こんな感じでアプリが表示されれば成功です。
複数のreducerに対応させる
ここまではreducerが1つだけのシンプルなものでしたが、実際のアプリではreducerが複数存在します。ということでそれに対応するためにコードを少し修正します。
reducers.tsの作成
まずは以下の内容のsrc/app/reducers.tsを作成します。
// src/app/reducers.ts
import {
ActionReducerMap,
createSelector,
createFeatureSelector,
} from '@ngrx/store';
import * as fromCounter from './counter';
export interface AppState {
counter: number;
}
export const reducers: ActionReducerMap<AppState> = {
counter: fromCounter.reducer
};
先ほどsrc/pages/home/home.tsで定義していたAppStateをこちらに移しています。また、@ngrx/storeのActionReducerMap
としてreducers
を定義しています。ここにcounter.tsから読み込んだreducerを登録します。
2つ以上のreducerを登録するときには
// ...
import * as fromSomeOther from './some-other';
// ...
export const reducers: ActionReducerMap<AppState> = {
counter: fromCounter.reducer,
someOther: fromSomeOther.reducer
};
のようにreducersの中に追加していきます。
app.module.tsでreducers.tsの読み込み
先ほどStoreModule.forRoot({counter: reducer})
としてreducerを登録していましたが、今度はreducers.tsを読み込んでreducers
を丸ごとStoreModule.forRoot
に渡します。
// src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers';
import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
@NgModule({
declarations: [
MyApp,
HomePage
],
imports: [
BrowserModule,
IonicModule.forRoot(MyApp),
StoreModule.forRoot(reducers),
],
bootstrap: [IonicApp],
entryComponents: [
MyApp,
HomePage
],
providers: [
StatusBar,
SplashScreen,
{provide: ErrorHandler, useClass: IonicErrorHandler}
]
})
export class AppModule {}
home.tsの修正
あとはhome.tsの修正です。
// src/pages/home/home.ts
import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import { INCREMENT, DECREMENT, RESET } from '../../app/counter';
import { AppState } from '../../app/reducers';
@Component({
selector: 'page-home',
templateUrl: 'home.html'
})
export class HomePage {
count$: Observable<number>;
constructor(public navCtrl: NavController, private store: Store<AppState>) {
this.count$ = store.select('counter');
}
increment() {
this.store.dispatch({ type: INCREMENT });
}
decrement() {
this.store.dispatch({ type: DECREMENT });
}
reset() {
this.store.dispatch({ type: RESET });
}
}
変更点はhome.ts内で定義していたinterface AppState ...
を削除して、代わりにreducers.tsからAppStateを読み込んでいます。これで先ほどと同様にカウンターアプリが動作することが確認できます。
Meta Reducersを追加してstateとactionのログをとってみる
複数のreducerに対応させたときに、アプリ内のreducerをreducersとして一つにまとめました。ここではStoreModule.forRoot
にmeta reducersを追加して、stateとactionのログをとってみます。手を加えるのはapp.module.tsです。
// src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
import { StoreModule } from '@ngrx/store';
import { reducers } from './reducers';
import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
function debug(reducer) {
return function(state, action) {
const nextState = reducer(state, action);
console.group(action.type);
console.log(`%c prev state`, `color: #9E9E9E; font-weight: bold`, state);
console.log(`%c action`, `color: #03A9F4; font-weight: bold`, action);
console.log(`%c next state`, `color: #4CAF50; font-weight: bold`, nextState);
console.groupEnd();
return nextState;
}
}
const metaReducers = [debug];
@NgModule({
declarations: [
MyApp,
HomePage
],
imports: [
BrowserModule,
IonicModule.forRoot(MyApp),
StoreModule.forRoot(reducers, { metaReducers })
],
bootstrap: [IonicApp],
entryComponents: [
MyApp,
HomePage
],
providers: [
StatusBar,
SplashScreen,
{provide: ErrorHandler, useClass: IonicErrorHandler}
]
})
export class AppModule {}
新たにdebug
関数を定義してmetaReducers
配列に渡し、StoreModule.forRootのオプションで登録しています。
metaReducerはreducerを引数にとり、新たなreducerを返す関数として定義します。reducerを返すため、return function(...)
の引数は当然(state, action)
です。このstate
がreducerが実行される前のstateです。ここではreducerを実行後のstateをnextState
とし、
- reducerが実行される前のstate
- ディスパッチされたaction
- reducerが実行された後のstate
をコンソールに表示しています。reducerはstateを返すため、最後にはreturn nextState
で新たなstateを返しています。
@ngrxでは自分でわざわざロガーを登録しなくても、@ngrx/store-devtoolsという開発者用のデバッグツールが用意されています。使い方は簡単で、npm install @ngrx/store-devtools
でインストールした後、
// src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { ErrorHandler, NgModule } from '@angular/core';
import { IonicApp, IonicErrorHandler, IonicModule } from 'ionic-angular';
import { SplashScreen } from '@ionic-native/splash-screen';
import { StatusBar } from '@ionic-native/status-bar';
import { StoreModule } from '@ngrx/store';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { reducers } from './reducers';
import { MyApp } from './app.component';
import { HomePage } from '../pages/home/home';
@NgModule({
declarations: [
MyApp,
HomePage
],
imports: [
BrowserModule,
IonicModule.forRoot(MyApp),
StoreModule.forRoot(reducers),
StoreDevtoolsModule.instrument()
],
bootstrap: [IonicApp],
entryComponents: [
MyApp,
HomePage
],
providers: [
StatusBar,
SplashScreen,
{provide: ErrorHandler, useClass: IonicErrorHandler}
]
})
export class AppModule {}
のように、imports
配列にStoreDevtoolsModule.instrument()を追加するだけです。あとはブラウザにRedux DevTools Extensionをインストールすれば、Redux DevToolsで状態遷移を追いかけることができます。
Actionにpayloadをもたせてタイプ付けをする
簡単なカウンターアプリでは必要ありませんが、多くの場合ではactionをディスパッチする際にpayloadをもたせてreducerに情報を渡します。ところが@ngrx/storeのAction
インターフェースはtypeプロパティしかもっていないため、これだけだとpayloadをもたせることができません。
また複数のreducerを実装するようになると、actionの衝突を避ける必要が出てきます。ということで次はactionにpayloadをもたせてタイプ付けします。
Actionの実装
まずは以下の内容のsrc/app/actions.tsファイルを作成します。
// src/app/actions.ts
import { Action } from '@ngrx/store';
export const INCREMENT = '[Counter] Increment';
export const DECREMENT = '[Counter] Decrement';
export const RESET = '[Counter] Reset';
export class Increment implements Action {
readonly type = INCREMENT;
}
export class Decrement implements Action {
readonly type = DECREMENT;
}
export class Reset implements Action {
readonly type = RESET;
constructor(public payload: number) {}
}
export type All
= Increment
| Decrement
| Reset;
インターフェースとして定義されているActionに対してIncrement
、Decrement
、Reset
クラスを実装しています。最後にはTypeScriptのUnion type(直和型)としてすべてのactionをexportしています。
今まではResetでカウンターを0にリセットしていましたが、それでは面白くないので、Reset
クラスのconstructor
でpayloadをもたせています。これでactionをディスパッチする際にpayloadを追加できるようになります。
Reducerの修正
次にcounter.tsを修正します。
// src/app/counter.ts
import { Action } from '@ngrx/store';
import * as CounterActions from './actions'
export type Action = CounterActions.All;
const initialState: number = 0;
export function reducer(state: number = initialState, action: Action): number {
switch (action.type) {
case CounterActions.INCREMENT:
return state + 1;
case CounterActions.DECREMENT:
return state - 1;
case CounterActions.RESET:
return action.payload;
default:
return state;
}
}
今までは@ngrx/storeからAction
を読み込んでいましたが、ここではactions.tsからAction
を呼んでいます。またリセット時に0を返していましたが、action.payload
が返るように変更しています。
home.tsの修正
最後にhome.tsの修正です。
// src/pages/home/home.ts
import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import * as Counter from '../../app/actions';
import { AppState } from '../../app/reducers';
@Component({
selector: 'page-home',
templateUrl: 'home.html'
})
export class HomePage {
count$: Observable<number>;
constructor(public navCtrl: NavController, private store: Store<AppState>) {
this.count$ = store.select('count');
}
increment() {
this.store.dispatch(new Counter.Increment());
}
decrement() {
this.store.dispatch(new Counter.Decrement());
}
reset() {
this.store.dispatch(new Counter.Reset(3));
}
}
actionをディスパッチする際にactions.tsで定義したクラスを生成しています。ここではResetに3を渡しているので、リセットボタンをクリックするとカウンターが3に初期化されるのがアプリで確認できます。
Stateをプリミティブ型からオブジェクト型にする
多くのアプリではstateはnumberやstringなどのプリミティブ型ではなく、それらを含むオブジェクト型です。ということでstateをnumberからobjectにしてみます。
counter.tsの修正
まずはcounte.tsです。
// src/app/counter.ts
import * as CounterActions from './actions'
export type Action = CounterActions.All;
export interface State {
count: number;
}
const initialState: State = {
count: 0
};
export function reducer(state: State = initialState, action: Action): State {
switch (action.type) {
case CounterActions.INCREMENT:
return {count: state.count + 1};
case CounterActions.DECREMENT:
return {count: state.count - 1};
case CounterActions.RESET:
return {count: action.payload};
default:
return state;
}
}
export const getCount = (state: State) => state.count;
export interface State
が加わっています。ここでstateをオブジェクトとして定義しています。中にはnumber型のcount
プロパティをもたせています。この変更に伴い、initialState
やreducerの返り値の型がnumber
からState
に変更されます。
また、一番下にcount
プロパティを抜き出すための関数を新たに定義しています。あとでcreateSelector
にこの関数を渡します。
reducers.tsの修正
次はreducers.tsです。
// src/app/reducers.ts
import {
ActionReducerMap,
createSelector,
createFeatureSelector,
} from '@ngrx/store';
import * as fromCounter from './counter';
export interface AppState {
counter: fromCounter.State;
}
export const reducers: ActionReducerMap<AppState> = {
counter: fromCounter.reducer
};
export const getCounterState = createFeatureSelector<fromCounter.State>('counter');
export const getCounterCount = createSelector(getCounterState, fromCounter.getCount);
AppState
の中身がcounter: number
からcounter: fromCounter.State
に変更されています。また、最後の2行でcount
プロパティを取得するためのgetCounterCount
を定義しています。そのために、まずはstateの中でcounter
部分だけを取り出したgetCounterState
をcreateFeatureSelector
を使って定義しています。そしてcreateSelector
の第一引数にそれを、第二引数にcounter.tsで定義したgetCount
関数を渡しています。これでgetCounterCount
を使ってcount
プロパティの値をコンポーネント側で参照することができます。
home.tsの修正
最後はhome.tsです。
// src/pages/home/home.ts
import { Component } from '@angular/core';
import { NavController } from 'ionic-angular';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs/Observable';
import * as fromCounter from '../../app/actions';
import * as fromRoot from '../../app/reducers';
@Component({
selector: 'page-home',
templateUrl: 'home.html'
})
export class HomePage {
count$: Observable<number>;
constructor(public navCtrl: NavController, private store: Store<fromRoot.AppState>) {
this.count$ = store.select(fromRoot.getCounterCount);
}
increment() {
this.store.dispatch(new fromCounter.Increment());
}
decrement() {
this.store.dispatch(new fromCounter.Decrement());
}
reset() {
this.store.dispatch(new fromCounter.Reset(3));
}
}
constructorに渡されるstore
の型がfromRoot.AppState
に、store.select
の引数に渡されるのが’counter’からfromRoot.getCounterCount
に変更されています。
以上の修正を加えることで、アプリの挙動はまったく同じままでstateをプリミティブなnumber型からオブジェクトに変更することができます。先ほど導入したmetaReducers
、もしくはStoreDevtoolsModule
で、stateがただの数字から{count: 0}
のようなオブジェクトに変更されているのが確認できます。
まとめ
今回は最初の入り口としてIonic + @ngrx/storeでカウンターアプリを作ってみました。FluxやRedux, Vuexでの基本的な処理の流れを知っていれば敷居はかなり低いと思います。state, action, reducerを用意して、StoreModule.forRoot
にreducersを渡すだけです。
実用的なアプリになるとAPIへ問い合わせたりするような処理が加わります。その場合には@ngrx/storeに加えて@ngrx/effectsを追加することになります。@ngrx/effectsではRxJSがバリバリ使われることになるので、そちらはRxJSに馴染みがないとハードルが高いかもしれません。