🏘️

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

2022/03/19に公開

AngularでUndoRedo機能を解説するシリーズの3回目。今回はNgRXを使用してのMementoパターンを解説する。

はじめに

前回はNgRXの使い方と自前のローカルストアとの相違点を中心に解説した。今回はMementoパターンを使うための前提や変更点を解説する。

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

Mementoパターン

Mementoパターンは変更処理のたびに変更前のステート全体をとっておくことによってUndoRedoを実現する。UndoまたはRedo処理時には保持されているステート全体を随時入れ替えることで対応する。ステートをすべて入れ替える必要性から随時永続化を行っているTour of Heroesのようなアプリケーションでは都度ステート全体を永続化する処理が必要だ。

HeroServiceの変更点

追加、更新、削除は個別の関数ではなくsave関数でdelete/insertを行う。

※注 バックエンドにInMemoryDbServiceを使用しているので疑似的にdelete/insertを実装している。resetDbをPOSTすることでDBをクリアし各Heroを随時追加している。

hero.service.ts
export class HeroService {
  private heroesUrl = 'api/heroes';  // URL to web api

  httpOptions = {
    headers:new HttpHeaders({ 'Content-Type' : 'application/json' })
  };

  constructor(
    private http: HttpClient,
    private messageService:MessageService) { }
    
  getHeroes(): Observable<Hero[]> {
    const heroes = this.http.get<Hero[]>(this.heroesUrl)
              .pipe(
                  tap(_ => this.log('fetched heroes')),
                  catchError(this.handleError<Hero[]>('geHeroes', []))
              );
    return heroes;
  }

  save(heroes:Hero[]):Observable<any>{
    return this.http.post('commands/resetDb', { clear: true }).pipe(
      catchError(this.handleError<any>('save', [])),
      mergeMap(() => forkJoin(heroes.map(h => this.addHero(h))))
    );
  }
  
  addHero(hero:Hero):Observable<Hero>{
    return this.http.post<Hero>(this.heroesUrl, hero, this.httpOptions).pipe(
      tap((newHero:Hero) => this.log(`added hero w/ id=${newHero.id}`)),
      catchError(this.handleError<Hero>('addHero'))
    );
  }
  
  searchHeroes(term:string):Observable<Hero[]>{
    if(!term.trim()){
      return of([]);
    }
    return this.http.get<Hero[]>(`${this.heroesUrl}/?name=${term}`).pipe(
      tap(x => x.length ?
        this.log(`found heroes matching "${term}"`) :
        this.log(`no heroes matching "${term}"`)),
      catchError(this.handleError<Hero[]>('searchHeroes', []))
    );
  }

  private handleError<T>(operation = 'operation', result? : T){
    return (error:any):Observable<T> => {
      console.error(error);
      this.log(`${operation} failed: ${error.message}`);
      return of(result as T);
    };
  }

  private log(message:string){
    this.messageService.add(`HeroService: ${message}`);
  }
}

UndoRedoステート

前回まではUndoRedoステートの保持はNgRX Storeに任せることなくUndoRedoServiceが行っていた。しかしMementoパターンでActionが簡素化されたことでNgRX Storeに集約可能となった。

HeroActions.getHeroesSuccessはバックエンドから読み込んだ結果の通知で、HeroActions.saveSuccessは永続化処理の結果通知だ。違うActionを使って同じ処理(Hero[]ステートの更新)を行っている。UndoRedoのために分けているので理由は後述する。

HeroActions.undoSuccess、HeroActions.redoSuccessはそれぞれUndoRedoServiceのundo、redoが行っていたUndoRedo関連のステート処理をNgRX Storeに対して行っている。

バックエンドの更新処理は前回から変わらずEffectで行っている(Effectの処理は後述)。

hero.reducer.ts
export interface UndoRedoItem{
    description:string,
    state:Array<Hero>
}

export interface UndoRedoState {
    isRedoable:boolean,
    isUndoable:boolean,
    current:Array<Hero>,
    undoStack:Array<UndoRedoItem>,
    redoStack:Array<UndoRedoItem>
}

