Read OSS

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を受け取ると処理をそちらへ委譲します。

src/createStore.ts#L131-L144

呼び出しパターンは enhancer(createStore)(reducer, preloadedState) です。enhancerは次のような高階関数です。

  1. createStore を引数として受け取る
  2. 拡張された新しい createStore を返す
  3. その拡張版 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"]

型定義を見ると、この合成の仕組みがよくわかります。

src/types/store.ts#L220-L232

StoreEnhancer<Ext, StateExt> は2つの型パラメータを持ちます。Ext はストアに追加されるメソッド・プロパティ、StateExt はstateに追加されるプロパティです。enhancerを合成するたびに、これらの型は NextExt & ExtNextStateExt & 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 2next =>)は compose の実行時に1度だけ呼ばれます。next はチェーンの次のミドルウェアのdispatch関数、あるいは最後のミドルウェアの場合は生の store.dispatch です。

Layer 3action =>)は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 変数とクロージャの組み合わせこそが、このパターンを成り立たせる仕掛けです。

ヒント: middlewareAPIdispatch プロパティが dispatch を直接渡さず、(action, ...args) => dispatch(action, ...args) というラッパー関数になっている理由はここにあります。直接渡すと変数の現在値を捕捉してしまいますが、ラッパー経由にすることで参照の解決を呼び出し時まで遅延させ、常に最新の値を読み取れるようにしています。

実例で追うthunkミドルウェアの動き

実際のフローをテストシナリオで確認しましょう。

test/applyMiddleware.spec.ts#L47-L65

このストアにはspyとthunkの2つのミドルウェアが適用されています。addTodoAsync をdispatchすると、それは関数(thunk)を返します。フローはこうなります。

  1. store.dispatch(addTodoAsync('Use Redux')) が合成済みdispatchを呼び出す
  2. spyミドルウェアがthunk関数をアクションとして受け取り、spyOnMethods(action) を呼んでから next(action) に渡す
  3. thunkミドルウェアが関数を受け取り、typeof action === 'function' を検出して action(dispatch, getState) を呼ぶ
  4. thunk内部で最終的に dispatch(addTodo('Use Redux')) が呼ばれ、このアクションはチェーンの先頭から再び流れる(spy → thunk → store.dispatch)
  5. 今度はアクションがプレーンオブジェクトなので、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 というトリック、コードを実行せずに推論を検証する型レベルテストなど、読み応えのある内容が続きます。