この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。
前記事:Angularのngrxを使って状態管理を行う(実装編:初期設定~エフェクト設定)
次記事:WEBアプリでFirebaseのデプロイ環境を構築する
この記事で行うこと
前回の記事ではngrxの状態管理の実装(初期設定~エフェクト設定)を扱いました。
本記事ではngrxの実装方法(機能ストア~エンティティ設定)について学習します。
機能ストアとは
公式ドキュメント内で機能ストアはStoreModule.forFeature
として記載されています。
ルートストア(StoreModule.forRoot
)がアプリケーション全体で使用できる一方、機能ストアは機能モジュール単位で利用できるストアになります。
Angularでは機能モジュール単位でLazy Loadingを実行しているため、この設定が抜けるとデータの過剰および不足が発生し、エラーの温床となります。
機能ストアで管理するステートは、ルートストアで作成したステートツリーにぶら下がる形で構成され、『アプリケーション全体で1つのオブジェクト』というReduxの理念を維持しています。
ルートモジュールで管理しているコンポーネントから機能ストアのステートにアクセスすることもできますが、その機能ストアが登録されている機能モジュールが起動するまでundefindedになるので注意してください。
(追記:2020/6)現時点(2020年6月)での最新の内容に書き換えています。
実装内容
機能ストアの設置
機能モジュールを設定する
まず、機能ストアを登録するための機能モジュールと、Entityを扱うためのライブラリを追加します。
以前の記事と同様、moduleコマンドを使って初期配置をし、諸々の設定を行います。
ng g module chat --routing
npm install @ngrx/entity --save
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({
declarations: [
ChatComponent, // 追加
],
imports: [
// CommonModule, // 削除
SharedModule, // 追加
ChatRoutingModule,
],
})
export class ChatModule { }
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
// import { ChatComponent } from './chat/chat.component'; 削除
import { PageNotFoundComponent } from './error/page-not-found/page-not-found.component';
import { AccountModule } from './account/account.module';
import { AuthGuard } from './guard/auth.guard';
import { LoginGuard } from './guard/login.guard';
const routes: Routes = [
{
path: 'account',
loadChildren: () => import('./account/account.module').then(m => m.AccountModule),
canActivate: [LoginGuard],
},
{
path: '',
loadChildren: () => import('./chat/chat.module').then(m => m.ChatModule), // 変更
canActivate: [AuthGuard],
},
{
path: '**',
component: PageNotFoundComponent,
},
];
import { AppComponent } from './app.component';
// import { ChatComponent } from './chat/chat.component'; // 削除
import { HeaderComponent } from './header/header.component';
import { PageNotFoundComponent } from './error/page-not-found/page-not-found.component';
import { AppStoreModule } from './app-store/app-store.module';
@NgModule({
declarations: [
AppComponent,
// ChatComponent, // 削除
HeaderComponent,
PageNotFoundComponent,
],
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
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
? Do you want to use the create function? Yes
// 機能エフェクトの追加
ng g effect chat/store/Chat --module chat/chat.module.ts
? Should we wire up success and failure actions? Yes
? Do you want to use the create function? Yes
import { NgModule } from '@angular/core';
import { ChatRoutingModule } from './chat-routing.module';
import { SharedModule } from '../shared/shared.module';
import { ChatComponent } from './chat.component';
import { StoreModule } from '@ngrx/store';
import * as fromChat from './store/chat.reducer';
import { EffectsModule } from '@ngrx/effects';
import { ChatEffects } from './store/chat.effects';
@NgModule({
declarations: [
ChatComponent,
],
imports: [
SharedModule,
ChatRoutingModule,
StoreModule.forFeature(fromChat.chatsFeatureKey, fromChat.reducer),
EffectsModule.forFeature([ChatEffects]),
],
})
これで機能ストアの設置は完了です。
次は自動作成されたAction、Reducer、Effectの内容を確認し、実装を行います。
Entityを用いたストア設計
Actionの設計
まずは自動作成されたActionの内容を確認します。
Action名 | 概要 |
---|---|
loadChats | データ(複数)の読み込み |
addChat | データ(個別)の追加 |
upsertChat | データ(個別)があれば更新、なければ追加 |
addChats | データ(複数)の追加 |
upsertChats | データ(複数)があれば更新、なければ追加 |
updateChat | データ(個別)の更新 |
updateChats | データ(複数)の更新 |
deleteChat | データ(個別)の削除 |
deleteChats | データ(複数)の削除 |
clearChats | 全データのクリア |
本アプリケーションに必要なAction以外を削除し、また非同期処理成功、失敗時のActionを追加します。
Action名 | 起動時 | 概要 |
---|---|---|
loadChats | チャット画面訪問時 | データ(複数)の読み込み |
loadChatsSuccess | LoadChatsが成功 | - |
loadChatsFail | LoadChatsが失敗 | - |
addChat | Sendボタンクリック時 | データ(個別)の追加 |
updateChat | 編集で保存ボタンクリック時 | データ(個別)の更新 |
deleteChat | 削除ボタンクリック時 | データ(個別)の削除 |
writeChatSuccess | 書き込み処理が成功 | - |
writeChatChatFail | 書き込み処理が失敗 | - |
これをもとに、Actionファイルを更新します。
なお、自動でchat.model.ts
というファイルが作成され、Chat
をインポートしていますが、ここではすでに作成済のComment
クラスに差し替えます。
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;
}
}
import { createAction, props } from '@ngrx/store';
import { Update } from '@ngrx/entity';
import { Comment } from '../../class/chat';
// import { Chat } from './chat.model'; // 削除
export const loadChats = createAction(
'[Chat/API] Load Chats',
(payload: { chats: Comment[] }) => ({ payload }),
);
export const loadChatsSuccess = createAction(
'[Chat/API] Load Chats Success',
(payload: { chats: Comment[] }) => ({ payload }),
);
export const loadChatsFail = createAction(
'[Chat/API] Load Chats Fail',
(payload?: { error: any }) => ({ payload }),
);
export const addChat = createAction(
'[Chat/API] Add Chat',
(payload: { chat: Comment }) => ({ payload }),
);
export const updateChat = createAction(
'[Chat/API] Update Chat',
(payload: { chat: Update<Comment> }) => ({ payload }),
);
export const deleteChat = createAction(
'[Chat/API] Delete Chat',
(payload: { id: string }) => ({ payload }),
);
export const writeChatSuccess = createAction(
'[Chat/API] Write Chat Success',
(payload?: { chats: Comment[] }) => ({ payload }),
);
export const writeChatChatFail = createAction(
'[Chat/API] Write Chat Fail',
(payload?: { error: any }) => ({ payload }),
);
LoadとWriteの非同期処理の結果を分けていますが、これはLoadでObservableを使用しているためです。Add、Update、Delete時にはその結果がLoadからも流れてくることになるので、処理をわけるようにしています。
また、UpdateChatアクションではUpdate<Comment>
というクラスを使用しています。このクラスはidと変更後のデータ(changes)をプロパティとして持っているので、Update時にはそれらを指定する必要があります。
続いてReducerの実装を行います。
import { createReducer, on, createFeatureSelector, createSelector } from '@ngrx/store';
import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
import * as ChatActions from './chat.actions';
import { Comment } from '../../class/chat';
export const chatsFeatureKey = 'chats';
export interface State extends EntityState<Comment> {
loading: boolean;
}
export const adapter: EntityAdapter<Comment> = createEntityAdapter<Comment>();
export const initialState: State = adapter.getInitialState({
loading: false,
});
export const reducer = createReducer(
initialState,
on(ChatActions.addChat, (state) => {
return { ...state, loading: true };
}),
on(ChatActions.updateChat, (state, action) => {
return adapter.updateOne(action.payload.chat, { ...state, loading: true });
}
),
on(ChatActions.deleteChat, (state, action) => {
return adapter.removeOne(action.payload.id, { ...state, loading: true });
}),
on(ChatActions.loadChats, (state) => {
return { ...state, loading: true };
}),
on(ChatActions.loadChatsSuccess, (state, action) => {
return adapter.upsertMany(action.payload.chats, { ...state, loading: false });
}),
on(ChatActions.loadChatsFail, (state) => {
return { ...state, loading: false };
}),
on(ChatActions.writeChatSuccess, (state) => {
return { ...state, loading: false };
}),
on(ChatActions.writeChatFail, (state) => {
return { ...state, loading: false };
}),
);
const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();
export const selectChat = createFeatureSelector<State>('chats');
export const getChatLoading = createSelector(selectChat, state => state.loading);
export const selectAllChats = createSelector(selectChat, selectAll);
少し分解しながら説明します。
export interface State extends EntityState<Comment> {
loading: boolean;
}
このState はEntityState<Comment>
のids: string[] | number[]
、entities: {[id: string]: Comment}
というプロパティを継承しています。ここではAPI接続時のローディング用ステート(loading)を追加しています。
on(ChatActions.addChat, (state) => {
return { ...state, loading: true };
}),
on(ChatActions.updateChat, (state, action) => {
return adapter.updateOne(action.payload.chat, { ...state, loading: true });
}
),
on(ChatActions.deleteChat, (state, action) => {
return adapter.removeOne(action.payload.id, { ...state, loading: true });
}),
on(ChatActions.loadChats, (state) => {
return { ...state, loading: true };
}),
CLUD処理の開始時に、ローディングが始まるようにしています(loading: true
)。adapter.updateOne
は指定されたidのエンティティを更新し、chatAdapter.removeOne
は指定されたidのエンティティを削除します。
adapter: EntityAdapter<Comment>
はこれ以外にもaddAll
(すべて追加)やremoveAll
(すべて削除)といったメソッドを持っています。
on(ChatActions.loadChatsSuccess, (state, action) => {
return adapter.upsertMany(action.payload.chats, { ...state, loading: true });
}),
on(ChatActions.loadChatsFail, (state) => {
return { ...state, loading: false };
}),
on(ChatActions.writeChatSuccess, (state) => {
return { ...state, loading: false };
}),
on(ChatActions.writeChatFail, (state) => {
return { ...state, loading: false };
}),
LoadChatSuccess
はLoad成功時に取得したデータをStateに反映させます。取得したエンティティがすでにある場合は更新、ない場合は追加をします。また、非同期処置が完了したので、ローディングを終了させます(loading: false
)。
const { selectIds, selectEntities, selectAll, selectTotal } = adapter.getSelectors();
export const selectChat = createFeatureSelector<State>('chats');
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 |
エンティティの総数を取得 |
これでReducerの実装は完了です。最後にEffectの実装を行います。
import { Injectable } from '@angular/core';
import { AngularFirestore } from '@angular/fire/firestore';
import { Actions, createEffect, ofType } from '@ngrx/effects';
import { Observable, of } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { Update } from '@ngrx/entity';
import { Comment} from '../../class/chat';
import * as ChatActions from './chat.actions';
@Injectable()
export class ChatEffects {
constructor(private actions$: Actions,
private db: AngularFirestore) {
}
addChat$ = createEffect(() =>
this.actions$.pipe(
ofType(ChatActions.addChat),
map(action => action.payload.chat),
switchMap((comment: any) => {
return this.db
.collection('comments')
.add(comment)
.then(() => ChatActions.writeChatSuccess())
.catch(() => ChatActions.writeChatFail({ error: 'failed to add' }));
}),
));
updateChat$ = createEffect(() =>
this.actions$.pipe(
ofType(ChatActions.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 ChatActions.writeChatSuccess();
})
.catch(() => ChatActions.writeChatFail({ error: 'failed to update' }));
}),
));
deleteChat$ = createEffect(() =>
this.actions$.pipe(
ofType(ChatActions.deleteChat),
map(action => action.payload.id),
switchMap((id: string) => {
return this.db
.collection('comments')
.doc(id)
.delete()
.then(() => {
alert('コメントを削除しました');
return ChatActions.writeChatSuccess();
})
.catch(() => ChatActions.writeChatFail({ error: 'failed to delete' }));
}),
));
loadChats$ = createEffect(() =>
this.actions$.pipe(
ofType(ChatActions.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 commentData = new Comment(data.user, data.content);
commentData.setData(data.date, key);
return commentData;
})),
map((result: Comment[]) => {
return ChatActions.loadChatsSuccess({
chats: result
});
}),
catchError(this.handleChatsError(
'fetchChats', ChatActions.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に反映させます。
import { Component, OnInit } from '@angular/core';
import { Comment, User } from '../class/chat';
// import { AngularFirestore } from '@angular/fire/firestore'; // 削除
// import { Observable } from 'rxjs'; // 削除
// import { map } from 'rxjs/operators'; // 削除
import { Store } from '@ngrx/store';
import * as fromSession from '../app-store/reducers';
import * as fromChat from './store/chat.reducer'; // 追加
import * as ChatActions 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: Comment[] = []; // 変更
public currentUser: User;
// DI(依存性注入する機能を指定)
constructor(private chat: Store<fromChat.State>, // 追加
// private db: AngularFirestore, // 削除
private store: Store<fromSession.State>) {
this.store
.select(fromSession.getSession)
.subscribe(data => {
this.currentUser = data.user;
});
this.chat.select(fromChat.selectAllChats)
.subscribe((comments: Comment[]) => {
this.comments = [];
comments.forEach((comment: Comment) => {
this.comments.push(
new Comment(comment.user, comment.content)
.setData(comment.date, comment.id)
);
});
}); // 追加
}
ngOnInit() { // 変更
this.store.dispatch(ChatActions.loadChats({ chats: [] }));
}
// 新しいコメントを追加
addComment(e: Event, content: string) { // 変更
e.preventDefault();
if (content) {
const tmpComment = new Comment(this.currentUser, content).deserialize();
this.chat.dispatch(ChatActions.addChat({ chat: tmpComment }));
this.content = '';
}
}
// 編集フィールドの切り替え
toggleEditComment(comment: Comment) { // 変更
comment.editFlag = !comment.editFlag;
}
// コメントを更新する
saveEditComment(comment: Comment) { // 変更
comment.editFlag = false;
this.chat.dispatch(ChatActions.updateChat({ chat: { id: comment.id, changes: comment } }));
}
// コメントをリセットする
resetEditComment(comment: Comment) { // 変更
comment.content = '';
}
// コメントを削除する
deleteComment(key: string) { // 変更
this.chat.dispatch(ChatActions.deleteChat({ id: key }));
}
}
機能ストアにdispatchを行い、Effectに移行した分を削除しました。
storeにあるデータはreadOnlyであるため、一時的なデータ利用はシャローコピーを行なって対応しています。
自分以外のコメントは編集・削除ができないようにHTMLも修正します。
<!-- 自分のuidのときのみ、編集領域を表示 -->
<ng-container *ngIf="comment.user.uid === currentUser.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>
次に、ヘッダーコンポーネントからも機能ストアをDIして、ローディングを反映させます。
なお、コアモジュールで管理しているコンポーネントに読み込ませる場合、ChatModule
にアクセスするまで機能ストアはundefinedになるので注意が必要です。
import { Component, OnInit } from '@angular/core';
import { SessionService } from '../service/session.service';
import { Session } from '../class/chat';
import { Observable } from 'rxjs';
import { Store } from '@ngrx/store';
import * as fromSession from '../app-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<fromSession.State>,
private chat: Store<fromChat.State>) { // 追加
this.loadingSession$ = this.store.select(fromSession.getLoading); // 変更
this.loadingChat$ = this.chat.select(fromChat.getChatLoading); // 追加
this.session$ = this.store.select(fromSession.getSession);
}
ngOnInit() {
}
logout(): void {
this.sessionService.logout();
}
}
<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を入れてください。