LoginSignup
20
23

More than 3 years have passed since last update.

Angularのngrxを使って状態管理を行う(実装編:エンティティ設定)

Last updated at Posted at 2018-10-30

この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。
前記事:Angularのngrxを使って状態管理を行う(実装編:初期設定~エフェクト設定)
次記事:WEBアプリでFirebaseのデプロイ環境を構築する

この記事で行うこと

前回の記事ではngrxの状態管理の実装(初期設定~エフェクト設定)を扱いました。
本記事ではngrxの実装方法(機能ストア~エンティティ設定)について学習します。

機能ストアとは

公式ドキュメント内で機能ストアはStoreModule.forFeatureとして記載されています。
ルートストア(StoreModule.forRoot)がアプリケーション全体で使用できる一方、機能ストアは機能モジュール単位で利用できるストアになります。

Angularでは機能モジュール単位でLazy Loadingを実行しているため、この設定が抜けるとデータの過剰および不足が発生し、エラーの温床となります。

機能ストアで管理するステートは、ルートストアで作成したステートツリーにぶら下がる形で構成され、『アプリケーション全体で1つのオブジェクト』というReduxの理念を維持しています。

Redux図 (6).png

ルートモジュールで管理しているコンポーネントから機能ストアのステートにアクセスすることもできますが、その機能ストアが登録されている機能モジュールが起動するまでundefindedになるので注意してください。


(追記:2020/6)現時点(2020年6月)での最新の内容に書き換えています。


実装内容

機能ストアの設置

機能モジュールを設定する

まず、機能ストアを登録するための機能モジュールと、Entityを扱うためのライブラリを追加します。
以前の記事と同様、moduleコマンドを使って初期配置をし、諸々の設定を行います。

ng g module chat --routing
npm install @ngrx/entity --save
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({
  declarations: [
    ChatComponent, // 追加
  ],
  imports: [
    // CommonModule, // 削除
    SharedModule, // 追加
    ChatRoutingModule,
  ],
})
export class ChatModule { }
app/app-routing.module.ts
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,
  },
];
app.module.ts
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,
  ],
app/chat/chat-routing.module.ts
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

sessionstateはアプリケーション起動時に取得される一方、chatstateは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
app/chat/chat.module.ts
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クラスに差し替えます。

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 { 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の実装を行います。

chat/store/chat.reducer.ts
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の実装を行います。

app/chat/effects.ts
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に反映させます。

chat/chat.component.ts
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も修正します。

app/chat/chat.component.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になるので注意が必要です。

app/header/header.component.ts
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();
  }

}
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>

実行結果

Oct-26-2018 17-46-31.gif

これでngrxの実装は完了です。次回からはAngularとFirebaseの本番環境構築について書いていきます。

ソースコード

この時点でのソースコード
※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。

20
23
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
23