結論としてはthe-storeという俺様パッケージに落ち着いたのだけれど、その背景と理由と目的を以下にまとめた。
Reduxは素晴らしい。
成り行きで超絶的な進化をとげ複雑怪奇した現代のフロントエンド開発において、明確な原理を掲げ単純な実装と豊富なドキュメントに大量の実装例、革新的な開発ツールをぶら下げて登場し、積年の課題だった状態管理に対して明快な解を提示した。
もうReduxなしのUI開発なんて考えられない。もうどっぷり依存してしまおう。
そう決心してしばらくがっつり本格的に使ってみた。
そして思った。
めんどくせぇ。。。
Reduxで実装する時に生じた不満点
1. Action Typeが阿鼻叫喚
Redux(というかFlux)において全ての状態の変化はActionによって引き起こされる。
それぞれのActionはReducerでの判別に使うための種別をもつ。ActionTypeだ。
ActionTypeはstoreにおいて一意である必要がある。storeの管轄内での状態変化の種類だけActionTypeを定義する必要がある。本格的なアプリケーションにおいて一つのstoreの管理する状態変化の数は膨大だ。
結果、ちゃんと実装するとActionTypeはとんでもない数になる。しかも一意性を担保するためにそれぞれがめっちゃ長い名前になる。
しかも開発する時に事前に全て洗いだすのは現実的ではない。試行錯誤で手探りしつつだんだん足していくことになる。一方、一度足したActionTypeはDispatcherやReducerからがっつり依存しているからそう簡単に変えられない。
結果、莫大でよくわからない巨大な定義ファイルができあがる。いやはやその。
2. Reducerが既視感全開
ReduxにおいてDispatcherから渡されたActionをStateにどう格納するかの整形を担うのがReducerだ。現状のStateとActionを受け取り、ActionTypeに応じて加工する。
これはObjectのImmutabilityを確保しレンダリングを最適化する上で実によくできた手法である。
Pureな関数を守ることで結合を疎にテストも容易になる。
ただ、本格的に実装してみると、似たようなロジックが乱立するという事態が多発した。
一応、公式のドキュメントにはReducerのLogicの再利用については記述がある。Reducer関数を作成するFactory関数を用意するというものだ。
function createCounterWithNamedType(counterName = '') {
return function counter(state = 0, action) {
switch (action.type) {
case `INCREMENT_${counterName}`:
return state + 1;
case `DECREMENT_${counterName}`:
return state - 1;
default:
return state;
}
}
}
function createCounterWithNameData(counterName = '') {
return function counter(state = 0, action) {
const {name} = action;
if(name !== counterName) return state;
switch (action.type) {
case `INCREMENT`:
return state + 1;
case `DECREMENT`:
return state - 1;
default:
return state;
}
}
}
実際やってみたが、これはめちゃくちゃデバッグしづらい。
確かに共通化はできるのだが、「結果何が起こるのか」がとてもわかりにくし、ブラウザ上でブレイクポイントを置く手法も取りづらくなってしまう
3. Actionの中身がカオス
FluxにおけるActionの型に関してはFSAという型標準があり、redux-actionsを使えばこれに沿った形でのAction定義が比較的に簡単にできる。
問題はその後だ。
FSAにおける"payload"や"meta"の中身をどうするか。結局、Reducerの中で使うのはこの中身だ。ということはこの中の構造がReducerと足並みを合わせなければならない。
そう、依存することになるだ。
せっかくDispatchとReducerを分けたのに。わざわざActionTypeを外に切り出したのに。
どうDispatchされるか気にしながらReducerを書く、もしくは、どうReduceされるか考えながらDispatcherを書かなければならない。
意味なくね?
巨大になって役割分担したのに結局見比べるとかめんどくさ過ぎる。
もちろん先に全部型をきちんと定義できれば問題ないのだろうが、正直現実的ではない。結局手探りであっちを直し、こっちを直しを試しながらにならざるを得ない。そうすると半端に分離したのが逆に手間になる。
というのが実際にやってみて思ったところ。
そこでひねり出した解決策
割と真剣に丸三日ほど悩んでひねり出したのが以下の策
- 単一のstoreを擬似的なscopeに分割する
- ReducerはScope単位で定義する
- DispatcherとReducerを一対一にする
- DispatcherとReducerに共通の名前を持たせ、定義を一箇所にまとめる
- Scope名+Dispatcher名(兼Reducer名)を持ってActionTypeとする
- ActionのPayloadはDispatcher関数の引数をそのまま配列としたものにする
- Scopeをクラス化して担当範囲のstateへのアクセスと、Dispatch/Reduceを担うようにする
というもの。ごちゃごちゃと長くなってしまったがこれらは個別の対策ではなくまとめて初めて効果を発揮する。実際にライブラリ化したものがthe-storeだ。
これを使うと以下のようにstoreをScope分割することができるようになる
'use strict'
const theStore = require('the-store')
const { Scope } = theStore
// Scoped state class
class CounterScope extends Scope {
// Define initial state
static get initialState () {
return 0
}
// Define reducer factory
static get reducerFactories () {
return {
increment (amount) {
return (state) => state + amount
}
}
}
}
async function tryExample () {
let store = theStore()
// Create state instance and attach to the store
store.load(CounterScope, 'counterA')
store.load(CounterScope, 'counterB')
{
// Access to loaded store
let { counterA } = store
// Each instance has dispatcher methods which share signatures with reducerFactories
counterA.increment(1) // This will dispatch action and reduce into state
// Access to the scoped state
console.log(counterA.state) // -> 1
}
// Store it self has all state
console.log(store.state) // -> { counterA: 1 }
}
tryExample().catch((err) => console.error(err))
具体的な挙動については以下順次。
ReducerとDispatcherの統一
前述の問題点として挙げた通り、経験上DispatcherとReducerはそれほどうまく分離できない。
ならば割り切って一対一だと決めつけ、送り手と受け手に同じ名前をつけてしまえばいい。
例えばReduxのチュートリアルに出てくるような簡単なCounterを定義する時。
現状の値に渡された値を足してincrementするようなものを考えてみる。
class CounterScope extends Scope {
static get reducerFactories () {
return {
increment (amount) {
return (state) => state + amount
}
}
}
}
このScope
はstaticメンバーとしてreducerFactories
をもち、その中には名前付きのreducer生成関数が含まれている。the-storeにおいてこれらはreducer生成と同時に、dispatcherのシグネチャも兼ねる。
上記のCounterScope
例だと、reducerFactories.increment(amount)
は、
- incrementに対するReduce処理
- CounterScopeインスタンスのDispatcherのシグネチャ
の二つを意味する。
Class化によるReducerロジックの共通化
Reducerロジックの共通化の方法としては、Classベースを用いることにした。関数Factoryは正直わかりにくい。
以下のようにstore.load(ScopeClass, name)
メソッドを用いることで、インスタンスを生成し、storeに紐づけることができる。
async function tryExample () {
let store = theStore()
// Create state instance and attach to the store
store.load(CounterScope, 'counterA')
store.load(CounterScope, 'counterB')
}
scopeの名称は
- storeにおけるscopeインスタンスの名前
- storeのもつstateの名前空間
の二つを兼ねている。
{
// Access to loaded store
let { counterA } = store
// Each instance has dispatcher methods which share signatures with reducerFactories
counterA.increment(1) // This will dispatch action and reduce into state
// Access to the scoped state
console.log(counterA.state) // -> 1
}
// Store it self has all state
console.log(store.state) // -> { counterA: 1 }
store.load(CounterScope, 'counterA')
によって
-
store.counterA
にインスタンスを登録 -
store.state.counterA
には前述のインスタンスの担当範囲のデータが格納される
わけだ。
ActionTypeからの解放
上記の例では、counterA.increment(1)
の実行によってstore.state.counterA
の値が書き換わっている。
Actionを介在していないようにも見えるが、裏ではきちんと従来のReduxのActionがDispatchされReduceされている。
具体的には
{
"type": "counterA/increment",
"payload": [1]
}
というActionであり、
CounterScope.reduceFactory.increment
が返却した
(state) => state + amount`
のReducerが処理をしてstore.state.counterA
を書き換えているのだ。
payloadが配列なのは、関数としてのincrementの引数をそのままハンドリングするためだ。
これまで通りのactionをwrapしているだけなので、redux-devtoolもきちんと動く。
ActionTypeである"counterA/increment"は自動生成されたわけだが、見ての通り非常に単純なルール (scopeのインスタンス名とメソッド名を繋ぐだけ)なので、Actionから発火元を探すのも非常に簡単だ。
さらに巨大なアプリの場合
さらに複雑化した場合、scopeをさらにscope分けしたくなる
のでnestedもサポートするようにした。説明は長くなるのでここでは省略。以下参照。
まとめ
- Reduxはよくできているけど実装が大変
- 割り切って省略できるところは省略しよう
- 結局俺様ライブラリという結論