Angularのngrxを使って状態管理を行う(実装編:エンティティ設定)
Angularのngrxを使って状態管理を行う(実装編:エンティティ設定):
前回の記事ではngrxの状態管理の実装(初期設定~エフェクト設定)を扱いました。
本記事ではngrxの実装方法(機能ストア~エンティティ設定)について学習します。
公式ドキュメント内で機能ストアは
ルートストア(
Angularでは機能モジュール単位でLazy Loadingを実行しているため、この設定が抜けるとデータの過剰および不足が発生し、エラーの温床となります。
機能ストアで管理するステートは、ルートストアで作成したステートツリーにぶら下がる形で構成され、『アプリケーション全体で1つのオブジェクト』というReduxの理念を維持しています。
コアモジュールで管理しているコンポーネントから機能ストアのステートにアクセスすることもできますが、その機能ストアが登録されている機能モジュールが起動するまでundefindedになるので注意してください。
まず、機能ストアを登録するための機能モジュールを追加します。
以前の記事と同様、moduleコマンドを使って初期配置をし、諸々の設定を行います。
機能ストアを設置する前に、どのようなステートツリーになるかを確認します。
これで機能ストアの設置は完了です。
次は自動作成されたAction、Reducer、Effectの内容を確認し、実装を行います。
まずは自動作成されたActionのの内容を確認します。
本アプリケーションに必要なAction以外を削除し、また非同期処理成功、失敗時のActionを追加します。
これをもとに、Actionファイルを更新します。
なお、自動で
LoadとWriteの非同期処理の結果を分けていますが、これはLoadでObservableを使用しているためです。Add、Update、Delete時にはその結果がLoadからも流れてくることになるので、処理をわけるようにしています。
また、UpdateChatアクションでは
続いてReducerの実装を行います。
少し分解しながら説明します。
このState は
CLUD処理の開始時に、ローディングが始まるようにしています(
これでReducerの実装は完了です。最後にEffectの実装を行います。
Reducerで設定したStateを、Viewに反映させます。
機能ストアにdispatchを行い、Effectに移行した分を削除しました。
commentsには
自分以外のコメントは編集・削除ができないようにHTMLも修正します。
次に、ヘッダーコンポーネントからも機能ストアをDIして、ローディングを反映させます。
なお、コアモジュールで管理しているコンポーネントに読み込ませる場合、
これでngrxの実装は完了です。次回からはAngularとFirebaseの本番環境構築について書いていきます。
この時点でのソースコード
※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。
この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。
前記事:Angularのngrxを使って状態管理を行う(実装編:初期設定~エフェクト設定)
次記事:準備中
この記事で行うこと
前回の記事ではngrxの状態管理の実装(初期設定~エフェクト設定)を扱いました。本記事ではngrxの実装方法(機能ストア~エンティティ設定)について学習します。
機能ストアとは
公式ドキュメント内で機能ストアはStoreModule.forFeature
として記載されています。ルートストア(
StoreModule.forRoot
)がアプリケーション全体で使用できる一方、機能ストアは機能モジュール単位で利用できるストアになります。Angularでは機能モジュール単位でLazy Loadingを実行しているため、この設定が抜けるとデータの過剰および不足が発生し、エラーの温床となります。
機能ストアで管理するステートは、ルートストアで作成したステートツリーにぶら下がる形で構成され、『アプリケーション全体で1つのオブジェクト』というReduxの理念を維持しています。
コアモジュールで管理しているコンポーネントから機能ストアのステートにアクセスすることもできますが、その機能ストアが登録されている機能モジュールが起動するまでundefindedになるので注意してください。
実装内容
機能ストアの設置
機能モジュールを設定する
まず、機能ストアを登録するための機能モジュールを追加します。以前の記事と同様、moduleコマンドを使って初期配置をし、諸々の設定を行います。
ng g module chat --routing
app/chat/chat.module.ts
import { NgModule } from '@angular/core'; // import { CommonModule } from '@angular/common'; // 削除 import { ChatRoutingModule } from './chat-routing.module'; import { SharedModule } from '../shared/shared.module'; // 追加 import { ChatComponent } from './chat.component'; // 追加 @NgModule({ imports: [ // CommonModule, // 削除 SharedModule, // 追加 ChatRoutingModule, ], declarations: [ ChatComponent, // 追加 ] }) export class ChatModule { }
app/app.module.ts
const appRoutes: Routes = [ { path: 'account', loadChildren: './account/account.module#AccountModule', canActivate: [LoginGuard], }, { path: '', loadChildren: './chat/chat.module#ChatModule', // 変更 canActivate: [AuthGuard], }, { path: '**', component: PageNotFoundComponent }, ]; @NgModule({ declarations: [ AppComponent, // ChatComponent, // 削除 PageNotFoundComponent ], imports: [ NgbModule.forRoot(), BrowserModule, RouterModule.forRoot(appRoutes), CoreModule, SharedModule, AngularFireModule.initializeApp(environment.firebase), AngularFirestoreModule, AngularFireAuthModule, ], providers: [], bootstrap: [AppComponent] })
app/chat/chat-routing.module.ts
import { ChatComponent } from './chat.component'; // 追加 const routes: Routes = [ { path: '', component: ChatComponent }, // 追加 ];
機能ストアを設置する
機能ストアを設置する前に、どのようなステートツリーになるかを確認します。state ├── session │ ├── session │ └── loading └── chat ├── ids ├── entities └── loading
session
stateはアプリケーション起動時に取得される一方、chat
stateはChatModule
ダウンロード時に取得が開始されます。この前提をもとに、ジェネレータを使って機能ストアと機能エフェクトを設置します。ng g entitiy EntityName
を使うと、ID付きのオブジェクトを扱うAction、Reducerのテンプレートを自動生成し、指定したモジュールにそのStateを登録してくれます。// 機能ストアの追加 ng g entity chat/store/Chat --module ../chat.module.ts // 機能エフェクトの追加 ng g effect chat/store/Chat --module chat/chat.module.ts
app/chat/chat.module.ts
import { NgModule } from '@angular/core'; import { StoreModule } from '@ngrx/store'; import { EffectsModule } from '@ngrx/effects'; import { ChatRoutingModule } from './chat-routing.module'; import { SharedModule } from '../shared/shared.module'; import { ChatComponent } from './chat.component'; import { ChatEffects } from './store/chat.effects'; import * as fromChat from './store/chat.reducer'; @NgModule({ imports: [ SharedModule, ChatRoutingModule, StoreModule.forFeature('chat', fromChat.reducer), EffectsModule.forFeature([ChatEffects]), ],
次は自動作成されたAction、Reducer、Effectの内容を確認し、実装を行います。
Entityを用いたストア設計
Actionの設計
まずは自動作成されたActionのの内容を確認します。Action名 | 概要 |
---|---|
LoadChats | データ(複数)の読み込み |
AddChat | データ(個別)の追加 |
UpsertChat | データ(個別)があれば更新、なければ追加 |
AddChats | データ(複数)の追加 |
UpsertChats | データ(複数)があれば更新、なければ追加 |
UpdateChat | データ(個別)の更新 |
UpdateChats | データ(複数)の更新 |
DeleteChat | データ(個別)の削除 |
DeleteChats | データ(複数)の削除 |
ClearChats | 全データのクリア |
Action名 | 起動時 | 概要 |
---|---|---|
LoadChats | チャット画面訪問時 | データ(複数)の読み込み |
LoadChatsSuccess | LoadChatsが成功 | - |
LoadChatsFail | LoadChatsが失敗 | - |
AddChat | Sendボタンクリック時 | データ(個別)の追加 |
UpdateChat | 編集で保存ボタンクリック時 | データ(個別)の更新 |
DeleteChat | 削除ボタンクリック時 | データ(個別)の削除 |
WriteChatSuccess | 書き込み処理が成功 | - |
WriteChatChatFail | 書き込み処理が失敗 | - |
なお、自動で
chat.model.ts
というファイルが作成され、Chat
をインポートしていますが、ここではすでに作成済のComment
クラスに差し替えます。class/chat.ts
export class Comment { user: User; initial: string; content: string; date: number; id?: string; // 変更 edit_flag?: boolean; constructor(user: User, content: string) { this.user = user; this.initial = user.name.slice(0, 1); this.content = content; this.date = +moment(); } deserialize() { this.user = this.user.deserialize(); return Object.assign({}, this); } // 取得した日付を反映し、更新フラグをつける setData(date: number, key: string): Comment { this.date = date; this.id = key; // 変更 this.edit_flag = false; return this; } }
chat/store/chat.actions.ts
import { Action } from '@ngrx/store'; import { Update } from '@ngrx/entity'; import { Comment } from '../../class/chat'; export enum ChatActionTypes { LoadChats = '[Chat] Load Chats', LoadChatSuccess = '[Chat] Load Chats Success', LoadChatsFail = '[Chat] Load Chats Fail', AddChat = '[Chat] Add Chat', UpdateChat = '[Chat] Update Chat', DeleteChat = '[Chat] Delete Chat', WriteChatSuccess = '[Chat] Write Chat Success', WriteChatChatFail = '[Chat] Write Chat Fail' } export class LoadChats implements Action { readonly type = ChatActionTypes.LoadChats; constructor(public payload: { chats: Comment[] }) {} } export class LoadChatsSuccess implements Action { readonly type = ChatActionTypes.LoadChatSuccess; constructor(public payload: { chats: Comment[] }) {} } export class LoadChatsFail implements Action { readonly type = ChatActionTypes.LoadChatsFail; constructor(public payload?: { error: any }) {} } export class AddChat implements Action { readonly type = ChatActionTypes.AddChat; constructor(public payload: { chat: Comment }) {} } export class UpdateChat implements Action { readonly type = ChatActionTypes.UpdateChat; constructor(public payload: { chat: Update<Comment> }) {} } export class DeleteChat implements Action { readonly type = ChatActionTypes.DeleteChat; constructor(public payload: { id: string }) {} } export class WriteChatSuccess implements Action { readonly type = ChatActionTypes.WriteChatSuccess; constructor(public payload?: { chats: Comment[] }) {} } export class WriteChatChatFail implements Action { readonly type = ChatActionTypes.WriteChatChatFail; constructor(public payload?: { error: any }) {} } export type ChatActions = LoadChats | LoadChatsSuccess | LoadChatsFail | AddChat | UpdateChat | DeleteChat | WriteChatSuccess | WriteChatChatFail;
また、UpdateChatアクションでは
Update<Comment>
というクラスを使用しています。このクラスはidと変更後のデータ(changes)をプロパティとして持っているので、Update時にはそれらを指定する必要があります。続いてReducerの実装を行います。
chat/store/chat.reducer.ts
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; import { createFeatureSelector, createSelector } from '@ngrx/store'; import { ChatActions, ChatActionTypes } from './chat.actions'; import { Comment } from '../../class/chat'; export interface State extends EntityState<Comment> { loading: boolean; } export const adapter: EntityAdapter<Comment> = createEntityAdapter<Comment>(); export const initialState: State = adapter.getInitialState({ loading: false, }); export function reducer( state = initialState, action: ChatActions ): State { switch (action.type) { case ChatActionTypes.AddChat: { return { ...state, loading: true }; } case ChatActionTypes.UpdateChat: { return { ...adapter.updateOne(action.payload.chat, state), loading: true }; } case ChatActionTypes.DeleteChat: { return { ...adapter.removeOne(action.payload.id, state), loading: true }; } case ChatActionTypes.LoadChats: { return { ...state, loading: true }; } case ChatActionTypes.LoadChatSuccess: { return { ...adapter.upsertMany(action.payload.chats, state), loading: false }; } case ChatActionTypes.LoadChatsFail: { return { ...state, loading: false }; } case ChatActionTypes.WriteChatSuccess: { return { ...state, loading: false }; } case ChatActionTypes.WriteChatChatFail: { return { ...state, loading: false }; } default: { return state; } } } const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors(); export const selectChat = createFeatureSelector<State>('chat'); export const getChatLoading = createSelector(selectChat, state => state.loading); export const selectAllChats = createSelector(selectChat, selectAll);
export interface State extends EntityState<Comment> { loading: boolean; }
EntityState<Comment>
のids: string[] | number[]
、entities: {[id: string]: Comment}
というプロパティを継承しています。ここではAPI接続時のローディング用ステート(loading)を追加しています。case ChatActionTypes.AddChat: { return { ...state, loading: true }; } case ChatActionTypes.UpdateChat: { return { ...adapter.updateOne(action.payload.chat, state), loading: true }; } case ChatActionTypes.DeleteChat: { return { ...adapter.removeOne(action.payload.id, state), loading: true }; } case ChatActionTypes.LoadChats: { return { ...state, loading: true }; }
loading: true
)。adapter.updateOne
は指定されたidのエンティティを更新し、chatAdapter.removeOne
は指定されたidのエンティティを削除します。adapter: EntityAdapter<Comment>
はこれ以外にもaddAll
(すべて追加)やremoveAll
(すべて削除)といったメソッドを持っています。case ChatActionTypes.LoadChatSuccess: { return { ...adapter.upsertMany(action.payload.chats, state), loading: false }; } case ChatActionTypes.LoadChatsFail: { return { ...state, loading: false }; } case ChatActionTypes.WriteChatSuccess: { return { ...state, loading: false }; } case ChatActionTypes.WriteChatChatFail: { return { ...state, loading: false }; }
LoadChatSuccess
はLoad成功時に取得したデータをStateに反映させます。取得したエンティティがすでにある場合は更新、ない場合は追加をします。また、非同期処置が完了したので、ローディングを終了させます(loading: false
)。const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors(); export const selectChat = createFeatureSelector<State>('chat'); export const getChatLoading = createSelector(selectChat, state => state.loading); export const selectAllChats = createSelector(selectChat, selectAll);
adapter: EntityAdapter<Comment>
から基本となるセレクタを取得しています。それぞれのセレクタからは、下記のデータが取得できます。メソッド名 | 取得データ | 概要 |
---|---|---|
selectIds | string[] or number[] |
idの一覧を取得 |
selectEntities | {[id: string]: Comment} |
エンティティ(オブジェクト)の一覧を取得 |
selectAll | Comment[] |
エンティティ(配列)の一覧を取得 |
selectTotal | number |
エンティティの総数を取得 |
app/chat/effects.ts
import { Injectable } from '@angular/core'; import { AngularFirestore } from '@angular/fire/firestore'; import { Actions, Effect, ofType } from '@ngrx/effects'; import { Observable, of } from 'rxjs'; import { Action } from '@ngrx/store'; import { catchError, map, switchMap } from 'rxjs/operators'; import { Update } from '@ngrx/entity'; import { Comment } from '../../class/chat'; import { AddChat, ChatActionTypes, DeleteChat, LoadChats, LoadChatsFail, LoadChatsSuccess, UpdateChat, WriteChatChatFail, WriteChatSuccess, } from './chat.actions'; @Injectable() export class ChatEffects { constructor(private actions$: Actions, private db: AngularFirestore) { } @Effect() addChat$: Observable<Action> = this.actions$.pipe( ofType<AddChat>(ChatActionTypes.AddChat), map(action => action.payload.chat), switchMap((comment: Comment) => { return this.db .collection('comments') .add(comment.deserialize()) .then(() => new WriteChatSuccess()) .catch(() => new WriteChatChatFail({ error: 'failed to add' })); }) ); @Effect() updateChat$: Observable<Action> = this.actions$.pipe( ofType<UpdateChat>(ChatActionTypes.UpdateChat), map(action => action.payload.chat), switchMap((comment: Update<Comment>) => { return this.db .collection('comments') .doc(comment.id.toString()) .update({ content: comment.changes.content, date: comment.changes.date }) .then(() => { alert('コメントを更新しました'); return new WriteChatSuccess(); }) .catch(() => new WriteChatChatFail({ error: 'failed to update' })); }) ); @Effect() deleteChat$: Observable<Action> = this.actions$.pipe( ofType<DeleteChat>(ChatActionTypes.DeleteChat), map(action => action.payload.id), switchMap((id: string) => { return this.db .collection('comments') .doc(id) .delete() .then(() => { alert('コメントを削除しました'); return new WriteChatSuccess(); }) .catch(() => new WriteChatChatFail({ error: 'failed to delete' })); }) ); @Effect() loadChats$: Observable<Action> = this.actions$.pipe( ofType<LoadChats>(ChatActionTypes.LoadChats), map(action => action.payload.chats), switchMap(() => { return this.db.collection<Comment>('comments', ref => { return ref.orderBy('date', 'asc'); }).snapshotChanges() .pipe( map(actions => actions.map(action => { // 日付をセットしたコメントを返す const data = action.payload.doc.data() as Comment; const key = action.payload.doc.id; const comment_data = new Comment(data.user, data.content); comment_data.setData(data.date, key); return comment_data; })), map((result: Comment[]) => { return new LoadChatsSuccess({ chats: result }); }), catchError(this.handleChatsError( 'fetchChats', new LoadChatsFail() )) ); }) ); // エラー発生時の処理 private handleChatsError<T>(operation = 'operation', result: T) { return (error: any): Observable<T> => { // 失敗した操作の名前、エラーログをconsoleに出力 console.error(`${operation} failed: ${error.message}`); // 結果を返して、アプリを持続可能にする return of(result as T); }; } }
chat.component.ts
内で行っていた処理を、こちらに移行しています。SessionEffect
と同様、ofType
でトリガーとなるActionを指定し、処理結果を新しいActionに渡しています。
View(コンポーネント)にStateを反映
Reducerで設定したStateを、Viewに反映させます。chat/chat.component.ts
import { Component, OnInit } from '@angular/core'; import { Observable } from 'rxjs'; // import { AngularFirestore } from '@angular/fire/firestore'; // 削除 // import { map } from 'rxjs/operators'; // 削除 import { Comment, User } from '../class/chat'; import { Store } from '@ngrx/store'; import * as fromCore from '../core/store/reducers'; import * as fromChat from './store/chat.reducer'; // 追加 import { AddChat, DeleteChat, LoadChats, UpdateChat } from './store/chat.actions'; // 追加 @Component({ selector: 'app-chat', templateUrl: './chat.component.html', styleUrls: ['./chat.component.css'] }) export class ChatComponent implements OnInit { public content = ''; public comments: Observable<Comment[]>; public current_user: User; // DI(依存性注入する機能を指定) constructor(private chat: Store<fromChat.State>, // 追加 // private db: AngularFirestore, // 削除 private store: Store<fromCore.State>) { this.store.select(fromCore.getSession) .subscribe(data => { this.current_user = data.user; }); this.comments = this.chat.select(fromChat.selectAllChats); // 追加 } ngOnInit() { // 変更 this.store.dispatch(new LoadChats({ chats: [] })); } // 新しいコメントを追加 addComment(e: Event, comment: string) { // 変更 e.preventDefault(); if (comment) { this.chat.dispatch(new AddChat({chat: new Comment(this.current_user, comment)})); this.content = ''; } } // 編集フィールドの切り替え toggleEditComment(comment: Comment) { comment.edit_flag = (!comment.edit_flag); } // コメントを更新する saveEditComment(comment: Comment) { // 変更 comment.edit_flag = false; this.chat.dispatch(new UpdateChat({chat: {id: comment.id, changes: comment}})); } // コメントをリセットする resetEditComment(comment: Comment) { comment.content = ''; } // コメントを削除する deleteComment(key: string) { // 変更 this.chat.dispatch(new DeleteChat({id: key})); } }
commentsには
this.chat.select(fromChat.selectAllChats)
を指定し、返り値であるObservable<Comment[]>
を取得できるようにしています。自分以外のコメントは編集・削除ができないようにHTMLも修正します。
chat/chat.component.html
<!-- 自分のuidのときのみ、編集領域を表示 --> <ng-container *ngIf="comment.user.uid === current_user.uid"><!-- 追加 --> <button class="btn btn-primary btn-sm" (click)="toggleEditComment(comment)">編集</button> <button class="btn btn-danger btn-sm" (click)="deleteComment(comment.id)">削除</button> </ng-container>
なお、コアモジュールで管理しているコンポーネントに読み込ませる場合、
ChatModule
にアクセスするまで機能ストアはundefinedになるので注意が必要です。core/header/header.component.ts
import { SessionService } from '../service/session.service'; import { Session } from '../../class/chat'; import * as fromCore from '../store/reducers'; import * as fromChat from '../../chat/store/chat.reducer'; // 追加 @Component({ selector: 'app-header', templateUrl: './header.component.html', styleUrls: ['./header.component.css'] }) export class HeaderComponent implements OnInit { public loadingSession$: Observable<boolean>; // 変更 public loadingChat$: Observable<boolean>; // 追加 public session$: Observable<Session>; constructor(private sessionService: SessionService, private store: Store<fromCore.State>, private chat: Store<fromChat.State>) { // 追加 this.loadingSession$ = this.store.select(fromCore.getLoading); // 変更 this.loadingChat$ = this.chat.select(fromChat.getChatLoading); // 追加 this.session$ = this.store.select(fromCore.getSession); }
core/header/header.component.html
<div class="progress" style="height: 3px;" *ngIf="(loadingSession$ | async) || (loadingChat$ | async)"><!-- 変更 --> <div class="progress-bar bg-info progress-bar-striped progress-bar-animated" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="width: 100%"></div> </div>
実行結果
これでngrxの実装は完了です。次回からはAngularとFirebaseの本番環境構築について書いていきます。
ソースコード
この時点でのソースコード※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。
コメント
コメントを投稿