🏡

Undo Redo機能をAngularで実装する 2

2022/03/18に公開

AngularでUndoRedo機能を解説するシリーズの2回目。前回はこちら

はじめに

前回はStoreServiceというローカルストアを自前で実装し、CommandがStoreServiceをReceiver[1]とする方式だった。StoreServiceはImmutabilityを保証するために実装に気を遣うのでNgRXで代替する方法を解説する。

今回のソースコードは以下。
https://github.com/yooontheearth/angular-tour-of-heroes-undo-redo/tree/UndoRedoByCommandPatternWithNgRx

NgRX

NgRXはAngular用のステートマネジメントフレームワークでReduxから影響を受けている。NgRXには複数のモジュールがあるが今回はStoreとEffectを使う。NgRXの詳細については公式ドキュメントを参照してもらいたいが簡単に解説すると以下の項目に分解できる。

  • Store:ステートを管理するもの
  • Selector:Storeからステートを取得するために使用する
  • Reducer:Store内のステートを編集するために使用する
  • Effect:永続化処理などのステート編集以外のことをする
  • Action:ReducerとEffectに通知するために使用する

各ComponentはSelectorでステートを取得するがStore内のステートを変更できるのはReducerだけとなる。そしてReducerを呼び出せるのはActionだけだ。

なんのためにこんな風に役割が分けられているかというと、複数のComponent(ComponentだけでなくてServiceでもEffectでもどこからでも)から同一のステートを更新したい場合に、Reducerのようにステートを変更できる(Mutateできる)場所が定まっていないとバグが発生しやすく、問題の追跡も難しくなる。とくにコードベースが肥大化するほどその傾向が高まるだろう。

以下の動画でMeta(当時はFacebook)がなぜFluxパターン(ReduxはFluxパターンに影響を受けている)を開発したかの経緯が紹介されている。メッセージの未読数のカウントを制御する事例はFluxパターンを理解する上で非常に有益なので一度視聴されることをお勧めする。

https://www.youtube.com/watch?v=nYkdrAPrdcw&t=620s

Reducerには初期ステートが与えられ(ここでは空の配列)、各Actionに応じてステートを変更していく。

hero.reducer.ts
export const initialState:ReadonlyArray<Hero> = [];
export const heroReducer = createReducer(
    initialState,
    on(HeroActions.getHeroesSuccess, (state, { heroes }) => heroes),
    on(HeroActions.addHeroSuccess, (state, { hero }) => [...state, hero]),
    on(HeroActions.deleteHeroSuccess, (state, { id }) => state.filter(h => h.id !== id)),
    on(HeroActions.updateHeroSuccess, (state, { hero }) => {
        return state.map(h => h.id === hero.id ? hero : h);
    }),
);

Effectで永続化処理を行ってからステートの変更をしたいので各ActionにはEffect用のActionとReducer用のAction(末尾にSuccessとついている)の2種類を用意している。

hero.actions.ts
export const addHero = createAction(
  '[Heroes] Add Hero',
  props<{ hero:Hero }>()
);
export const addHeroSuccess = createAction(
    '[Heroes] Add Hero Success',
    props<{ hero:Hero }>()
  );
export const updateHero = createAction(
    '[Heroes] Updagte Hero',
    props<{ hero:Hero }>()
  );
  export const updateHeroSuccess = createAction(
    '[Heroes] Updagte Hero Success',
    props<{ hero:Hero }>()
  );
export const deleteHero = createAction(
  '[Heroes] Delete Hero',
  props<{ id:number }>()
);
export const deleteHeroSuccess = createAction(
    '[Heroes] Delete Hero Success',
    props<{ id:number }>()
  );

export const searchHeroes = createAction(
    '[Heroes] Search Heroes',
    props<{ term:string }>()
  );
  export const searchHeroesSuccess = createAction(
    '[Heroes] Search Heroes Success',
    props<{ term:string }>()
  );

export const getHeroes =  createAction(
    '[Heroes] Get Heroes'
  );
  export const getHeroesSuccess =  createAction(
    '[Heroes] Get Heroes Success',
    props<{ heroes:ReadonlyArray<Hero>}>()
  );

