applyMiddleware を25行で読み解く:ミドルウェアパイプラインとストア拡張
前提知識
- ›第1〜3回の記事
- ›高階関数とクロージャ
- ›ミドルウェアの基本概念(Express、Koaなど)
applyMiddleware を25行で読み解く:ミドルウェアパイプラインとストア拡張
ここまでの連載で、Reduxの土台となる仕組みをしっかりと理解してきました。クロージャベースのストア、dispatchサイクル、reducerの合成、そして compose ユーティリティです。今回はいよいよ、Reduxをシンプルな状態コンテナから拡張可能なプラットフォームへと押し上げた核心部分、applyMiddleware に踏み込みます。
実装のランタイムコードはわずか25行。しかしその25行には、JavaScriptオープンソース界屈指のクロージャテクニックが凝縮されています。これを理解することで、なぜReduxミドルウェアがReactアプリにおける副作用処理の定番パターンになったのかが見えてきます。
StoreEnhancerパターン
applyMiddleware の詳細へ入る前に、それが実装する抽象化である StoreEnhancer を把握しておきましょう。第2回の記事で触れたように、createStore がenhancerを受け取ると処理をそちらへ委譲します。
呼び出しパターンは enhancer(createStore)(reducer, preloadedState) です。enhancerは次のような高階関数です。
createStoreを引数として受け取る- 拡張された新しい
createStoreを返す - その拡張版
createStoreは元のcreateStoreを内部で呼び出し、得られたストアを加工して返す
flowchart TD
A["enhancer(createStore)"] --> B["enhancedCreateStore"]
B --> C["enhancedCreateStore(reducer, preloadedState)"]
C --> D["Calls original createStore internally"]
D --> E["Gets base store"]
E --> F["Modifies/wraps store methods"]
F --> G["Returns enhanced store"]
型定義を見ると、この合成の仕組みがよくわかります。
StoreEnhancer<Ext, StateExt> は2つの型パラメータを持ちます。Ext はストアに追加されるメソッド・プロパティ、StateExt はstateに追加されるプロパティです。enhancerを合成するたびに、これらの型は NextExt & Ext、NextStateExt & StateExt というインターセクション型として積み重なっていきます。
applyMiddleware を1行ずつ読む
完全なランタイム実装はこちらです。
src/applyMiddleware.ts#L53-L77
各ステップを順に見ていきましょう。
sequenceDiagram
participant App
participant applyMiddleware
participant createStore
participant Middleware A
participant Middleware B
participant compose
App->>applyMiddleware: applyMiddleware(mwA, mwB)
Note over applyMiddleware: Returns a StoreEnhancer
applyMiddleware->>createStore: createStore(reducer, preloadedState)
createStore-->>applyMiddleware: base store
Note over applyMiddleware: Create throwing dispatch<br/>(safety during setup)
applyMiddleware->>Middleware A: mwA({ getState, dispatch })
Middleware A-->>applyMiddleware: chainA (next => action => ...)
applyMiddleware->>Middleware B: mwB({ getState, dispatch })
Middleware B-->>applyMiddleware: chainB (next => action => ...)
applyMiddleware->>compose: compose(chainA, chainB)(store.dispatch)
compose-->>applyMiddleware: composed dispatch
Note over applyMiddleware: Reassign dispatch variable!
applyMiddleware-->>App: { ...store, dispatch: composedDispatch }
Step 1: ベースストアの生成(57行目)。applyMiddleware 自体がenhancerなので、元の createStore をenhancerなしで呼び出します。
Step 2: エラーをthrowするdispatchの用意(58〜63行目)。ミドルウェアチェーンが未完成の状態でdispatchが呼ばれると問題が起きます。そのためセットアップ中は、dispatchを呼ぶと例外をthrowする関数を初期値として設定します。これにより、以下のテストで確認できるようなバグを防ぎます。
test/applyMiddleware.spec.ts#L9-L20
Step 3: middlewareAPIの構築(65〜68行目)。各ミドルウェアは { getState, dispatch } を受け取ります。ここで重要なのは、この dispatch がthrowする関数の直接参照ではなく、その時点で dispatch 変数が指している関数を呼び出すラッパー関数だという点です。
Step 4: 各ミドルウェアの初期化(69行目)。middleware(middlewareAPI) を呼ぶと、next => action => ... という形の関数が返ります。
Step 5: チェーンの合成(70行目)。compose(...chain)(store.dispatch) で右から左にミドルウェアをつなぎ、最も内側の next としてベースの store.dispatch を渡します。
Step 6: dispatchの再代入(70行目)。let dispatch 変数に合成済みのチェーンが代入されます。
Step 7: 拡張されたストアの返却(72〜75行目)。ベースストアをスプレッドし、dispatch を合成済みのものに差し替えて返します。
ミドルウェアのシグネチャ:store => next => action
Reduxのミドルウェアはすべて、3段階のカリー化関数です。テストヘルパーのthunkミドルウェアを例に、各レイヤーを解説します。
test/helpers/middleware.ts#L1-L9
flowchart TD
subgraph "Layer 1: Setup"
A["({ dispatch, getState }) =>"]
end
subgraph "Layer 2: Chain building"
B["(next) =>"]
end
subgraph "Layer 3: Action handling"
C["(action) => ..."]
end
A --> B --> C
D["middlewareAPI"] --> A
E["next middleware's<br/>action handler<br/>(or store.dispatch)"] --> B
F["Dispatched action"] --> C
Layer 1({ dispatch, getState } =>)は applyMiddleware のセットアップ時に1度だけ呼ばれます。ミドルウェアはここでAPIをクロージャに捕捉します。
Layer 2(next =>)は compose の実行時に1度だけ呼ばれます。next はチェーンの次のミドルウェアのdispatch関数、あるいは最後のミドルウェアの場合は生の store.dispatch です。
Layer 3(action =>)はdispatchのたびに呼ばれます。実際にアクションを処理する「ホット」な関数です。
この「オニオンモデル」では、アクションが各レイヤーを内側へと通り抜け、また外側へと戻ってきます。
flowchart LR
A["Action dispatched"] --> B["Middleware A<br/>(outer)"]
B --> C["Middleware B<br/>(inner)"]
C --> D["store.dispatch<br/>(base)"]
D --> E["Reducer"]
E --> F["New state"]
各ミドルウェアは以下のことができます。
nextに渡す前にアクションを加工するnextを呼ばずにアクションを遮断するnextを呼んだ後に追加処理を行う- Layer 1 の
dispatchを使って別のアクションをdispatchする
クロージャの妙技:再帰的なdispatch
applyMiddleware の巧みさは、65〜68行目に凝縮されています。
const middlewareAPI: MiddlewareAPI = {
getState: store.getState,
dispatch: (action, ...args) => dispatch(action, ...args)
}
この dispatch は、throwするプレースホルダーへの直接参照ではありません。let 変数である dispatch を呼び出すラッパー関数です。セットアップ時点では dispatch 変数はthrowする関数を指していますが、ミドルウェアが実際に middlewareAPI.dispatch を呼び出す頃には、70行目の処理によって dispatch は合成済みのチェーンに差し替えられています。
sequenceDiagram
participant Thunk
participant middlewareAPI.dispatch
participant dispatch variable
participant Full Chain
Note over dispatch variable: Initially: throwing fn
Note over dispatch variable: After compose: full chain
Thunk->>middlewareAPI.dispatch: dispatch(someAction)
middlewareAPI.dispatch->>dispatch variable: dispatch(someAction)
dispatch variable->>Full Chain: Goes through ALL middleware
Full Chain->>Thunk: Result
つまり、thunk内で dispatch(anotherAction) を呼ぶと、そのアクションはチェーンの途中からではなく、先頭から全ミドルウェアを通過します。thunkがthunkをdispatchし、そのthunkがプレーンなアクションをdispatchする、という再帰的なパターンが実現できるのはこのためです。
もし middlewareAPI.dispatch がベースの store.dispatch を直接参照していたり、特定の時点での合成済みdispatchを固定で参照していたりした場合、thunkがdispatchするthunkはミドルウェアチェーンを通過できません。ミュータブルな let 変数とクロージャの組み合わせこそが、このパターンを成り立たせる仕掛けです。
ヒント:
middlewareAPIのdispatchプロパティがdispatchを直接渡さず、(action, ...args) => dispatch(action, ...args)というラッパー関数になっている理由はここにあります。直接渡すと変数の現在値を捕捉してしまいますが、ラッパー経由にすることで参照の解決を呼び出し時まで遅延させ、常に最新の値を読み取れるようにしています。
実例で追うthunkミドルウェアの動き
実際のフローをテストシナリオで確認しましょう。
test/applyMiddleware.spec.ts#L47-L65
このストアにはspyとthunkの2つのミドルウェアが適用されています。addTodoAsync をdispatchすると、それは関数(thunk)を返します。フローはこうなります。
store.dispatch(addTodoAsync('Use Redux'))が合成済みdispatchを呼び出す- spyミドルウェアがthunk関数をアクションとして受け取り、
spyOnMethods(action)を呼んでからnext(action)に渡す - thunkミドルウェアが関数を受け取り、
typeof action === 'function'を検出してaction(dispatch, getState)を呼ぶ - thunk内部で最終的に
dispatch(addTodo('Use Redux'))が呼ばれ、このアクションはチェーンの先頭から再び流れる(spy → thunk → store.dispatch) - 今度はアクションがプレーンオブジェクトなので、thunkは
next(action)を呼び、ベースのstore.dispatchまで届く
spyが2回呼ばれること(thunkに対して1回、プレーンオブジェクトに対して1回)から、再帰的なdispatchがチェーン全体を通過していることが確認できます。
119〜151行目のテストはさらに踏み込んで、dispatch への追加引数がチェーン全体を通じて保持されることを示しています。これは高度なミドルウェアパターンを実装する際に重要な挙動です。
次回予告
25行のコードがどれほど強力な拡張機構を生み出すか、その全貌が見えてきたでしょうか。鍵となる要素は3つ、クロージャ(ミュータブルなdispatch変数)、高階関数(3層のミドルウェアシグネチャ)、そして関数合成(チェーンをつなぐ compose の呼び出し)です。
次回はランタイムの動作からTypeScriptの型システムへと視点を移します。Reduxの型定義は想像以上に洗練されていて、reducerマップからstateの型を推論するconditional typeや UnknownIfNonSpecific というトリック、コードを実行せずに推論を検証する型レベルテストなど、読み応えのある内容が続きます。