Angularのngrxを使って状態管理を行う(実装編:初期設定~エフェクト設定)

Angularのngrxを使って状態管理を行う(実装編:初期設定~エフェクト設定):

この記事は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 Stateng 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 
--root

ルートストアの設定を行います。この指定がない場合、自動的に機能ストア(別記事で詳述)が作成されます。

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

reducerを加えたいストアを指定します(相対パス)。

これで必要なファイルは整いました。


Action、Reducerの実装

それではまずStateに変更を加える処理をリストアップし、Actionとして定義していきます。

Action名 起動タイミング 役割
LoadSessions サイト訪問時 ログイン有無の問合せ
LoadSessionsSuccess LoadSessionsが成功 ユーザーデータの取得
LoadSessionsFail LoadSessionsが失敗 -
LoginSessions ログインクリック時 ログイン有無の問合せ
LoginSessionsSuccess LoginSessionsが成功 ユーザーデータの取得
LoginSessionsFail LoginSessionsが失敗 -
LogoutSessions ログアウトクリック時 ログインの無効化
LogoutSessionsSuccess LogoutSessionsが成功 ユーザーデータの破棄
LogoutSessionsFail LogoutSessionsが失敗 -
ここではLoad、Update、Logoutという3つの動作と、それぞれにSuccess、Failという非同期の結果(Side Effect)を加えた計9種類のActionを定義しました。

それぞれの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; 
 
続いてReducerの実装に入ります。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() 
}; 
Stateの設定が完了したら、reducerにアクション毎の処理を加えます。

非同期処理のトリガーとなる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; 
  } 
} 
最後に特定のstateを取得できるようセレクタの設定を行います。

複数のreducerをストアで扱う場合、個別のreducerをreducers: ActionReducerMap<State>にまとめておく必要があります。

このとき、どのreducerのstateなのかをセレクタで指定し、View(コンポーネント)はそのセレクタを指定することでstateの取得を行います。

今回の実装であれば、まずcore/store/reducers/session.reducer.tsでstateを取得するためのメソッドを作成し、core/store/reducers/index.reducer.tscreateSelector()を使ってセレクタを作成します。

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); 
これでAction、Reducerの設定は完了しました。

次は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のメソッドには、@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をチャット画面に反映しました。

続いてヘッダーに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> 
チャット画面と同様にルートストアをDIし、非同期処理に対応できるようhtmlも更新しました。


非同期処理時のローディングをヘッダーに追加

最後に、非同期処理時のローディングバーをヘッダーに追加します。

Reducerで指定したloadingセレクタをヘッダーコンポーネントでDIし、LoadSessions()の起動からLoadSessionsSuccess()、もしくはLoadSessionsFail()が完了するまでの状態を反映できるようにします。

header.component.tsloading$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; 
} 


実行結果



Oct-26-2018 17-27-23.gif


これでルートストアの設定、およびViewへのStateの反映が完了しました。

次は機能ストアの設定、およびエンティティの設定を行います。


ソースコード

この時点でのソースコード

※firebaseのapiKeyは削除しているので、試すときは自身で作成したapiKeyを入れてください。

コメント

このブログの人気の投稿

投稿時間:2021-06-17 05:05:34 RSSフィード2021-06-17 05:00 分まとめ(1274件)

投稿時間:2021-06-20 02:06:12 RSSフィード2021-06-20 02:00 分まとめ(3871件)

投稿時間:2020-12-01 09:41:49 RSSフィード2020-12-01 09:00 分まとめ(69件)