Angularのngrxを使って状態管理を行う(実装編:初期設定~エフェクト設定)
Angularのngrxを使って状態管理を行う(実装編:初期設定~エフェクト設定):
前回の記事ではngrxの概念と基本的な構成を扱いました。
本記事ではngrxの初期設定から、エフェクト設定などの具体的な実装方法について学習します。
まず、コードジェネレーターである
このコマンドを実行すると、ジェネレータコマンドの一部を省略することができます。
(例:
参考
@ngrx/schematics
アプリーケーション全体で利用するストア(ルートストア)のステートツリーは、次のような構成にします。
ルートストアはアプリケーションが読み込まれると同時に読み込みを開始するので、コアモジュールに登録します。
また、ここで扱うステートにはセッションなどアプリケーション全体で利用するものが適しているので、今回はセッションとローディングを設定します。
それでは早速、Coreモジュールにストアとエフェクトを作成します。
--root
ルートストアの設定を行います。この指定がない場合、自動的に機能ストア(別記事で詳述)が作成されます。
--statePath
ストアのパスを指定します。※ストア以外は
--module
対象としたいモジュールを指定します。
どのような挙動をするかは実行結果で確認してください。
次に、Action、Reducerを追加します。
--reducers
reducerを加えたいストアを指定します(相対パス)。
これで必要なファイルは整いました。
それではまずStateに変更を加える処理をリストアップし、Actionとして定義していきます。
ここではLoad、Update、Logoutという3つの動作と、それぞれにSuccess、Failという非同期の結果(Side Effect)を加えた計9種類のActionを定義しました。
それぞれのActionに対し、クラスとタイプ、初期値を加えていきます。
続いてReducerの実装に入ります。
Stateの設定が完了したら、reducerにアクション毎の処理を加えます。
非同期処理のトリガーとなるActionには
最後に特定のstateを取得できるようセレクタの設定を行います。
複数のreducerをストアで扱う場合、個別のreducerを
このとき、どのreducerのstateなのかをセレクタで指定し、View(コンポーネント)はそのセレクタを指定することでstateの取得を行います。
今回の実装であれば、まず
これでAction、Reducerの設定は完了しました。
次はEffectの設定を行います。
まずはEffectにサイト訪問時のログイン状況確認用メソッドを加え、Sessionクラスのコンストラクタを更新します。
Effectのメソッドには、
これにより、
その後、Firebaseに認証状況を問合せ、その結果によって認証下情報(users)の問合せに移行します。
これらの問合せが成功した場合には
エラー発生時には
エラー発生が確認できた場合は、その処理名、エラー内容を表示し、ログアウト処理を行っています。
同様にログインクリック時、ログアウト時のエフェクトを追加します。
これでエフェクトの実装は完了です。
これにより、
上記で設定したルートストアのStateを、チャット画面とヘッダーに反映します。
コンポーネントにStateを反映させたいときは、Reducerで設定したセレクタをコンストラクタでDIします。
これでルートストアのStateをチャット画面に反映しました。
続いてヘッダーにStateを反映します。
チャット画面と同様にルートストアをDIし、非同期処理に対応できるようhtmlも更新しました。
最後に、非同期処理時のローディングバーをヘッダーに追加します。
Reducerで指定した
これでルートストアの設定、およびViewへのStateの反映が完了しました。
次は機能ストアの設定、およびエンティティの設定を行います。
この時点でのソースコード
※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。
この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。
前記事:Angularのngrxを使って状態管理を行う(理論編)
次記事:Angularのngrxを使って状態管理を行う(実装編:エンティティ設定)
この記事で行うこと
前回の記事ではngrxの概念と基本的な構成を扱いました。本記事ではngrxの初期設定から、エフェクト設定などの具体的な実装方法について学習します。
実装内容
ngrxの初期設定
ライブラリインストール
まず、コードジェネレーターである@ngrx/schematics
を開発環境に、それ以外のライブラリをプロジェクトにインストールします。npm install @ngrx/schematics --save-dev npm install @ngrx/store @ngrx/effects @ngrx/entity @ngrx/store-devtools --save
AngularとFirebaseの環境構築は過去の記事から行ってください。続いて、
また、この実装はAngular6以上であることが必要ですので注意してください。
@ngrx/schematics
の初期設定を行います。ng config cli.defaultCollection @ngrx/schematics
(例:
ng g @ngrx/schematics:store State
→ ng g store State
)cssの実装をscssで行いたい場合は、angular.json
に次の記述を追加します。
src/angular.json"schematics": { "@ngrx/schematics:component": { "styleext": "scss" } }
@ngrx/schematics
ジェネレータのコマンド一覧
コマンド | 概要 |
---|---|
ng g action ActionName [options] |
Actionファイルの作成 |
ng g effect EffectName [options] |
Effectファイルの作成 |
ng g reducer ReducerName [options] |
Reducerファイルの作成 |
ng g entity EntityName [options] |
Entityを扱う場合のファイル群を作成 |
ng g container ComponentName [options] |
コンポーネント(html\css\spec\ts)の作成 |
ng g feature FeatureName [options] |
機能ストアの初期設定、Actionファイル等の同時作成 |
ng g store State [options] |
ストア(ルート、機能)、Stateの初期設定 |
@ngrx/schematics
ステートツリー設計
アプリーケーション全体で利用するストア(ルートストア)のステートツリーは、次のような構成にします。state └── session ├── session(セレクタ) └── loading(セレクタ)
また、ここで扱うステートにはセッションなどアプリケーション全体で利用するものが適しているので、今回はセッションとローディングを設定します。
それでは早速、Coreモジュールにストアとエフェクトを作成します。
// ルートストアの追加 ng g store State --root --statePath core/store/reducers --module core/core.module.ts // ルートエフェクトの追加 ng g effect core/store/effects/Session --root --module core/core.module.ts
ルートストアの設定を行います。この指定がない場合、自動的に機能ストア(別記事で詳述)が作成されます。
--statePath
ストアのパスを指定します。※ストア以外は
ng g effect パス
のように指定します。--module
対象としたいモジュールを指定します。
@ngrx/store-devtools
が動作するようCoreモジュールにStoreDevtoolsModuleを追加し、reducers/index.ts
にaction、stateのログ機能を実装しておきます。app/core/core.module.ts
@NgModule({ imports: [ CommonModule, RouterModule, StoreModule.forRoot(reducers, { metaReducers }), !environment.production ? StoreDevtoolsModule.instrument() : [], EffectsModule.forRoot([SessionEffects]), StoreDevtoolsModule.instrument({ // 追加 maxAge: 25, // stateの上限を設定 logOnly: environment.production, // 開発環境でのみ動作するよう制限 }), ], exports: [ HeaderComponent, ], declarations: [ HeaderComponent, ] })
app/core/store/reducers/index.ts
export function logger(reducer: ActionReducer<State>) { // 追加 return (state, action) => { const newState = reducer(state, action); console.log('action', action); console.log('state', newState); return newState; }; } export const metaReducers: MetaReducer<State>[] = !environment.production ? [logger] : []; // 変更
@ngrx/store-devtools
@ngrx/store-devtools
はChromeの拡張ツールを使って視覚的にステートツリーの状況確認を行うための開発用ツールです。拡張ツールをダウンロードした後、Chromeの開発者ツールから使用することができます。どのような挙動をするかは実行結果で確認してください。
次に、Action、Reducerを追加します。
// Actionの追加 ng g action core/store/actions/Session // Reducerの追加 ng g reducer core/store/reducers/Session --reducers index.ts
reducerを加えたいストアを指定します(相対パス)。
これで必要なファイルは整いました。
Action、Reducerの実装
それではまずStateに変更を加える処理をリストアップし、Actionとして定義していきます。Action名 | 起動タイミング | 役割 |
---|---|---|
LoadSessions | サイト訪問時 | ログイン有無の問合せ |
LoadSessionsSuccess | LoadSessionsが成功 | ユーザーデータの取得 |
LoadSessionsFail | LoadSessionsが失敗 | - |
LoginSessions | ログインクリック時 | ログイン有無の問合せ |
LoginSessionsSuccess | LoginSessionsが成功 | ユーザーデータの取得 |
LoginSessionsFail | LoginSessionsが失敗 | - |
LogoutSessions | ログアウトクリック時 | ログインの無効化 |
LogoutSessionsSuccess | LogoutSessionsが成功 | ユーザーデータの破棄 |
LogoutSessionsFail | LogoutSessionsが失敗 | - |
それぞれのActionに対し、クラスとタイプ、初期値を加えていきます。
app/core/store/actions/session.actions.ts
import { Action } from '@ngrx/store'; import { Session } from '../../../class/chat'; export enum SessionActionTypes { LoadSessions = '[Session] Load', LoadSessionsSuccess = '[Session] Load Success', LoadSessionsFail = '[Session] Load Fail', LoginSessions = '[Session] Login', LoginSessionsSuccess = '[Session] Login Success', LoginSessionsFail = '[Session] Login Fail', LogoutSessions = '[Session] Logout', LogoutSessionsSuccess = '[Session] Logout Success', LogoutSessionsFail = '[Session] Logout Fail', } export class LoadSessions implements Action { readonly type = SessionActionTypes.LoadSessions; } export class LoadSessionsSuccess implements Action { readonly type = SessionActionTypes.LoadSessionsSuccess; constructor(public payload: { session: Session }) { } } export class LoadSessionsFail implements Action { readonly type = SessionActionTypes.LoadSessionsFail; constructor(public payload?: { error: any }) { } } export class LoginSessions implements Action { readonly type = SessionActionTypes.LoginSessions; constructor(public payload: { email: string, password: string }) { } } export class LoginSessionsSuccess implements Action { readonly type = SessionActionTypes.LoginSessionsSuccess; constructor(public payload: { session: Session }) { } } export class LoginSessionsFail implements Action { readonly type = SessionActionTypes.LoginSessionsFail; constructor(public payload?: { error: any }) { } } export class LogoutSessions implements Action { readonly type = SessionActionTypes.LogoutSessions; } export class LogoutSessionsSuccess implements Action { readonly type = SessionActionTypes.LogoutSessionsSuccess; constructor(public payload: { session: Session }) { } } export class LogoutSessionsFail implements Action { readonly type = SessionActionTypes.LogoutSessionsFail; constructor(public payload?: { error: any }) { } } export type SessionActions = | LoadSessions | LoadSessionsSuccess | LoadSessionsFail | LoginSessions | LoginSessionsSuccess | LoginSessionsFail | LogoutSessions | LogoutSessionsSuccess | LogoutSessionsFail;
session.reducer.ts
のState、initialStateに値を入力します。このとき、ローディングの状態も管理できるよう「loading」も追加しておきます。app/core/store/reducers/session.reducer.ts
import { Session } from '../../../class/chat'; export interface State { loading: boolean; session: Session; } export const initialState: State = { loading: false, session: new Session() };
非同期処理のトリガーとなるActionには
loading: true
を加え、その結果が更新され次第loading: false
にstateが上書きされるようにしておきます。app/core/store/reducers/session.reducer.ts
export function reducer( state = initialState, action: SessionActions ): State { switch (action.type) { case SessionActionTypes.LoadSessions: { return { ...state, loading: true }; } case SessionActionTypes.LoadSessionsSuccess: { return { ...state, loading: false, session: action.payload.session }; } case SessionActionTypes.LoadSessionsFail: { return { ...state, loading: false }; } case SessionActionTypes.LoginSessions: { return { ...state, loading: true }; } case SessionActionTypes.LoginSessionsSuccess: { return { ...state, loading: false, session: action.payload.session }; } case SessionActionTypes.LoginSessionsFail: { return { ...state, loading: false }; } case SessionActionTypes.LogoutSessions: { return { ...state, loading: true}; } case SessionActionTypes.LogoutSessionsSuccess: { return { ...state, loading: false, session: action.payload.session }; } case SessionActionTypes.LogoutSessionsFail: { return { ...state, loading: false }; } default: return state; } }
複数のreducerをストアで扱う場合、個別のreducerを
reducers: ActionReducerMap<State>
にまとめておく必要があります。このとき、どのreducerのstateなのかをセレクタで指定し、View(コンポーネント)はそのセレクタを指定することでstateの取得を行います。
今回の実装であれば、まず
core/store/reducers/session.reducer.ts
でstateを取得するためのメソッドを作成し、core/store/reducers/index.reducer.ts
でcreateSelector()
を使ってセレクタを作成します。app/core/store/reducers/session.reducer.ts
export const getSessionLoading = (state: State) => state.loading; export const getSessionData = (state: State) => state.session;
app/core/store/reducers/index.reducer.ts
export const selectSession = (state: State) => state.session; export const getLoading = createSelector(selectSession, fromSession.getSessionLoading); export const getSession = createSelector(selectSession, fromSession.getSessionData);
次はEffectの設定を行います。
EffectでFirebaseのデータをストアに反映
まずはEffectにサイト訪問時のログイン状況確認用メソッドを加え、Sessionクラスのコンストラクタを更新します。app/core/store/effects/session.effects.ts
import { Session, User } from '../../../class/chat'; import { User as fbUser } from 'firebase'; @Injectable() export class SessionEffects { constructor(private actions$: Actions, private afAuth: AngularFireAuth, private afs: AngularFirestore, private router: Router) {} @Effect() loadSession$: Observable<Action> = this.actions$.pipe( ofType<LoadSessions>(SessionActionTypes.LoadSessions), // ユーザーの認証状況を取得 switchMap(() => { return this.afAuth.authState .pipe( take(1), map((result: fbUser | null) => { if (!result) { // ユーザーが存在しなかった場合は、空のセッションを返す return new LoadSessionsSuccess({ session: new Session() }); } else { return result; } }), catchError(this.handleLoginError<LoadSessionsFail>( 'fetchAuth', new LoadSessionsFail()) ) ); }), // ユーザーの認証下情報を取得 switchMap((auth: fbUser | LoadSessionsSuccess | LoadSessionsFail) => { // ユーザーが存在しなかった場合は、認証下情報を取得しない if (auth instanceof LoadSessionsSuccess || auth instanceof LoadSessionsFail) { return of(auth); } return this.afs .collection<User>('users') .doc(auth.uid) .valueChanges() .pipe( take(1), map((result: User) => { return new LoadSessionsSuccess({ session: new Session(result) }); }), catchError(this.handleLoginError<LoadSessionsFail>( 'fetchUser', new LoadSessionsFail()) ) ); }) ); // エラー発生時の処理 private handleLoginError<T> (operation = 'operation', result: T) { return (error: any): Observable<T> => { // 失敗した操作の名前、エラーログをconsoleに出力 console.error(`${operation} failed: ${error.message}`); // ログアウト処理 this.afAuth.auth.signOut() .then(() => this.router.navigate([ '/account/login' ])); // 結果を返して、アプリを持続可能にする return of(result as T); }; } }
class/chat.ts
export class Session { login: boolean; user: User; constructor(init?: User) { // 変更 this.login = (!!init); this.user = (init) ? new User(init.uid, init.name) : new User(); }
@Effect()
デコレータを加えます。これにより、
ofType()
オペレータで指定したActionをdispatchした段階で該当メソッドを発火させることができます。その後、Firebaseに認証状況を問合せ、その結果によって認証下情報(users)の問合せに移行します。
これらの問合せが成功した場合には
LoadSessionsSuccess()
、失敗した場合にはLoadSessionsFail()
のActionを発生させ、その結果をStateに反映させます。エラー発生時には
catchError()
オペレータを使用し、handleLoginError()
メソッドに渡しています。エラー発生が確認できた場合は、その処理名、エラー内容を表示し、ログアウト処理を行っています。
同様にログインクリック時、ログアウト時のエフェクトを追加します。
app/core/store/effects/session.effects.ts
@Effect() loginSession$: Observable<Action> = this.actions$.pipe( ofType<LoginSessions>(SessionActionTypes.LoginSessions), map(action => action.payload), switchMap((payload: { email: string, password: string }) => { return this.afAuth .auth .signInWithEmailAndPassword(payload.email, payload.password) .then(auth => { // ユーザーが存在しなかった場合は、空のセッションを返す if (!auth.user.emailVerified) { alert('メールアドレスが確認できていません。'); this.afAuth.auth.signOut() .then(() => this.router.navigate([ '/account/login' ])); return new LoginSessionsSuccess({ session: new Session() }); } else { return auth.user; } }) .catch(err => { alert('ログインに失敗しました。\n' + err); return new LoginSessionsFail({ error: err }); } ); }), switchMap((auth: fbUser | LoginSessionsSuccess | LoginSessionsFail) => { // ユーザーが存在しなかった場合は、空のセッションを返す if (auth instanceof LoginSessionsSuccess || auth instanceof LoginSessionsFail) { return of(auth); } return this.afs .collection<User>('users') .doc(auth.uid) .valueChanges() .pipe( take(1), map((result: User) => { alert('ログインしました。'); this.router.navigate([ '/' ]); return new LoginSessionsSuccess({ session: new Session(result) }); }), catchError(this.handleLoginError<LoginSessionsFail>( 'loginUser', new LoginSessionsFail(), 'login' )) ); }) ); @Effect() logoutSession$: Observable<Action> = this.actions$.pipe( ofType<LogoutSessions>(SessionActionTypes.LogoutSessions), switchMap(() => this.afAuth.auth.signOut()), switchMap(() => { return this.router.navigate([ '/account/login' ]) .then(() => { alert('ログアウトしました。'); return new LogoutSessionsSuccess({ session: new Session() }); }); }), catchError(this.handleLoginError<LogoutSessionsFail>( 'logoutUser', new LogoutSessionsFail(), 'logout' )) ); // エラー発生時の処理 private handleLoginError<T>(operation = 'operation', result: T, dialog?: 'login' | 'logout') { // 変更 return (error: any): Observable<T> => { // 失敗した操作の名前、エラーログをconsoleに出力 console.error(`${operation} failed: ${error.message}`); // アラートダイアログの表示 // 追加 if (dialog === 'login') { alert('ログインに失敗しました。\n' + error); } if (dialog === 'logout') { alert('ログアウトに失敗しました。\n' + error); } // ログアウト処理 this.afAuth.auth.signOut() .then(() => this.router.navigate([ '/account/login' ])); // 結果を返して、アプリを持続可能にする return of(result as T); }; }
これにより、
sessionState
からデータを取得する処理が不要になったので、session.service.ts
内の記述を変更します。app/core/service/session.service.ts
import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; // 変更 import { Router } from '@angular/router'; import { AngularFireAuth } from '@angular/fire/auth'; import { map } from 'rxjs/operators'; // 変更 import { AngularFirestore } from '@angular/fire/firestore'; import { Store } from '@ngrx/store'; // 追加 import { Password, User } from '../../class/chat'; // 変更 import * as fromCore from '../../core/store/reducers'; // 追加 import { LoadSessions, LogoutSessions, UpdateSessions } from '../store/actions/session.actions'; // 追加 /* 省略 */ // public session = new Session(); // 削除 // public sessionSubject = new Subject<Session>(); // 削除 // public sessionState = this.sessionSubject.asObservable(); // 削除 constructor(private router: Router, private afAuth: AngularFireAuth, private afs: AngularFirestore, private store: Store<fromCore.State>) { // 追加 } // ログイン状況確認 checkLogin(): void { // 変更 this.store.dispatch(new LoadSessions()); } // ログイン状況確認(State) checkLoginState(): Observable<{ login: boolean }> { // 変更 return this.afAuth .authState .pipe( map((auth: any) => { // ログイン状態を返り値の有無で判断 return { login: !!auth }; }) ); } login(account: Password): void { // 変更 this.store.dispatch(new LoginSessions({email: account.email, password: account.password})); } logout(): void { // 変更 this.store.dispatch(new LogoutSessions()); } /* 省略 */ // ユーザーを取得 // 削除
View(コンポーネント)にStateを反映
上記で設定したルートストアのStateを、チャット画面とヘッダーに反映します。コンポーネントにStateを反映させたいときは、Reducerで設定したセレクタをコンストラクタでDIします。
app/chat/chat.component.ts
import { Comment, User } from '../class/chat'; // import { SessionService } from '../core/service/session.service'; // 削除 import { Store } from '@ngrx/store'; // 追加 import * as fromCore from '../core/store/reducers'; // 追加 /* 省略 */ // DI(依存性注入する機能を指定) constructor(private db: AngularFirestore, private store: Store<fromCore.State>) { // 追加 this.store.select(fromCore.getSession) // 変更 .subscribe(data => { this.current_user = data.user; }); }
続いてヘッダーにStateを反映します。
app/core/header/header.component.ts
import { Observable } from 'rxjs'; // 追加 import { Store } from '@ngrx/store'; // 追加 import * as fromCore from '../store/reducers'; // 追加 /* 省略 */ public session$: Observable<Session>; // 追加 // public login = false; // 削除 constructor(private sessionService: SessionService, private store: Store<fromCore.State>) { // 変更 this.session$ = this.store.select(fromCore.getSession); } ngOnInit() { // 削除 }
app/core/header/header.component.html
<nav class="navbar fixed-top navbar-dark bg-primary"> <a class="navbar-brand" href="#">NgChat</a> <!-- ログイン状態で分岐 --> <span class="navbar-text" *ngIf="!(session$ | async)?.login"><!-- 変更 --> <a routerLink="/account/login">Login</a> </span> <span class="navbar-text" *ngIf="(session$ | async)?.login"><!-- 変更 --> <a routerLink="/account/login" (click)="logout()">Logout</a> </span> <!-- 分岐ここまで --> </nav>
非同期処理時のローディングをヘッダーに追加
最後に、非同期処理時のローディングバーをヘッダーに追加します。Reducerで指定した
loading
セレクタをヘッダーコンポーネントでDIし、LoadSessions()
の起動からLoadSessionsSuccess()
、もしくはLoadSessionsFail()
が完了するまでの状態を反映できるようにします。header.component.ts
にloading$
、header.component.html
にBootstrapのプログレスバーを追加します。app/core/header/header.component.ts
public loading$: Observable<boolean>; // 追加 public session$: Observable<Session>; constructor(private sessionService: SessionService, private store: Store<fromCore.State>) { this.loading$ = this.store.select(fromCore.getLoading); // 追加 this.session$ = this.store.select(fromCore.getSession); }
app/core/header/header.component.html
<nav class="navbar fixed-top navbar-dark bg-primary"> <a class="navbar-brand" href="#">NgChat</a> <!-- ログイン状態で分岐 --> <span class="navbar-text" *ngIf="!(session$ | async)?.login"> <a routerLink="/account/login">Login</a> </span> <span class="navbar-text" *ngIf="(session$ | async)?.login"> <a routerLink="/account/login" (click)="logout()">Logout</a> </span> <!-- 分岐ここまで --> </nav> <div class="progress" style="height: 3px;" *ngIf="(loading$ | 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>
app/core/header/header.component.css
.progress { /* 追加 */ position: absolute; top: 0; width: 100%; z-index: 1050; }
実行結果
これでルートストアの設定、およびViewへのStateの反映が完了しました。
次は機能ストアの設定、およびエンティティの設定を行います。
ソースコード
この時点でのソースコード※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。
コメント
コメントを投稿