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
sessionstateはアプリケーション起動時に取得される一方、chatstateは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を入れてください。
コメント
コメントを投稿