この記事はAngular+Firebaseでチャットアプリを作るのエントリーです。
前記事:Angular+Firebase ユーザー設計とデータの保護
次記事:Angularのngrxを使って状態管理を行う(実装編:初期設定~エフェクト設定)
この記事で行うこと
前回の記事ではユーザー設計とデータの保護を扱いました。
ここからAngularの@ngrx
というライブラリを使って、状態管理の理論、実装を3回にわけて行っていきます。
本記事ではまず、状態管理の理論について学習します。
@ngrx
とは
@ngrx
はReduxというライブラリに影響を受けて作成されたAngularの公式ライブラリです。Actions、Dispatcher、Reducer、State、ViewというReduxのデータフロー構成を、RxJSとクラスを使ってAngular流に再現しています。
Reduxのデータフロー
- View(Component)でユーザーがクリックするなどの動作でActionを発行
- DispatcherがActionをStoreに渡す
- Actionの結果をReducerで元のStateと合成する
- stateをViewに返して、Viewを更新する
また、@ngrx
はngrx/platformで開発が進められており、2020/6月現在では下記ライブラリが利用できます。
ライブラリ名 | 概要 |
---|---|
@ngrx/store |
Reduxに影響を受けた、RxJSベースのAngularアプリケーション用状態管理ライブラリ |
@ngrx/store-devtools |
リアルタイムでストアの状態を把握するデバッグ用ライブラリ |
@ngrx/effects |
発行されたActionを元にWebAPIにアクセスするといった「副作用」を処理するライブラリ |
@ngrx/router-store |
Angular Routerと@ngrx/storeを連携させるライブラリ |
@ngrx/entity |
エンティティ(複数配列データ)の管理ができるライブラリ |
@ngrx/data |
エンティティデータ管理を簡略化する拡張機能 |
@ngrx/component |
フルリアクティブかつZoneを利用しないアプリケーション用拡張機能(2020年6月時点で開発中) |
@ngrx/component-store |
ローカルにあるコンポーネントストアを管理するためのスタンドアロンライブラリ |
@ngrx/schematics |
ngrxのコードジェネレーターライブラリ |
参考
本題
なぜngrxを使う必要があるのか
以前の記事で、Angularのデータ管理にはRxJSを使用するということを書きました。あるコンポーネントで利用したデータをほかのコンポーネントでも利用したい場合、RxJSのストリーム(Observable)にデータを流すことで実現できます。
規模の小さいアプリケーションであれば、上記のような構成でもそれほど問題になりません。しかし、アプリケーションの規模が大きくなってObservableの数が増え始めると、それぞれのObservableの配線が複雑になって、Stateがどのような状況になっているかが不明瞭になっていきます。
状態管理が複雑になってしまうと、何か不具合があったときにどのObservableによるものなのかが判断しづらく、解消に時間がかかってしまったり、不要なObservableがメモリを圧迫してパフォーマンスに影響を与えてしまうようなことが考えられます。
この事態を避けるために有効な手段が@ngrx
ライブラリの導入です。状態管理に一定の規則性を与え、1つの大きなオブジェクト(ステートツリー)で管理することで上記の懸念を解消できます。
また、@ngrx
の導入には、複雑性の解消以外にも次のようなことが期待できます。
- 開発者間での共通な設計思想
- コンポーネントテストの容易性
これらの有効性についてはこちらの記事を参照してください。
ngrxの流れを見る
具体的な実装方法については次回以降の記事で扱うので、ここでは各構成の役割を流れに沿って確認していきます。
①View → Dispatcher
まずはView(コンポーネント)でActionを作成し、Dispatcherに渡します。
export enum SessionActionTypes {
LoadSessions = '[Session] Load',
LoginSessions = '[Session] Login',
LogoutSessions = '[Session] Logout'
}
export class LoadSessions implements Action {
readonly type = SessionActionTypes.LoadSessions;
}
export class LoginSessions implements Action {
readonly type = SessionActionTypes.LoginSessions;
}
export class LogoutSessions implements Action {
readonly type = SessionActionTypes.LogoutSessions;
}
export type SessionActions =
LoadSessions
| LoginSessions
| LogoutSessions;
export class AppComponent {
constructor(private store: Store<fromCore.State>) {
this.store.dispatch(new LoadSessions());
}
login() {
this.store.dispatch(new LoginSessions());
}
logout() {
this.store.dispatch(new LogoutSessions());
}
}
ActionはView上で生じる操作(クリック、スクロール)や、データの読み込みといった、データ(State)の変化を生じさせるトリガーを定義したものです。
ここではセッションデータの読み込み、ログイン、ログアウトといった操作を定義し、dispatch()
の引数にすることでReducerにActionを渡しています。
②Dispatcher → Reducer → State
続いてDispatcherで受けたActionをStoreにあるReducerに渡します。
/**
* State
*/
export interface State {
session: { login: boolean };
}
export const initialState: State = {
session: { login: false };
};
/**
* Reducer
*/
export function reducer(
state = initialState,
action: SessionActions
): State {
switch (action.type) {
case SessionActionTypes.LoadSessions: {
return { ...state };
}
case SessionActionTypes.LoginSessions: {
return { session: { login: true } };
}
case SessionActionTypes.LogoutSessions: {
return { session: { login: false } };
}
default:
return state;
}
}
export const getSession = (state: State) => state.session;
Reducerでは最初にStateの型と初期値(initialState
)を定義します。
初めてdispatch()
された時は、このinitialState
がstateとして流れてきます。
reducer
クラスには現在のstateとDispatcherで指定されたactionが引数として渡され、actionのタイプによって異なる処理が加えられます。
流れてきたActionのタイプがLoginSessions
だった場合、session stateは{ login: true }
に上書きされ、LogoutSessions
だった場合は{ login: false }
に上書きされます。
getSession()
は取得したいstateを指定するためのメソッドです。Viewからstateを指定するときは、このメソッドを使うことになります。
③State → View
最後は②で上書きしたStateをViewに渡して反映させます。
export class HeaderComponent implements OnInit {
public session$: Observable<{login: boolean}>;
constructor(private store: Store<fromCore.State>) {
this.session$ = this.store.select(fromCore.getSession);
}
ngOnInit() {
}
}
this.store.select()
で取得したいstateを指定し、session$
に格納しています。
これにより他のコンポーネントで更新されたStateを、リアルタイムで反映することができるようになります。
@ngrx
の基本的なデータの流れは以上です。
次は非同期通信で外部からデータを取得した場合について考えます。
Effectで非同期のデータを扱う
非同期通信でデータを取得する場合、どのような流れになるのかを図示してみます。
最初の図に@Effect
という概念が追加されました。非同期通信時は、この@Effect
を使ってデータの取得とStateの更新を行います。
@Effect
は特定のActionがDispatcherに渡された瞬間から開始され、非同期通信の結果が取得でき次第、その結果を再びDispatcherに渡します。もちろん、その結果には通信の成功と失敗の両方がありますので、それに合わせて新しいActionを用意する必要があります。
このときの処理の流れは、こちらを参照するとわかりやすいです。
@Effect
上記のをコードに反映すると次のようになります。
export enum SessionActionTypes {
LoadSessions = '[Session] Load',
LoadSessionsSuccess = '[Session] Load Success',
LoadSessionsFail = '[Session] Load Fail',
LoginSessions = '[Session] Login',
LogoutSessions = '[Session] Logout'
}
export class LoadSessions implements Action {
readonly type = SessionActionTypes.LoadSessions;
}
export class LoadSessionsSuccess implements Action {
readonly type = SessionActionTypes.LoadSessionsSuccess ;
constructor(public payload: { session: { login: boolean } }) {}
}
export class LoadSessionsFail implements Action {
readonly type = SessionActionTypes.LoadSessionsFail ;
constructor(public payload?: { error: any }) {}
}
export class LoginSessions implements Action {
readonly type = SessionActionTypes.LoginSessions;
}
export class LogoutSessions implements Action {
readonly type = SessionActionTypes.LogoutSessions;
}
export type SessionActions =
LoadSessions
| LoadSessionsSuccess
| LoadSessionsFail
| LoginSessions
| LogoutSessions;
/**
* Reducer
*/
export function reducer(
state = initialState,
action: SessionActions
): State {
switch (action.type) {
case SessionActionTypes.LoadSessions: {
return { ...state };
}
case SessionActionTypes.LoadSessionsSuccess: {
return { session: action.payload.session };
}
case SessionActionTypes.LoadSessionsFail: {
if (action.payload) {
console.log(action.payload.error);
}
return { ...state };
}
case SessionActionTypes.LoginSessions: {
return { session: { login: true } };
}
case SessionActionTypes.LogoutSessions: {
return { session: { login: false } };
}
default:
return state;
}
}
export class ChatEffects {
constructor(private actions$: Actions,
private afAuth: AngularFireAuth) {
}
@Effect()
loadSession$: Observable<Action> =
this.actions$.pipe(
ofType<LoadSessions>(SessionActionTypes.LoadSessions),
map(action => action),
switchMap(() => {
return this.afAuth.authState
.pipe(
take(1),
map( result => {
const login_state = { login: (!!result) };
return new LoadSessionsSuccess({ session: login_state })
}),
catchError( err => new LoadSessionsFail({ error: err }))
);
})
);
}
ActionとReducerにLoadSessionsSuccess
とLoadSessionsFail
を加え、それぞれの初期値と返り値を定義しています。
Effectを使う場合は、まず@Effect()
デコレータを宣言します。ofType()
で対象となるActionを指定し、そのActionがdispatch()
されると同時に処理が始まります。
今回はthis.afAuth.authState()
でログイン状況を問合せ、その結果をLoadSessionsSuccess()
でDispatcherに渡しています。そのデータはその後Reducerに渡され、現在のstateを上書きしてまたView(コンポーネント)へと渡されていきます。
まとめ
@ngrx
はReduxとRxJSの両方の知識が必要となり、習得にはある程度時間がかかるかと思われます。
しかし、SPAを始めとした近年のWEBアプリケーションでは、クライアントサイトでのデータ処理が増えているため、体系化した状態管理のしくみが求められています。今後、新しいWEBアプリケーションを構築する、もしくは既存アプリケーションの状態管理を体系化するといったことを予定している場合は、@ngrx
の導入を検討してみてください。
ここまで、@ngrx
の概念と基本的な構成を扱いました。次回の記事では、具体的な実装方法について取り上げていきます。