EffectはHeroServiceを使用して永続化処理を行い、その後、Reducerへの通知を行う。

hero.effects.ts
export class Heroffects {
loadHeroes$ = createEffect(() => this.actions$.pipe(
    ofType(HeroActions.getHeroes),
    mergeMap(() => this.heroService.getHeroes().pipe(
                    tap(_ => this.messageService.add('fetched heroes in an effect')),
                    map(heroes => HeroActions.getHeroesSuccess({ heroes })),
                    catchError(() => EMPTY)
                )
    )
)); 

addHero$ = createEffect(() => this.actions$.pipe(
    ofType(HeroActions.addHero),
    mergeMap((action) => this.heroService.addHero(action.hero).pipe(
                    tap(_ => this.messageService.add('add hero in an effect')),
                    map(hero => HeroActions.addHeroSuccess({ hero }))
                )
    )
)); 

deleteHero$ = createEffect(() => this.actions$.pipe(
    ofType(HeroActions.deleteHero),
    mergeMap((action) => this.heroService.deleteHero(action.id).pipe(
                    tap(_ => this.messageService.add('delete hero in an effect')),
                    map(id => HeroActions.deleteHeroSuccess({ id }))
                )
    )
)); 

updateHero$ = createEffect(() => this.actions$.pipe(
    ofType(HeroActions.updateHero),
    mergeMap((action) => this.heroService.updateHero(action.hero).pipe(
                    tap(_ => this.messageService.add('update hero in an effect')),
                    map(hero => HeroActions.updateHeroSuccess({ hero }))
                )
    )
)); 
  constructor(
    private actions$: Actions,
    private heroService: HeroService,
    private messageService: MessageService
  ) {}
}

SelectorはHero配列を取得するのと特定のHeroを取得するために利用している。

hero.selectors.ts
export const selectHeroes = createFeatureSelector<ReadonlyArray<Hero>>('heroes');
export const selectHero = (id:number) => createSelector(
    selectHeroes,
    (heroes) => heroes.find(h => h.id === id)
);

NgRXを使ったCommandパターン

CommandのInterfaceは前回と変わらない。前回はStoreServiceをReceiverとして処理していたが、今回はEffectへ通知するためにActionをdispatchしている。

hero.selectors.ts
export class AddHeroCommand implements Command<Hero>{
    description:string;
    hero:Hero;
    subscription!:Subscription;
    constructor(
        hero:Hero,
        private store: Store,
        private actionsSubject:ActionsSubject
        ){
            this.hero = {...hero} as Hero;  // Clone a state
            this.description = `Add ${hero.name}`;

             this.subscription = this.actionsSubject.pipe(
                ofType(HeroActions.addHeroSuccess)
              ).subscribe((props) => { 
                this.hero = {...props.hero};    // Retrieve a generated Id
                this.subscription.unsubscribe();
            });
        }
    execute(): void {
        this.store.dispatch(HeroActions.addHero({ hero:this.hero }));
    }
    undo(): void {
        this.store.dispatch(HeroActions.deleteHero({ id:this.hero.id }));
    }
    redo(): void {
        return this.execute();
    }    
}

export class DeleteHeroCommand implements Command<Hero>{
    description:string;
    constructor(
        private hero:Hero,
        private store: Store
        ){
            this.description = `Delete ${hero.name}`;
        }
    execute(): void {
        this.store.dispatch(HeroActions.deleteHero({ id:this.hero.id }));
    }
    undo(): void {
        this.store.dispatch(HeroActions.addHero({ hero:this.hero }));
    }
    redo(): void {
        return this.execute();
    }    
}

export class UpdateHeroCommand implements Command<Hero>{
    description:string;
    constructor(
        private oldHero:Hero,
        private newHero:Hero,
        private store: Store
        ){
            this.description = `Update ${oldHero.name} to ${newHero.name}`;
        }
    execute(): void {
        this.store.dispatch(HeroActions.updateHero({ hero:this.newHero}));
    }
    undo(): void {
        this.store.dispatch(HeroActions.updateHero({ hero:this.oldHero}));
    }
    redo(): void {
        return this.execute();
    }    
}

