Undo Redo機能をAngularで実装する 3
AngularでUndoRedo機能を解説するシリーズの3回目。今回はNgRXを使用してのMementoパターンを解説する。
はじめに
前回はNgRXの使い方と自前のローカルストアとの相違点を中心に解説した。今回はMementoパターンを使うための前提や変更点を解説する。
今回のソースコードは以下。
Mementoパターン
Mementoパターンは変更処理のたびに変更前のステート全体をとっておくことによってUndoRedoを実現する。UndoまたはRedo処理時には保持されているステート全体を随時入れ替えることで対応する。ステートをすべて入れ替える必要性から随時永続化を行っているTour of Heroesのようなアプリケーションでは都度ステート全体を永続化する処理が必要だ。
HeroServiceの変更点
追加、更新、削除は個別の関数ではなくsave関数でdelete/insertを行う。
※注 バックエンドにInMemoryDbServiceを使用しているので疑似的にdelete/insertを実装している。resetDbをPOSTすることでDBをクリアし各Heroを随時追加している。
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の処理は後述)。
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を分けている。
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が追加されている。
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)を使用している。
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が追加されている。
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するだけとなっている。
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