本記事はマニアックなないようなため以下の文脈を前提とし詳しい説明を大きく省略している箇所があります。
- Server Side Renderingするアプリ
- Dynamic importはwebpackに任せる
- ViewライブラリとしてReactを採用
- Redux, react-redux を利用し combineReducerが必要になるぐらいには複雑なアプリ
- 分割の粒度はRoute based
- コンポーネントのLazyloadは react-loadable
- PRPLパターンの用語を理解してる人
- React + Redux 環境で徹底的にCode Splitting Lazyloadしてみたい人
- Route BasedなCode Splittingはやり方も含めてある程度わかっている人
- React + Reduxを使ったSPAの構成が全然創造つかない人
- Reducerって別にファイルサイズ大きくないしAppShellにまとめてもよくない?と既に結論を出している人
- Webpackの設定
- Route Based Code Splittingのやりかた
- なぜリデューサーは分割されないのか
- リデューサーを分割してみてどうだったか概略
- リデューサーのコードスプリット レイジーロードの設計概略
- リデューサーのコードスプリット実践
- まとめ
URLに対応したコンポーネントをLazy Loadするだけだと、ReducerはApp shellへ含まれます。
理由は大きく2つあります。 1つはReduxが提供している createStore のタイミングでReducerの実態が必要だからです。複数のReducerを必要とする場合、予めすべてcombineReducerにより一つのReducerへとまとめておく必要があるからです。このためcreateStoreを呼び出す前にすべてのReducerをimportすることになり、App Shellへと含まれていきます。 2つ目はRoute Componentからimportを辿っていってもReducerはimportされない点にあります。これはReduxの設計がActionの発生ロジックと発生したActionによりどのような状態へと更新するかを分割していて、とくにActionCreatorからReducerへ依存させないようになっているためにおきます。これによりReducerはRoute側のバンドルに含まれません。 このため、ReducerのLazy Loadの実現のためには以下の2つの実現が必要になります。
- App Shellへ含まれないようにする
- Routeバンドルへ含まれるようにする(もしくはRouteバンドルともわけ、Route Componentがマウントされる前までにLazy Loadする)
長いので先に簡単なまとめをしておきます。
- ReducerのCode Splitting + Lazy Loadはできた
- 明らかに設計の悪い箇所が残った。(改善のアイデアはあるが未着手)
- 設計の課題を抱えたまま実践するのは難しい
- やるならば速さを極端に追求するようなトレードオフになる
細かいことは棚上げにしてどのような実装になるかを説明していきます。
この部分にはTwitter Liteの実装例を大きく参考にしました。(というか他に解説記事を見つけられなかった…)。
彼らの手法との違いは、以下の二点。
- 複数階層のcombineReducerをサポートすること
- 直接Reducerをdynamic importする部分を持つこと
2は、combineReducerを実行するのに必要な、Reducerツリー 構造をcreateStoreの呼び出しの近くに持っておきたかったためです。
コードをすべて持ち出すと、一生書き終わらないので、外観がわかるような、サンプルコードを用意しました。ReducerRegistryとrecursiveCombineReducerは自作です。実装全体はgistにて公開し、細かい解説は省略します。
- Stateのプロパティとその更新を担当するReducerの組み合わせをここですべて定義します。
大きくTwitter Liteの例とは違います。
register(reducerName, reducer)
というシグネチャだと改装構造をサポートできないためにこのような設定で扱うようにしました。 - Store生成時にはApp Shellの動作に必要なものだけを準備させます。
- ReducerRegistryからはreducerSettingにそった木構造でReducerの一覧が返ってきます。 それらを一つになるまで再帰的にcombineReducerします。
- アプリ起動時なのでcreateStoreによりStoreを生成します。
- Route Componentの動作に必要なReducerを準備させます。 準備に必要なパラメータはなんとかしてRoute Componentのあたりからなんとかして受け取ります。(後述)
- Store.replaceReducerによりreducerを差し替えます。
これにより、ReducerすべてがApp Shellに含まれることはなくなりました。
あとは、Route ComponentとともにReducerをLazy loadし、上記手順の5が実行されるようにできれば終わりです。
大まかに以下の手順が実行される必要があります。
- Route ComponentのLazy loadに合わせてReducerRegistryのparepareを実行する
- Route ComponentとReducerのLazy loadが終わったあとに前述の手順5からの手続きを実行する
- Route Componentをmountする
react-loadable のloadメソッドでReducerとRoute Componentをloadします。
- 事前の準備として必要なReducerの設定とRoute Componentをdynamic loadする関数をひとまとめにしてあります。 これは単純に管理上の都合です。 ここではEntryPointと呼ぶことにします。
- 実際にreact-routerの Routeに配置するコンポーネントはreact-loadableによって生成します。 AsyncRouteComponentと呼ぶことにします。
- react-loadableのloadではRouteComponentとReducerの両方の読み込みを実行します。 updateStoreReducerという関数が登場しますが、これはprepareとreplaceReducerを実行する関数です。 しれっと参照していますが、singletonにしてimportするとか、Context apiやpropsを経由する実装もあると思います。reducerRegistryにObserverパターンを適用して直接prepareメソッドを叩くのもいいかもしれません。
Loadableの呼び出しはパターン的になるので関数化して再利用できるでしょう。
さて、これでReducerのCode splitとLazy loadの実現ができました。
実現はできたのですが大きな課題が残りました。 reducerSettingが手動設定すぎて忘れたり、間違ったりするのです。そうなったとき以下のような状況になります。
- 必要なReducerを読み込まなくても一見、動いているようにみえる。
- 余計なReducerを読み込もうとしても気づけない
- Client Side Renderingしている場合、他のページでReducerがロードされ結果的にちゃんと動くことがある。(リロードすると壊れる)
この状況はかなり辛い状況です。本記事では未解決のままです。
このような状況を解決するには、reducerSettingの自動生成が必要です。
ReducerはActionのTypeを経由してActionCreatorに依存があるので不可能ではないと考えています。
ReducerのCode splitおよびLazy Loadは実現できました。
しかし、人手の部分に大きな課題が残りました。
課題に対して自動化のアイデアはあるものの、トライアンドエラーが必要だと考えています。
「PWA化しました」というフレーズを去年よりも多く聞いたような感覚があり、スピードアップのために何ができるのか?をみんなが考えているように感じました。そんな中、不要なものをできるだけクライアントにロードさせないようにどうすればいいのか?をすこし煮詰めたアイデアが本記事のReducerのCode split + Lazy loadです。
Cache戦略, 先読みなどスピードアップのための方針は他にもありますが、その中の一つとしていかがだったでしょうか?