export const initialState:UndoRedoState = {
    isRedoable:false,
    isUndoable:false,
    current:[],
    undoStack:[],
    redoStack:[]
};

export const heroReducer = createReducer(
    initialState,
    on(HeroActions.getHeroesSuccess,
       HeroActions.saveSuccess,
         (state, { heroes }) =>({
        ...state,
        current:[...heroes]
    })),
    on(HeroActions.undoSuccess,
          (state, { }) =>{
            const undoItem:UndoRedoItem = state.undoStack[0];
            const redoItem:UndoRedoItem = {
                description:undoItem.description,
                state:state.current.slice()
            };
            return {
                ...state,
                current:undoItem.state.slice(),
                isRedoable:true,
                isUndoable:state.undoStack.length > 1,
                redoStack:[redoItem, ...state.redoStack],
                undoStack:state.undoStack.slice(1)
            };
    }),
    on(HeroActions.redoSuccess,
        (state, { }) =>{
            const redoItem:UndoRedoItem = state.redoStack[0];
            const undoItem:UndoRedoItem = {
                description:redoItem.description,
                state:state.current.slice()
            };
            return {
                ...state,
                current:redoItem.state.slice(),
                isRedoable:state.redoStack.length > 1,
                isUndoable:true,
                redoStack:state.redoStack.slice(1),
                undoStack:[undoItem, ...state.undoStack]
            };
  })
);

MetaReducer

NgRXにはMetaReducerと呼ばれるものがあり、Reducerが実行される前に処理をフックすることが可能だ。

今回はMetaReducerでUndoRedo機能のために保存すべきActionの場合はsave関数でステートを保存している。UndoRedo機能用に保存すべきステートかどうかを判別するためにHeroActions.saveSuccessを使用している。HeroActions.saveSuccessは前述したReducerでHero[]ステートを更新するために使用している。

今回のアプリケーションでは保存時だけではなくデータを読み込んだ際にもステートが変更されるので、その場合はステートをUndoスタックに積む必要がないので、
HeroActions.getHeroesSuccessとHeroActions.saveSuccessとにActionを分けている。

hero.reducer.ts
export function saveActionMetaReducer(reducer: ActionReducer<{heroes:UndoRedoState}>): ActionReducer<{heroes:UndoRedoState}> {
    function save(state:{heroes:UndoRedoState}|undefined, action:Action){
        if(!state)
            return reducer(state, action);
        const wholeState = reducer(state, action);
        const heroesState = wholeState.heroes;
        const saveAction = action as {originalType:string, type:string};
        const item:UndoRedoItem = {
            description:saveAction.originalType,
            state:state.heroes.current.slice()
        };
        return {
            ...wholeState,
            heroes:{
                ...heroesState,
                isRedoable:false,
                isUndoable:true,
                redoStack:[],
                undoStack:[item, ...heroesState.undoStack]
            }
        };
    }

    return function(state, action) {
        switch(action.type){
            case HeroActions.saveSuccess.type:
               return save(state, action);
            default:
                return reducer(state, action);
        }
    };
  }

その他の前回からの変更点

追加、更新、削除のSuccess ActionはsaveSuccessに集約されている。そしてUndoRedo関連のActionが追加されている。

hero.actions.ts
export const addHero = createAction(
  '[Heroes] Add Hero',
  props<{ hero:Hero }>()
);
export const updateHero = createAction(
    '[Heroes] Updagte Hero',
    props<{ hero:Hero }>()
  );
export const deleteHero = createAction(
  '[Heroes] Delete Hero',
  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>}>()
);
export const saveSuccess =  createAction(
  '[Heroes] Save Success',
  props<{ originalType:string, heroes:ReadonlyArray<Hero>}>()
);
export const undo =  createAction(
  '[Heroes] Undo'
);
export const redo =  createAction(
  '[Heroes] Redo'
);
export const undoSuccess =  createAction(
  '[Heroes] Undo Success'
);
export const redoSuccess =  createAction(
  '[Heroes] Redo Success'
);

