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を入れてください。
コメント
コメントを投稿