各ComponentはCommandの生成とUndoRedoServiceへの呼び出しの責務を持つ。前回、ComponentはStoreServiceとやり取りするだけでUndoRedo機能の詳細は関知しなかった。StoreService廃止に伴い今回はComponentの責務が増えてしまった。そのためCommandの抽象化が可能であったり複数個所から同一のCommandが登録されるようであるならもう一つレイヤーを挟んでそのレイヤーにCommandの生成とUndoRedoServiceへの呼び出しの責務を移譲した方がより良いと思われる。

heroes.component.ts
export class HeroesComponent implements OnInit {
  heroes$ = this.store.select(selectHeroes);
  HeroType=HeroType;

  constructor(private store: Store, 
    private undoRedoService:UndoRedoService,
    private actionsSubject:ActionsSubject,
     private messageService:MessageService) { }
  
  ngOnInit(): void {
    this.store.dispatch(HeroActions.getHeroes());
  }

  add(name:string):void{
    name = name.trim();
    if(!name) {
      return;     
    }
    const type = HeroType.Classical;
    const hero = { name, type } as Hero;
    this.undoRedoService.execute(new AddHeroCommand(hero, this.store, this.actionsSubject));
  }

  delete(hero:Hero):void{
    this.undoRedoService.execute(new DeleteHeroCommand(hero, this.store));
  }
}

UndoRedoServiceは前回と変わりないので省略する。

UndoRedoのステート管理もNgRXに任せる方式について

UndoRedoServiceのステートもNgRX Storeにその管理を任せることは技術的には可能だ。ただし、UndoRedo機能とCommandパターンとNgRX Storeのこの三者の相性は非常に悪い。なぜならステートを変更するためのActionはUndoRedoのために記録しておかなければならないが、UndoRedo処理で取り出されたActionはUndoRedoのために記録してはならないからだ。このUndoRedoのために記録するのか、しないのかを判別するためにActionにフラグを追加したり、別途Actionを追加したりなどの対応もできなくもないがアプリケーション全体でこのような煩雑な実装をするのは避けた方がよいだろう。

それなので今回はUndoRedoServiceをNgRX Storeに移行はしなかった。

NgRxとObservable

前回、StoreServiceからステートを取得するComponentはメモリーリークを防ぐためにDestroyableComponentを継承しunsubscribeOnDestroyを実行する必要があったが、それはNgRXになっても同様だ。

dashboard.compornent.ts
export class DashboardComponent extends DestroyableComponent implements OnInit {
  heroes:Hero[] = [];

  constructor(private store: Store, private messageService:MessageService) { super(); }

  ngOnInit(): void {
    this.store.select(selectHeroes).pipe((s) => this.unsubscribeOnDestroy(s)).subscribe(heroes => this.heroes = heroes.slice(1, 5));

    this.store.dispatch(HeroActions.getHeroes());
  }
}

ただHTMLテンプレートのほうでasyncパイプを使用している場合はasyncパイプがObservableのSubscriptionを正しく処置してくれるのでDestroyableComponentを継承する必要はない。

おわりに

今回はCommandパターンどうこうというよりも、自前でローカルストアを用意する VS NgRXの解説になった。NgRXはコード量も増えるので導入の検討は慎重に行った方がよいと考えるが、ローカルストアのImmutabilityの実装の労力を考慮するとアプリケーションの規模が大きくなるほどNgRXのようなステートマネジメントフレームワークに面倒な部分を委譲した方がよいと思う。

ただNgRXを導入したとしても、今回のUndoRedoServiceのようにNgRX Storeにすべてのステートを集約する必要もないので臨機応変に使用するのが良いだろう。次回はNgRXを使ったMementoパターンを解説する。

脚注
  1. https://refactoring.guru/design-patterns/command のStructureの画像を参照してもらいたい ↩︎

Discussion