Effectで各Actionを受けて個別の処理を行い、バックエンドを更新してからSuccess ActionをDispatchする。SelectorをUnsubscribeするためにtake(1)を使用している。

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.store.select(selectCurrentHeroes).pipe(
            take(1),
            map(heroes => [...heroes, action.hero]),
            mergeMap(heroes => this.heroService.save(heroes)),
            map(heroes => HeroActions.saveSuccess({ originalType:action.type, heroes }))
        )
    )
)); 

deleteHero$ = createEffect(() => this.actions$.pipe(
    ofType(HeroActions.deleteHero),
    mergeMap((action) => this.store.select(selectCurrentHeroes).pipe(
        take(1),
        map(heroes => heroes.filter(h => h.id !== action.id)),
        mergeMap(heroes => this.heroService.save(heroes)),
        map(heroes => HeroActions.saveSuccess({ originalType:action.type, heroes }))
    ))
)); 

updateHero$ = createEffect(() => this.actions$.pipe(
    ofType(HeroActions.updateHero),
    mergeMap((action) => this.store.select(selectCurrentHeroes).pipe(
        take(1),
        map(heroes => heroes.map(h => h.id === action.hero.id ? action.hero : h)),
        mergeMap(heroes => this.heroService.save(heroes)),
        map(heroes => HeroActions.saveSuccess({ originalType:action.type, heroes }))
    ))
)); 

undo$ = createEffect(() => this.actions$.pipe(
    ofType(HeroActions.undo),
    mergeMap((action) => this.store.select(selectHeroUndoStack).pipe(
        take(1),
        mergeMap(undoStack => this.heroService.save(undoStack[0].state)),
        map(heroes => HeroActions.undoSuccess()))
    )
)); 
redo$ = createEffect(() => this.actions$.pipe(
    ofType(HeroActions.redo),
    mergeMap((action) => this.store.select(selectHeroRedoStack).pipe(
        take(1),
        mergeMap(redoStack => this.heroService.save(redoStack[0].state)),
        map(heroes => HeroActions.redoSuccess()))
    )
)); 
  constructor(
    private store:Store,
    private actions$: Actions,
    private heroService: HeroService,
    private messageService: MessageService
  ) {}
}

UndoRedo関連のSelectorが追加されている。

hero.selectors.ts
export const selectUndoableHeroes = createFeatureSelector<UndoRedoState>('heroes');
export const selectCurrentHeroes = createSelector(
    selectUndoableHeroes,
    (app) => app.current
);
export const selectHero = (id:number) => createSelector(
    selectCurrentHeroes,
    (heroes) => heroes.find(h => h.id === id)
);
export const selectHeroUndoable = createSelector(
    selectUndoableHeroes,
    (app) => app.isUndoable
);
export const selectHeroRedoable = createSelector(
    selectUndoableHeroes,
    (app) => app.isRedoable
);
export const selectHeroUndoStack = createSelector(
    selectUndoableHeroes,
    (app) => app.undoStack
);
export const selectHeroRedoStack = createSelector(
    selectUndoableHeroes,
    (app) => app.redoStack
);

前回までの各ComponentはUndoRedo機能のためにCommandを生成しUndoRedoServiceを使用していたが、UndoRedoステートの管理はEffectとReducerへと移譲されたので各種処理の呼び出しは各種ActionをDispatchするだけとなっている。

hero.component.ts
export class HeroesComponent implements OnInit {

  heroes$ = this.store.select(selectCurrentHeroes);
  HeroType=HeroType;

  constructor(private store: Store, 
    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.store.dispatch(HeroActions.addHero({ hero }));
  }

  delete(hero:Hero):void{
    this.store.dispatch(HeroActions.deleteHero({ id:hero.id }));
  }
}

おわりに

Commandパターン、Mementoパターン、自前のローカルストア、NgRXと複数の技術を見てきた。初回にも述べたがUndoRedo機能はポンと簡単に追加で載せられる機能ではなく、取り入れるには対象のアプリケーションへの深い知識が必要になる。なぜならUndoRedo機能のコンセプトは大体同じになるが実装方法は各種アプリケーションの要件に左右されるからだ。そのため自分のアプリケーション用にサンプルアプリケーションを実装して問題点、課題を明確にするのが機能実現には一番良いと思う。

Discussion