vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

NgRx に触れてみる 1

はじめに

前から気になっていた NgRx に触れてみます。

NgRX は Redux のように状態を管理するためのものです。
今回は Redux のチュートリアルにある tic-tac-toe をなんとなく真似してみることにします。

環境

  • Angular: 10.0.2
  • @ngrx/store: 9.2.0
  • @ngrx/effects: 9.2.0 (Effects で使用)

Install

npx ng add @ngrx/store

元のプロジェクト

まずは NgRx を使用しないプロジェクトを用意しますよ( Effects に関連するところは後で)。

app.component.html

<app-board></app-board>

board.component.html


<div class="board">
<app-square
    *ngFor="let s of state.squares; let i = index"
    [props]="s" (onClick)="updateSquare(i)"></app-square>
</div>

board.component.ts


import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';

@Component({
  selector: 'app-board',
  templateUrl: './board.component.html',
  styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
  public state: BoardState;
  constructor() {
    this.state = this.initialState();
  }

  ngOnInit(): void {
  }
  public updateSquare(index: number) {
    const squares = this.state.squares.slice();
    squares[index] = (this.state.nextIsX)? '✕': '◯';
    this.state = {
        nextIsX: ! this.state.nextIsX,
        squares
    };
  }
  private initialState(): BoardState {
    return {
        nextIsX: true,
        squares: Array(9).fill(null),
    };
  }
}

board.component.css


.board {
    display: flex;
    flex-direction: row;
    flex-wrap: wrap;
    height: auto;
    width: 21vw;
}

board-state.ts


import { SquareValue } from './square/square-value';

export type BoardState = {
    nextIsX: boolean,
    squares: Array<SquareValue>
};

square.component.html


<button class="square" (click)="click()">{{props}}</button>

square.component.ts


import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { SquareValue } from './square-value';

@Component({
  selector: 'app-square',
  templateUrl: './square.component.html',
  styleUrls: ['./square.component.css']
})
export class SquareComponent implements OnInit {
  @Input() props: SquareValue;
  @Output() onClick: EventEmitter<void> = new EventEmitter();
  constructor() { }

  ngOnInit(): void {
  }
  public click() {
    this.onClick.emit();
  }
}

square.component.css


.square{
    height: 7vw;
    width: 7vw;
}

実行すると 9 個のボタンが表示され、クリックすると順に「◯」「✕」が表示されます。

f:id:mslGt:20200704165550j:plain

Action, Reducer, Store を追加する

Action を作る

「何が起こったか」(今回はボタンがクリックされた) を表現する Action を作ります。

game.actions.ts


import { createAction, props } from '@ngrx/store';

export const updateSquare = createAction('[Game] updateSquare',
    props<{index: number}>());

Action を作る上で必須になるのは第一引数である「type」のみです。
ですが、今回はクリックされたボタンの配列番号が欲しかっったので「props」を第二引数として追加しています。

Reducer を作る

次は「Action によって状態がどう変わるか」(今回は「◯」または「✕」が入る)を表現する Reducer を追加します。

game.reducer.ts


import { createReducer, on, Action } from "@ngrx/store";
import { BoardState } from './board/board-state';
import { updateSquare } from './game.actions';

function initialState(): BoardState {
    return {
        nextIsX: true,
        squares: Array(9).fill(null),
    };
}
function getUpdatedState(lastState: BoardState, index: number): BoardState {
    const squares = lastState.squares.slice();
    squares[index] = (lastState.nextIsX)? '✕': '◯';
    return {
        nextIsX: ! lastState.nextIsX,
        squares
    };
}
const _gameReducer = createReducer(initialState(),
    on(updateSquare, (state, {index}) => getUpdatedState(state, index)));

export function reducer(state: BoardState | undefined, action: Action) {
    return _gameReducer(state, action);
}

今回は Action を一つだけ登録していますが、二つ以上の場合は下記のように追加していきます。


const _gameReducer = createReducer(initialState(),
    on(updateSquare, (state, {index}) => getUpdatedState(state, index)),
    on(resetSqure, (state) => initialState()));

StoreModule に Reducer を登録する

Reducer を有効にし、状態管理が行われるようにするため StoreModule に登録します。

app.module.ts


import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { StoreModule } from '@ngrx/store';
import { BoardComponent } from './tic-tac-toe/board/board.component';
import { SquareComponent } from './tic-tac-toe/board/square/square.component';
import * as gameReducer from './tic-tac-toe/game.reducer';
@NgModule({
  declarations: [
    AppComponent,
    BoardComponent,
    SquareComponent
  ],
  imports: [
    BrowserModule,
    StoreModule.forRoot({game: gameReducer.reducer})
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Component に接続する

「Board」 Component で今の状態の取得、 Action 、 Reducer を使って状態の更新をできるようにします。

board.component.ts


import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { updateSquare } from '../game.actions';

@Component({
  selector: 'app-board',
  templateUrl: './board.component.html',
  styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
  // receive current state
  public state$: Observable<BoardState>;

  constructor(private store: Store<{game: BoardState}>) {
    this.state$ = store.pipe(select('game'));
  }
  ngOnInit(): void {
  }
  public updateSquare(index: number) {
    // call the reducer to update the state
    this.store.dispatch(updateSquare({index}));
  }
}

ここで重要だと思うのは「select('game')」です。
引数の「game」は app.module.ts で登録したものと合わせる必要があります。

ここが違っているとエラーは発生しませんが状態を受け取ることができない、という問題が発生します(ちょっとハマりました)。

board.component.html


<div class="board">
<app-square
    *ngFor="let s of (state$|async)?.squares; let i = index"
    [props]="s" (onClick)="updateSquare(i)"></app-square>
</div>

現状

ここまでで下記のような流れができています。

f:id:mslGt:20200704165630j:plain

Effects を使う

次は Effects(@ngrx/effects) を使ってみます。

名前からすると「side-effects (副作用)」なのかな?と思いきや、ネットワークリクエストなど、外部リソースにアクセスする場合に使われるようです。

Reducer のように、「store.dispatch」を使って Action を発行することで処理を呼び出します。

流れとしてはこんな感じ。。。のはず。

f:id:mslGt:20200704165653j:plain

Install

npx ng add @ngrx/effects

元のプロジェクトに Service を追加する

今回のサンプルでは、ボタンがクリックされ、状態が変化したタイミングでゲームの勝敗がついたかを確認する処理を追加します。
簡易化のためサーバーにアクセスなどはしてません。

check-game-finished.ts


import { SquareValue } from './board/square/square-value';
import { GameResult } from './game-result';

type CheckTarget = {
    index: number,
    match: readonly CheckTarget[],
    unmatch: readonly CheckTarget[]
};
const emptyTarget = {
    match: [],
    unmatch: []
};
const checkTargets: CheckTarget = {
    index: 0,
    match: [{
        index: 1,
        match: [{
            index:2,
            ...emptyTarget
        }],
        unmatch: []
    }, {
        index: 3,
        match: [{
            index: 6,
            ...emptyTarget
        }],
        unmatch: []
    }, {
        index: 4,
        match: [{
            index: 8,
            ...emptyTarget
        }],
        unmatch: []
    }],
    unmatch: [{
        index: 4,
        match: [{
            index: 1,
            match: [{
                index: 7,
                ...emptyTarget
            }, {
                index: 3,
                match: [{
                    index: 5,
                    ...emptyTarget
                }],
                unmatch: []
            }],
            unmatch: []
        }],
        unmatch:[{
            index: 8,
            match: [{
                index: 2,
                ...emptyTarget
            },{
                index: 6,
                ...emptyTarget
            }],
            unmatch: []
        }]
    }]
};
function getTargetIndices(squares: readonly SquareValue[]): { circles: number[], crosses: number[]} {
    const circles = new Array<number>();
    const crosses = new Array<number>();
    for(let i = 0; i < squares.length; i++) {
        if (squares[i] == '◯') {
            circles.push(i);
        } else if(squares[i] == '✕') {
            crosses.push(i);
        }
    }
    return { circles, crosses };
}
function check(targetIndices: readonly number[],
        nextTarget: CheckTarget): boolean {
    if (targetIndices.some(i => nextTarget.index == i)) {
        if (nextTarget.match.length <= 0) {
            return true;
        }
        if (nextTarget.match.some(t => check(targetIndices, t))) {
            return true;
        }
    } else {
        if (nextTarget.unmatch.length <= 0) {
            return false;
        }
        if (nextTarget.unmatch.some(t => check(targetIndices, t))) {
            return true;
        }
    }
    return false;
}
export function checkGameFinished(squares: readonly SquareValue[]): GameResult {
    console.log("check");
    const targets = getTargetIndices(squares);
    if (check(targets.circles, checkTargets)) {
        return { finished: true, winner: '◯' };
    } else if (check(targets.crosses, checkTargets)) {
        return { finished: true, winner: '✕' };
    }
    return { finished: false, winner: null };
}

ゲームの勝敗確認の処理はもう少し簡略化したい。。。

game-score.service.ts


import { Injectable } from '@angular/core';
import { SquareValue } from './board/square/square-value';
import { Observable, of } from 'rxjs';
import { GameResult } from './game-result';
import * as GameChecker from './check-game-finished';

@Injectable({
  providedIn: 'root'
})
export class GameScoreService {

  constructor() { }
  public checkResult(squares: readonly SquareValue[]): Observable<GameResult> {
    return of(GameChecker.checkGameFinished(squares));
  }
}

Effects を追加する

game-result.effects.ts


import { Injectable } from '@angular/core';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { map, mergeMap, catchError } from 'rxjs/operators';
import { GameScoreService } from './game-score.service';
import { checkScore, gameFinished } from './game.actions';
import { EMPTY } from 'rxjs';
@Injectable()
export class GameResultEffects {
    /* this would be fired by dispatching "checkScore" action. */
    checkResult$ = createEffect(() => this.actions$.pipe(
        ofType(checkScore),
        /* call service */
        mergeMap(action => this.gameScoreService.checkResult(action.squares)
            .pipe(
                /* make an action fired */
                map(result => gameFinished({ result })),
                catchError(error => {
                    console.error(error);
                    return EMPTY;
                })
            ))));
    constructor(
        private actions$: Actions,
        private gameScoreService: GameScoreService
      ) {}
}

game.action.ts


import { createAction, props } from '@ngrx/store';
import { SquareValue } from './board/square/square-value';
import { GameResult } from './game-result';

...
export const checkScore = createAction('[Score] checkScore',
    props<{ squares: readonly SquareValue[]}>());
export const gameFinished = createAction('[Game] finished',
    props<{ result: GameResult }>());

Effects を登録する

Reducer と同じように app.module.ts に Effects を追加します。

app.module.ts


import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { StoreModule } from '@ngrx/store';
import { BoardComponent } from './tic-tac-toe/board/board.component';
import { SquareComponent } from './tic-tac-toe/board/square/square.component';
import * as gameReducer from './tic-tac-toe/game.reducer';
import { EffectsModule } from '@ngrx/effects';
import { GameResultEffects } from './tic-tac-toe/game-result.effects';
@NgModule({
  declarations: [
    AppComponent,
    BoardComponent,
    SquareComponent
  ],
  imports: [
    BrowserModule,
    StoreModule.forRoot({game: gameReducer.reducer}),
    EffectsModule.forRoot([GameResultEffects])
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Component に接続する(失敗)

こちらも Reducer のときと同じように Component に状態受け取りと Action 発行の処理を追加します。

board.component.ts


import { Component, OnInit } from '@angular/core';
import { BoardState } from './board-state';
import { Store, select } from '@ngrx/store';
import { Observable } from 'rxjs';
import { updateSquare, checkScore } from '../game.actions';
import { GameResult } from '../game-result';
import { SquareValue } from './square/square-value';

@Component({
  selector: 'app-board',
  templateUrl: './board.component.html',
  styleUrls: ['./board.component.css']
})
export class BoardComponent implements OnInit {
  public state$: Observable<BoardState>;
  public result$: Observable<GameResult>;

  constructor(private store: Store<{game: BoardState, result: GameResult }>) {
    this.state$ = store.pipe(select('game'));
    this.state$.subscribe(s => this.onValueChanged(s.squares));
    this.result$ = store.select(state => state.result);
    this.result$.subscribe(r => console.log(r));
  }

  ngOnInit(): void {
  }
  public updateSquare(index: number) {
    this.store.dispatch(updateSquare({index}));
  }
  private onValueChanged(squares: readonly SquareValue[]) {
    this.store.dispatch(checkScore({squares}));
  }
}

課題と対策

「gameScoreService.checkResult」の処理実行などは問題ないにもかかわらず、「this.result$.subscribe(r => console.log(r));」が実行されません。

結局 Effects から発行されている「gameFinished」の Action をハンドリングする Reducer がいない、ということが原因でした。

Effects から Action を発行すると、良い感じに StoreModule が状態をさばいてくれる、とかいう都合の良い展開にはならない、ということですね。

ということで Reducer を追加します。

game-result.reducer.ts


import { GameResult } from './game-result';
import { createReducer, on, Action } from '@ngrx/store';
import { gameFinished } from './game.actions';

function initialState(): GameResult {
    return {
        finished: false,
        winner: null
    };
}
const _resultReducer = createReducer(initialState(),
    on(gameFinished, (state, {result}) => {
        if (result.finished) {
            return result;
        }
        return state;
    }));
export function reducer(state: GameResult | undefined, action: Action) {
    return _resultReducer(state, action);
}

app.module.ts


...
import * as gameResultReducer from './tic-tac-toe/game-result.reducer';
import { EffectsModule } from '@ngrx/effects';
import { GameResultEffects } from './tic-tac-toe/game-result.effects';
@NgModule({
...
  imports: [
    BrowserModule,
    StoreModule.forRoot({game: gameReducer.reducer, result: gameResultReducer.reducer}),
    EffectsModule.forRoot([GameResultEffects])
  ],
...

これで result$ の変化も受け取ることができました。

今回は何もしていませんが、 Service で例外が発生した場合 Effects から Action として発行することができるため、実運用ではこちらも Reducer で処理することになると思います。

次回に続く。。。はず