うっかりReduxのReducerまでコード分割しようとするとどうなるか

この記事はeureka Advent Calendar 2018 5日目の記事です。

本記事はマニアックな内容のため前提となる説明を大きく省略している箇所があります。

また、参考実装となるプロジェクトをGitHubで公開しています。

想定する読者

  • PRPLパターンの用語を理解してる人
  • React + Redux環境で徹底的にコード分割 & 遅延読み込みしてみたい人
  • Routeベースなコード分割 & 遅延読み込みを、やり方も含めてある程度わかっている人

想定しない読者

  • React + Reduxを使ったSPAの構成が全然想像つかない人
  • Reducerって別にファイルサイズ大きくないし遅延読み込みしなくてもよくない?と既に結論を出している人

想定するアプリ

書いてないこと

  • Webpackの設定
  • SSR関連

Reducerを分割してみてどうだったか

長いので先に簡単なまとめをしておきます。

  • Reducerのコード分割 & 遅延読み込みはできた
  • 明らかに設計の悪い箇所が残った。(改善のアイデアはあるが未着手)
  • 設計の課題を抱えたまま実践するのは難しい
  • やるならば速さを極端に追求するようなトレードオフになる

目次

  1. なぜReducerは分割されないのか
  2. ReducerがApp Shellに含まれないようにする
  3. Reducerを遅延読み込みする箇所を明示する
  4. まとめ

なぜReducerは分割されないのか

URLに対応したコンポーネントを遅延読み込みするだけだと、ReducerはApp shellへ含まれます。

理由は大きく2つあります。

1つはReduxが提供しているcreateStoreのタイミングでReducerの実態が必要だからです。複数のReducerを必要とする場合、予めすべてcombineReducerにより一つのReducerへとまとめておく必要があるからです。このためcreateStoreを呼び出す前にすべてのReducerをimportすることになり、App Shellへと含まれます。

2つ目はRoute コンポーネントからimportを辿っていってもReducerはimportされない点にあります。これはReduxの設計がActionの発生ロジックと発生したActionによりどのような状態へと更新するかの役目を分割していて、とくにActionCreatorからReducerへ依存させないようになっているためにおきます。これによりReducerはRoute側のバンドルに含まれません。このため、Reducerの遅延読み込みの実現のためには以下の2つの実現が必要になります。

  1. App Shellへ含まれないようにする
  2. Reducerを読み込ませる箇所を明示する

ReducerがApp Shellに含まれないようにする

この部分にはTwitter Liteの実装例を大きく参考にしました。(というか他に解説記事を見つけられなかった…)。

彼らの手法との違いは多少ありますが、ちょっとした工夫程度のものでコンセプトは変わりません。

Reducerを動的に更新できるようにする

とりあえず外観がわかるような、サンプルコードを用意しました。ReducerRegistryとrecursiveCombineReducerは自作です。

  1. Stateのプロパティとその更新を担当するReducerの組み合わせをここですべて定義します。Twitter Liteの例と最も異なる部分です。register(reducerName, reducer) というシグネチャだと改装構造をサポートできないためにこのような設定で扱うようにしました。
  2. Store生成時にはApp Shellの動作に必要なものだけを準備させます。
  3. ReducerRegistryからはreducerSettingにそった木構造でReducerの一覧が返ってきます。それらを一つになるまで再帰的にcombineReducerします。
  4. アプリ起動時なのでcreateStoreによりStoreを生成します。
  5. Routeコンポーネントの動作に必要なReducerを準備させます。準備に必要なパラメータはなんとかしてRouteコンポーネントのあたりからなんとかして受け取ります。(後述)
  6. Store.replaceReducerによりreducerを差し替えます。

これにより、ReducerすべてがApp Shellに含まれることはなくなりました。

あとは、RouteコンポーネントとともにReducerを遅延読み込みし、上記手順の5が実行されるようにできれば終わりです。

Reducerを遅延読み込みする箇所を明示する

大まかに以下の手順が実行される必要があります。

  1. Routeコンポーネントの遅延読み込みに合わせてReducerRegistryのprepareを実行する
  2. RouteコンポーネントとReducerの読み込みが終わったあとに前述の手順5からの手続きを実行する
  3. Routeコンポーネントをマウントする

React.SuspenseとContext APIでの実装

コンポーネントもReducerも遅延読み込みするコンポーネントをつくりました。Asyncと呼ぶことにします。

コンポーネントを読み込むだけならSuspenseさえあれば可能ですが、それ以外のものもついでに読み込みしようとすると足りないので、Contextを併用します。

  1. 前述の5,6の手順を行う関数をRequireReducerとして型をつけました。マウントされるときこの関数を実行します。
  2. このコンポーネントは動作に必要なReducerの設定とコンポーネント本体を返すPromiseを受け取ります。
  3. RequireReducerの実態はContext API経由で受け取ります。Storeへのアクセスが必須となるためこのような形をとります。
  4. Reducerを読み込むPromiseとコンポーネントを読み込むPromiseを束ねた、PromiseをReact.lazyを通してSuspenseに渡します。
    Suspenseはdefault exportを前提としているため、Promiseの解決の最後で帳尻をあわせる必要があります。

課題

ここまでの実装で、目的は実現できたのですが大きな課題が残りました。reducerSettingが手動設定すぎて忘れたり、間違ったりするのです。そうなったとき以下のような状況になります。

  • 必要なReducerを読み込まなくても一見、動いているようにみえる。
  • 余計なReducerを読み込もうとしても気づけない
  • CSRしている場合、他のページでReducerが読み込まれ結果的にちゃんと動くことがある。(リロードすると壊れる)

この状況はかなり辛い状況です。本記事では未解決のままです。

解決のアイデア

このような状況を解決するには、reducerSettingの自動生成が必要です。

ReducerはActionのTypeを経由してActionCreatorに依存があるので不可能ではないと考えています。

まとめ

Reducerのコード分割 & 遅延読み込みは実現できました。

しかし、人手の部分に大きな課題が残りました。

課題に対して自動化のアイデアはあるものの、トライアンドエラーが必要だと考えています。

「PWA化しました」というフレーズを去年よりも多く聞いたような感覚があり、スピードアップのために何ができるのか?をみんなが考えているように感じました。そんな中、不要なものをできるだけクライアントに読み込ませないようにするにはどうすればいいのか?をすこし煮詰めたアイデアが本記事のReducerのコード分割 & 遅延読み込みです。

キャッシュ戦略, 先読みなどスピードアップのための方針は他にもありますが、その中の一つとしていかがだったでしょうか?

eurekaではパフォーマンスの向上のためにいろいろな試行錯誤を重ねていきます。