はじめに
Angular
と組み合わせてngrx
を使うと便利だ、という記事をあちこちで見かけるので使ってみようと思ったのですが、独特な「Action
に1つ1つ文字列を割り当てて、クラス化する」という文化にぎこちなさを感じていました。(ngrx/platform actions参照)
これは
Typed Action
というもので、Typescriptの型チェックの機能を使って、Action
が持つパラメータ(Payload
)を入力補完できるようにするために使われています。
よく見かけるカウンタの例を見てみると分かる通り、Action
名を変えたかったらあちこちに影響が波及するし、独特な作りがあるのでAction
/Reducer
のboilerplate(テンプレ)による自動生成を使おう、と言われていたりします。それでも同じ文字列をコピペしまくるのはちょっと…今どき感がない…?
調べてみると同じことを感じている人がいて、色々な工夫をしているのを見かけます。ただ、ngrx
本家への提案(reducers/actions as classes, no magic strings, reduced boilerplate)のように、ngrx
が元々目指している「何が起きたか(action
)と状態がどうなるかの分離」をしておらず却下されていたり、Action
クラスを1つ1つ定義するのは変わらないなど、ちょっと癖があるようでした。
- ngrx-actions: https://github.com/amcdnl/ngrx-actions
- Simplr: https://github.com/ovrmrw/ngrx-store-simplr
- ngrx issue: https://github.com/ngrx/platform/issues/1221
そこで、ngrx
の思想に準拠し、トリッキーすぎる仕組みを使わず、action
/reducer
/effect
の定義を簡単にする仕組みを考えたので紹介します。(ngrx-utility)
ngrx-utilityの紹介
(githubのリンクはこちら...サンプルもあります) 従来のカウンタの例に倣い、以下のようにaction
/reducer
/effect
の定義をしてみます。
- Action
-
increment
(カウンタを進める) -
decrement
(カウンタを戻す) -
preset
(指定した値にする)
-
- Reducer
-
increment
/decrement
/preset
で状態count
を進める/戻す/指定した値にする
-
- Effect
-
preset
に対してコンソールに設定値を表示する
-
Actionの定義
@ToAction
というデコレータでstatic methodを修飾するだけです。これだけでもすっきりした気がするので、個人的には満足です。
export class CounterActions {
@ToAction() static increment(): any {}
@ToAction() static decrement(): any {}
@ToAction() static preset( n: number ): any { return n }
}
Reducerの定義
ReducerFactory<T>
クラス(Tは状態の型)をインスタンス化し、add
メソッドによりaction
に対するreducer
を追加していきます。最終的に、create
メソッドにより初期値を指定してreducer
を生成します。本家と比べるとすっきり書けていると思います。
const factory = new ReducerFactory<number>();
// add( action, reducer: ( state: T, payload?: any ) => T );
factory.add( CounterActions.increment, ( count: number ) => count + 1 );
factory.add( CounterActions.decrement, ( count: number ) => count - 1 );
factory.add( CounterActions.preset, ( count: number, preset: number ) => preset );
// Define reducer
export const counterReducer = factory.create( 0 ); // initial value = 0
この方法の嬉しさは以下のようなところにあります。
-
action
はToAction
デコレータで修飾したstatic method
で指定するため、入力補完が使える。 -
action
という中間生成物の存在を一切気にする必要がない。-
reducer
の第2引数はpayload
=ToAction
デコレータで修飾したstatic method
の戻り値のため。
-
-
reducer
の定義でお決まりのaction.type
毎の分岐もpayload
の取り出しも不要。
現時点のコードは
immutable
のことを考えていませんが、きっと障壁はないはず。
Effectの定義
本来effect
は状態への作用に対しての副作用(side effect
)を表すもので濫用はNGですが、例示のためpreset
に対してeffect
を設け、引数である設定値をconsole
に表示するようにします。
Effect
を定義するには、@ngrx/effects
が提供するActions
に対して、通常のofType
ではなくpayloadOf
をpipe
します。これにより、対象のaction
が来た時にpayload
だけを通過させることができるようになっています。
@Injectable()
export class CounterEffect {
constructor( private actions$: Actions ) {}
@Effect( { dispatch: false } ) preset$ = this.actions$.pipe(
payloadOf( CounterActions.preset ), // presetアクションのpayloadを後ろに流す
map( payload => console.log( payload ) )
);
}
ngrxへの組み込み
特に従来と変わりません。
@NgModule({
declarations: [
AppComponent
],
imports: [
StoreModule.forRoot( { count: counterReducer } ),
EffectsModule.forRoot( [ CounterEffect ] )
],
providers: [ ],
bootstrap: [ AppComponent ]
})
export class AppModule { }
ActionのDispatch
こちらも特に従来と変わりません。store.dispatch
引数に、ToAction
で修飾したstatic method
の戻り値を与えればよいだけです。
import { Component } from '@angular/core';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { CounterActions } from './counter';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
count$: Observable<number>;
constructor( private store: Store<any> ) {
this.count$ = this.store.pipe( select('count') );
}
increment() {
this.store.dispatch( CounterActions.increment() );
}
decrement() {
this.store.dispatch( CounterActions.decrement() );
}
preset() {
this.store.dispatch( CounterActions.preset( 10 ) );
}
}
ngrx-utilityの仕組み
ここまで読んでいただいた方の中には、「action
って文字列/クラスを指定するんじゃなかった?」とか、「Payload
はどこへ行った?」と思うかもしれません。実はこれらの仕組みはngrx-utility
が裏で使うだけになっており、プログラマから見えないようにしています。これにより上記のようにaction
/reducer
/effect
の定義をシンプルにしています。
ToActionデコレータ
Action
を作る部分の面倒ごとを担ってくれるのが、ToAction
デコレータです。このデコレータにより、static method
を修飾することで、以下の2つのことを行っています。
- 元々の
static method
を置き換える - 1で置き換えた
static method
に対して、action
名を対応付ける
static methodの置き換え
下図のイメージで元々のstatic method
を包んでaction
を返すように置き換えます。CounterActionsのpresetの場合、type
にはpreset.CounterActions
、元々のメソッドを実行した戻り値(プリセット値n)をpayload
に持ったaction
を返すように置き換えます。
static methodとaction名の対応付け
Action
名をテキストで直接入力するのは入力補完ができないので、入力補完ができるstatic method
からaction
名を参照したいので、デコレータ内で対応関係を作ります。
ToActionデコレータの中身
メソッドデコレータで、メソッドの本体descriptor.value
を新しい関数に置き換えることで、action
を返すように置き換えています[1]。その上でDICTIONARY
に新しいメソッドをキーとして、action
名を登録しています[2]。
// to store action name...not so good way.
const DICTIONARY = new Map<any,string>();
// wrap static method to return Action with Payload
export function ToAction(): MethodDecorator {
return function( target: any, propertyKey: string, descriptor: PropertyDescriptor ) {
// 元々のstatic method
let original = descriptor.value;
// action名の設定(method.classにする)
const name = propertyKey + '.' + target.name;
// --------------------------------------------------------------------
// [1] 元々のmethodをActionを返すように置き換え
descriptor.value = function(): ActionWithPayload {
const retVal = original.apply( this, arguments );
const action: ActionWithPayload = {
type: name,
payload: retVal
};
return action;
}
// --------------------------------------------------------------------
// [2] [1]で置き換えたstaic methodにaction名を対応付け
DICTIONARY.set( descriptor.value, name );
}
}
ReducerFactoryクラス
ngrx
ではaction
のtype
はstring
であることが期待されているので、ReducerFactory
クラスではaction
のstatic method
からaction
名を参照し、action
名をキーとしてreducer
をreduces
に追加します。
create
により、登録済みのreducer
を集約したreducer
を生成します。
export class ReducerFactory<T> {
private reducers: { [ action: string ]: Reducer<T> } = {};
add( actions: Function | Function[], reducer: Reducer<T> ) {
let actionNames: string[];
if( actions instanceof Array ) {
actionNames = toActionName( actions );
} else {
actionNames = toActionName( [ actions ] );
}
actionNames.map( action => {
this.reducers[ action ] = reducer;
} );
}
create( initialState: T ): ActionReducer<T> {
const reducers = { ...this.reducers }; // copy
return ( state: T = initialState, action: ActionWithPayload ) => {
const reducer = reducers[ action.type ];
let ret = state;
if( reducer !== undefined ) {
ret = reducer( state, action.payload );
}
return ret;
}
}
}
payloadOf pipable operator
従来のeffect
では、ofType pipable operator
により、action
が起きたことを検出し、その後でpayload
にアクセスしますが結局二度手間になると思ったので、これらをまとめています。
export const payloadOf = ( ...actions: Function[] ) => ( source: Observable<ActionWithPayload> ) => {
const actionNames: string[] = toActionName( actions );
return source.pipe(
filter( target => ( actionNames.includes( target.type ) ) ),
map( target => target.payload )
);
}
おわりに
ngrx-utility
が、ngrx
を使おうとして独特の文化から敬遠していた人の役に立てば幸いです。試したい方はgithubか、npmから最新版をダウンロードしてください。
npm install @stm32p103/ngrx-utility
なお、ngrx-utility
はngrx
の思想に完全に準拠しているか自信がなく、すべての使い方を網羅できているか分かりません。ご意見とうありましたらコメントください。ngrx本家にもこの機能を提案しているので、良ければそちらにもコメントください。
参考
- ngrx-actions: https://github.com/amcdnl/ngrx-actions
- Simplr: https://github.com/ovrmrw/ngrx-store-simplr
- ngrx issue: https://github.com/ngrx/platform/issues/1221
- Stop using ngrx/effects for that: https://medium.com/@m3po22/stop-using-ngrx-effects-for-that-a6ccfe186399
- typescript decorator: https://www.typescriptlang.org/docs/handbook/decorators.html
- pipable operator: https://github.com/ReactiveX/rxjs/blob/master/doc/pipeable-operators.md