LoginSignup
4
4

More than 5 years have passed since last update.

ngrxのaction/reducer/effectの定義をシンプルにする

Last updated at Posted at 2018-09-29

はじめに

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の思想に準拠し、トリッキーすぎる仕組みを使わず、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を修飾するだけです。これだけでもすっきりした気がするので、個人的には満足です。

image.png
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

この方法の嬉しさは以下のようなところにあります。

  • actionToActionデコレータで修飾した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ではなくpayloadOfpipeします。これにより、対象の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つのことを行っています。

  1. 元々のstatic methodを置き換える
  2. 1で置き換えたstatic methodに対して、action名を対応付ける

static methodの置き換え

下図のイメージで元々のstatic methodを包んでactionを返すように置き換えます。CounterActionsのpresetの場合、typeにはpreset.CounterActions、元々のメソッドを実行した戻り値(プリセット値n)をpayloadに持ったactionを返すように置き換えます。
image.png

static methodとaction名の対応付け

Action名をテキストで直接入力するのは入力補完ができないので、入力補完ができるstatic methodからaction名を参照したいので、デコレータ内で対応関係を作ります。
image.png

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ではactiontypestringであることが期待されているので、ReducerFactoryクラスではactionstatic methodからaction名を参照し、action名をキーとしてreducerreducesに追加します。

createにより、登録済みのreducerを集約したreducerを生成します。
image.png

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-utilityngrxの思想に完全に準拠しているか自信がなく、すべての使い方を網羅できているか分かりません。ご意見とうありましたらコメントください。ngrx本家にもこの機能を提案しているので、良ければそちらにもコメントください。

参考

4
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
4