2020/05/31 追記
勉強や経験を重ねた結果、この記事を執筆した時より知識が増え、コードの書き方にも変化があります。
サンプルアプリも同様で、以下のプロダクトのコードのほうが、今の自分の考えが反映されていると思います。
追記終わり
2019/07/14 追記
というコメントを頂き、確かに便利そうだったので導入した。
それに合わせてこの記事の内容もアップデートした。
追記終わり
タイトルに書いた組み合わせで SPA を作るときにどのような設計にするのか、現時点での考えを記録しておく。
チュートリアルの次の一歩というか、現実的なアプリを構築する際の基本となる考え方。
状態管理とどう向き合うかが、主要なテーマになっている。
サンプルアプリ
具体的なコードがないと話を進められないので、サンプルアプリを作成した。
ページは2つあり、「会員種別選択ページ」と「商品選択ページ」からなる。
まず「会員種別選択ページ」で、「プレミアム会員」か「通常会員」かを選ぶ。
「プレミアム会員」の場合は「認証コード」が必要で、正しいコードを入れないと先に進めない。
次に「商品選択ページ」で商品を選ぶ。
「プレミアム会員」だと、10%ディスカウントされるだけでなく、選べる商品も増える。
商品を1つ以上選択すると「購入する」ボタンを押せる。
それ以上の細かい説明については、必要に応じて行っていく。
使っている主なライブラリのバージョンは以下の通り。
- react@16.8.6
- react-dom@16.8.6
- redux@4.0.1
- redux-thunk@2.3.0
- react-redux@7.1.0
- reselect@4.0.0
- react-router-dom@5.0.1
- typescript@3.5.2
APIについては、モックサーバーを作るのは面倒だったのでPromise.resolve
で擬似的に再現している。
全体像
原則としてReact Hooks
を使い、クラスコンポーネントは使わない。
業務でそこそこの規模の SPA を開発しているが、クラスコンポーネントが必要になったことはほぼ無い。
ドメインモデルに相当するものはRedux
で管理し、ビューに関する状態はuseState
で管理する。
ちなみに、useReducer
は使い所が難しいと思っている。
Redux
を使うのは大仰に思えるケースでuseReducer
を使ったことがあるが、却って複雑になって苦しむことになった。Async Action
の対応、Redux
におけるcombineReducers
に相当するものの用意、そういったことを自分で行う必要がある。そしてそうすると、型定義がどんどん面倒になってくる。
無駄に遠回りしているような気がしてくるし、素直にRedux
を使っておくのが身のためだと思う。自前で仕組みを用意すると他の開発者が理解しづらくなる、というデメリットもある。
簡単なログインフォームくらいであればuseState
で済むし、useReducer
を使うべきケースというのはかなり限定的ではないだろうか。
コンテナコンポーネントは、ルートコンポーネントであるsrc/components/App.tsx
のみ。
このサンプルの規模なら当然そうなるが、規模が大きくなっても、コンテナコンポーネントは出来るだけ少なくしたい。
もちろん、コンテナコンポーネントはいくつあってもいいし、コンポーネントツリーのどこにあってもよい。
Redux
の公式ドキュメントにも次のように書かれている。
Emphasizing “one container component at the top” in Redux examples was a mistake. Don't take this as a maxim.
Should I only connect my top component, or can I connect multiple components in my tree?
しかし個人的には、コンポーネントは出来るだけコンテナコンポーネントではなくプレゼンテーションコンポーネントにしておきたい。
コンポーネントを純粋関数にしておいたほうが保守が楽だし、再利用性やテストのしやすさという観点から見ても、Redux
とは接続せずに単にprops
を受け取るだけにしておきたい。
もちろん、「単一のコンテナコンポーネント」に拘泥すると却って煩雑になってしまう(props
バケツリレーの多発など)ので、必要に応じて複数のコンテナコンポーネントを作ってはいくが。
react-redux
のv7.1
で導入されたuseSelector
とuseDispatch
を使っている。
connect
を使うパターンに比べて、かなり直感的にコンポーネントとstore
を接続できる。
TypeScript はとにかく素晴らしい。
SPA 開発の技術選定の記事に対して、「とにかく TypeScript を入れろ」という力強い意見をいくつか頂いた。
SPAならTypeScript絶対に入れたほうがいい。絶対に / 他7件のコメント https://t.co/vQ2WlAlehx “SPA フルリニューアル計画における技術選定や設計思想(2019年2月版) - 30歳からのプログラミング” https://t.co/Hhalr09u7A
— preview state... (@mizchi) 2019年2月24日
素直にそれに従ってみたが、大正解だった。
VSCode の補完機能が優秀だし、ちゃんと型を書いておけば、間違ったコードを書いた時にエラーを出してくれる。これは本当に精神的に楽になる。脳内メモリは有限なので、「自分の頭で考え、注意を向けておかないといけない要素」を減らせるのはかなり助かる。
まだ TypeScript には習熟していないのだが、それでも既に大きな恩恵を受けている。
状態管理
Redux
に関連するコードはsrc/store
ディレクトリに入れてある。
ドメインでファイルを分割し、同じドメインのAction Creator
やReducer
は一箇所にまとめている。いわゆる「Ducks パターン」。
このパターンを採用したことにそれほど深い理由はない。単に一箇所にまとめたほうが見通しがよいから。
今後「Re-ducks パターン」などを調べて、そっちのほうがよさそうであればそれを採用する予定。
このサンプルではmember
とproducts
という2つのドメインがあり、それをsrc/store/index.ts
で一つにまとめている。
各ドメインのファイルでは、次の内容を定義している。例はsrc/store/products.ts
から紹介している。
- ドメインオブジェクトの型定義
- 例)
Product
- 例)
state
の型定義- 例)
ProductsState
- 例)
ActionTypes
の型定義(enum
型)- 例)
ActionTypes
- 例)
Action Creator
が発行するAction
の型定義Action
を継承する形で定義する- 例)
UpdateSelectedProductIdsAction
- そのドメインの
Action
の集合Reducer
の引数の型に使っている- 例)
ProductsActions
Action Creator
の型定義- 例)
UpdateSelectedProductIds
- 例)
state
の初期値- 例)
initialState
- 例)
Action Creator
- 例)
updateSelectedProductIds
- 例)
Reducer
- 例)
products
- 例)
state
を元に値を計算して返すビジネスロジック(導出項目)- 例)
extractPurchasableProductList
- 例)
- 引数として
state
の情報を必要とするが、Action
を発行するわけではない関数(導出項目?)- 例)
requestPurchase
- 例)
- 引数として
state
を受け取って導出項目を返すセレクタ関数- 例)
productsDerivedDataSelector
- 例)
最後の3つについては、後述の「ビジネスロジック」のセクションで詳しく説明する。
Async Action
の対応はredux-thunk
で行っている。
腐敗防止層
Action Creator
は腐敗防止層としての役割も担っている。
具体的には以下の作業を行っている。
src/store/products.ts
のfetchProductList
を例に説明していく。
- 必要な値を取捨選択する
fetchProductsApi
はmerchant_id
を返すが、フロント側ではこの値は必要ないので保存しない。
- プロパティ名の調整
- APIからの返り値はスネークケースであることも多いので、ここでローワーキャメルケースに変換する
fetchProductsApi
はプロダクトの名前をなぜかnamae
というローマ字で返しているので、name
に変換する
- データ構造などの調整
namae
の例は命名が適切ではないというケースだが、それ以外にも、構造がおかしい、バックエンドの実装を見ないと絶対に意味が分からないようなマジックワードを返している、などのケースも有り得るので、その調整もここで行う。
Action Creator
はAPIからのレスポンスを吸収しているが、APIへのリクエストのための調整も、一箇所に留める。
src/api.ts
のpurchaseApi
がそれにあたる。フロントエンドが持っているデータを、APIが求める形式に変換する。
APIとの調整はこれらの箇所に封じ込め、それ以外の箇所ではAPIとの調整を意識しないで済むようにする。
どのレイヤーで対応するか予め決めておき、APIの腐敗がフロントエンド全体に漏れ出すのを防ぐ。
ビジネスロジック
Redux
を使う場合、ビジネスロジックはどこに書けばよいのか。
ネットで調べてみたが、特に決まってなさそうである。
Action Creator
という意見が多い印象だけど、Reducer
に書いているケースもある。
Reducer
派が掲げる最大の理由は恐らく、ビジネスロジックがstate
を必要とするから、というものだと思う。
Action Creator
で使える値は、ビューから渡された引数のみ。
だから、ビジネスロジックがたくさんの値を必要とする場合、Action Creator
の引数が増えてしまうし、Action Creator
に渡すためにビューがデータを持たないといけない。
Action Creator
の引数として渡すためだけにビューが多くのデータを持つのは、違和感を覚えてしまう。
Reducer
なら、そういう面倒なことをせずにstate
にアクセスできる。
しかし、Reducer
がロジックを持つのは、本来の責務から逸脱しているように思えてしまう。
それに、ドメインを跨ぐ場合、例えばproducts
のReducer
がmember
のstate
を必要とする場合などは、結局そのための対応が必要になる。
結論としては、Reducer
にはビジネスロジックを入れないようにした。
また、上記の「ドメインを跨ぐ場合」については、Reducer
もAction Creator
もそういうケースには対応しないようにした。ただこれは、意図してそうしたわけではなく、結果的にそうなった。
意図したのは、「余計な値をstate
に保存しないようにする」ということ。
例えば、各商品の価格は保存するが、ディスカウント後の価格は保存しない。
両方を保存してしまうと、値の二重管理のようになってしまう。一方の値が更新されたのにもう一方の値は古いまま、のような事故が起きかねない。
元の価格さえ分かっていればディスカウント後の価格も定まるのだから、わざわざ保存しないようにする。
この「ディスカウント後の価格」のように、他の値から算出される値を「導出項目」という。
では、導出項目のように、state
に保存すべきではない値を使いたい場合は、どのようにすればいいのか。
言い換えれば、目的の値を算出するためのロジックをどこに書き、どのようにそれを呼び出すのか。
これは結構悩ましいというか、現時点では私は答えを持っていない。
いくつかの選択肢が思い浮かぶ。
Reducer
でロジックを持つ- これはさっき否定した。
- 値を必要とするコンポーネントのなかで計算する
- 再利用性が乏しく、ロジックが複数の場所に散らばってしまう。
- ビジネスロジックがビューに露出してしまうのは望ましくない。
Vuex
のgetter
のようなレイヤーを作るstate
とは別にgetter
を作って、その中にロジックを書くイメージ。- だがこれは、余計な複雑さを抱え込むことになるし、型をつけるのも大変になる。
- ロジックは決められた場所に書き、呼び出しは自由に行う
- 単なる関数として定義して、それを任意の場所で呼び出す。
- 副作用のない純粋関数であることを徹底すれば、破綻しにくいように思う。
- 引数として
state
を受け取り、算出した値を返すセレクタ関数を定義する4
の派生形というか、バラバラに書いていた処理を1つの関数にまとめたもの。
結局4
で行くことにした。積極的に選んだというより、思いついたなかで一番マシに思えたから。
置き場所は、sr/store/
直下の各ドメインのファイル。
src/store/products.ts
のextractPurchasableProductList
やcalculateTotalPrice
がこれにあたる。
「引数としてstate
の情報を必要とするが、Action
を発行するわけではない関数」も、同じように扱う。
src/store/products.ts
のrequestPurchase
がこれにあたる。
5
を使う形に書き換えた。具体的には、src/store/products.ts
にproductsDerivedDataSelector
というセレクタ関数を定義した。
サンプルではreselect
というライブラリを使っているが、設計という観点から見たときに重要なのはそこではなく、「state
を引数として受け取り、導出項目のセットを返す関数」を定義してそれを利用することが本質。
requestPurchase
もproductsDerivedDataSelector
の返り値に含めるようにした。
関数を「導出項目」のように扱うのには違和感があるが、requestPurchase
だけ扱いを変えると余計な複雑さが生まれそうなので、まとめることにした。
コンポーネント
ルートコンポーネントはsrc/components/App.tsx
。
このコンポーネントが、Redux Hooks
を使ったstore
との接続や、react-router
によるルーティングなどを、行っている。
もっと規模が大きくなってきたら、ルーティングについては分離させたほうがいいと思う。
productsDerivedDataSelector
による導出項目の取得も、ここで行っている。
他には、useFetchApi
を定義している。
これは、コンポーネントのマウント時にAPIを叩くためのCustom Hooks
。
マウントした時にのみデータ取得のためのAPIを叩く、という本当によくある処理。その処理を全てのコンポーネントで書くのは無駄なので、useFetchApi
としてまとめた。
ルーティングのためにURLを定数として持っているので、それを使うことで綺麗に書けたと思う。
全てのコンポーネントはFunctional Component
であり、前述のようにReact Hooks
を使っている。useState
とuseEffect
があれば、基本的な機能は実装できるはず。