redux-dynamic-modules を使う

Redux の Code-Splitting は実現するのは面倒臭そうだしバグもたくさん踏みそうだなと思っていたら redux-dynamic-modules という良いライブラリを見つけたので試してみました。
紹介するために触っていても非常に良いライブラリという感触だったので、これで大規模開発でも Code-Splitting による パフォーマンス維持を頑張っていけそうです。

サンプルプロジェクトはこちらから

Redux でも Code-Splitting したい

フロントエンドのパフォーマンスの改善方法の一つは、ローディング時間短縮のために初期読み込みするバンドルファイルのサイズを減らすことです。
公式ドキュメントによれば、 React では React.Suspense と React.lazy の組み合わせやライブラリを使って Code-Splitting することで、バンドルファイルの一部を遅延読み込みして初期読み込みする部分を減らせます。

一方、大規模開発で Redux のモジュールの数が多かったり、モジュール一つのコード量が多いなどの理由により全部が入ったファイルの読み込み時間がかかったりする場合、 Redux モジュールにも Code-Splitting を適用したくなります。
方法は公式ドキュメントに丁寧に書かれていますが、 replaceReducer を駆使したラッパーを書く必要があり、また、 Redux ミドルウェアの扱いも面倒臭そうです。

redux-dynamic-modules とは?

Redux モジュールへの Code-Splitting をお手軽に実現する方法として、ドキュメントの最後の方に書いてある redux-dynamic-modules を使う方法があります。

これは combineReducers によるモジュール分割と似たような感覚で Code-Splitting できるようなコード構造を実現できます。
もちろん、 Redux Thunkredux-saga を公式にサポートしているので安心です。

使い道

redux-dynamic-modules の公式ドキュメントでは、 3 つの活用ケースを挙げています。

  • すべての reducers を最初に読み込みたくないとき( Code-Splitting )
  • いくつかの共通 Redux モジュールがアプリケーション内のいくつかの場所で使われているとき(一部分の利用)
  • ある Redux モジュールが複数のアプリケーションを跨いで使い回されているとき(再利用)

使い方

大体は GettingStarted を読めばわかりますが…

まずは Redux モジュールを 1 つ作る感覚で get???Module() という関数を定義して、モジュールの情報を詰め込みます。
ちなみに、 redux-dynamic-modules にはいくつかのライフサイクル Actions 指定項目(実行すべきアクションを指定する形) があります。モジュールという単位が基準になると、そのままでは追加・削除がいつ起きるかわからなくなり、外部リソースと紐づくような状態の管理が大変だからでしょうか。上手く使うと、従来は React に書かざるを得なかった初期データ読み込みアクションを無くせるという副産物もあります。

Redux ストア自体の初期化は redux-dynamic-modules が export しているものを使って行います。( Redux のものを使うと多分うまく動かない)

一方の React 側では、 Redux モジュールを必要とする場所の直近の親要素として <DynamicModuleLoader modules={[get???Modules()]} /> を加えることで、このコンポーネントが render するタイミングでモジュールが Redux へ動的に追加されます。
Code-Splitting が目的の場合には、 <DynamicModuleLoader /> を中に含むコンポーネントごと React.lazy で遅延読み込みする必要がありますが、これは複数のページを持つアプリケーションであればよくあるパターンなので、特に難しいことはありません。

ただし、モジュール分割する単位をページという境界ではなくデータの関心を境界にするのが推奨されているのを意識しておかねばなりません。

サンプル実装

例のごとくサンプルプロジェクトを作成しました。大規模開発っぽく、 TypeScript + React + React-Redux + Redux + Redux-Saga + React-Router を採用しています。(実プロジェクトでは Redux モジュールは re-ducks パターンなファイル構成になると思いますが)
題材は業務アプリケーション風で、ダッシュボード、顧客リスト、従業員リスト(、とログインページ)を持っています。(偽物なのでリロード後は毎回「ログイン」処理が必要です。)
顧客・従業員・上司データへのアクセスは認証されている必要があります。認証用データはグローバルに利用される “auth” モジュールにあり、ほかのモジュールは “auth” に暗示的に依存を持っています。また、上司データは “boss” モジュールにあり、こちらは “auth” モジュールに明示的に依存を宣言しています。

ヘビーになったので詳細なコードは載せませんが、実装に迷ったいくつかのパターンだけ忘備録として載せます。

モジュール間の依存性解決

“boss” モジュールが “auth” モジュールに依存するところでは、内部でモジュール生成する関数を作っておいて、それを利用して配列を作る関数を export します。(公式ドキュメントの例
この関数の型は IModule<unknown>[] 等にしておけば実用上は困らないでしょう。

src/modules/boss/index.tslink
67
function getBossModuleInternal(): ISagaModule<BossModuleOwnState> {
0
// omit
78
79
80
81
export function getBossModule(): IModule<unknown>[] {
// "boss" module depends on "auth" module
return [getAuthModule(), getBossModuleInternal()];
}

( “boss” モジュール内では “boss” と “auth” が持つモジュールがすで追加されている前提で型を付けています。)

ページから複数モジュールを読み込む

“EmployeesPage” が “boss” モジュールと “employees” モジュールのデータに明示的に依存するところでは、 <DynamicModuleLoader />で両方を modules に含めます。

src/pages/EmployeesPage.tsxlink
51
52
53
<DynamicModuleLoader modules={[getBossModule(), getEmployeesModule()]}>
<EmployeesConnected />
</DynamicModuleLoader>

( “EmployeesPage” では “boss” と “employees” と グローバルである “auth” が持つモジュールがすでに追加されている前提で型を付けています。)

初期アクション

モジュールが追加されたときに Action を dispatch するには initialActions の配列に含めます。他にも、削除される直前で Action を dispatch する finalActions があります。
fetchEmployeesOperation() は Action のファクトリなので、その実行結果を配列に入れている。)

src/modules/employees/index.tslink
68
69
70
71
72
73
74
75
76
77
export function getEmployeesModule(): ISagaModule<EmployeesModuleOwnState> {
return {
id: "employees",
reducerMap: {
employees: reducer
},
initialActions: [fetchEmployeesOperation()],
sagas: [rootSaga]
};
}

他モジュールの状態

他のモジュールの状態にアクセスするには、 combineReducers の場合と同じように、プロパティをたどっていけば状態を取得できます。少し迷う可能性があるのは、モジュールの id ではなく reducerMap の中に書いたプロパティの中からたどることです。
(下の例では state.auth.token ではなく state.authInfo.token

src/modules/auth/index.tslink
55
56
57
58
59
60
61
62
63
export function getAuthModule(): ISagaModule<AuthModuleOwnState> {
return {
id: "auth",
reducerMap: {
authInfo: authInfoReducer
},
sagas: [rootSaga]
};
}
src/modules/employees/index.tslink
45
46
47
const token: string | null = yield select(
(state: GlobalState) => state.authInfo && state.authInfo.token
);

まとめ

最初に公式ドキュメントの説明を見たときの印象と違い、かなり使いやすいライブラリでした。フロントエンドの大規模開発でも使いやすいような仕様となっており、 DX とパフォーマンスの両方が向上できると思います。
日本語の記事が少ないのが不思議ですが、 Redux を使う場合は積極的に使っていきたいと思います。