createStore の内側:クロージャ、リスナー、そしてディスパッチサイクル
前提知識
- ›第1回:アーキテクチャとプロジェクト構成
- ›JavaScriptのクロージャと高階関数
- ›オブザーバーパターン(subscribe/unsubscribe)
createStore の内側:クロージャ、リスナー、そしてディスパッチサイクル
前回の記事では、Reduxがクラス階層ではなく、クロージャをベースにした状態コンテナであることを確認しました。今回はそのクロージャの中身を掘り下げます。createStore.ts はおよそ500行ありますが、その半分はJSDocコメントと型のオーバーロードです。実際のランタイムロジックは驚くほどコンパクトで、どの行にも明確な意図があります。
ここでは完全なライフサイクルを順に追っていきます。引数の検証と整理から始まり、エンハンサーパターンによるストア生成の短絡処理を経て、dispatch による状態遷移とリスナー通知の仕組みを解説します。最後に、ディスパッチ中のサブスクリプション変更を安全に扱うデュアルMapスナップショットのメカニズムに踏み込みます。
関数シグネチャと引数の整理
createStore は (reducer, preloadedState?, enhancer?) という形で2〜3個の引数を受け取ります。ただし、初期状態が不要な場合にエンハンサーを第2引数として渡すショートハンドが一般的です。この場合、関数内で単純な型チェックによって引数を振り分けます。
flowchart TD
A["createStore(reducer, arg2?, arg3?)"] --> B{"typeof arg2 === 'function'<br/>AND arg3 === undefined?"}
B -- Yes --> C["enhancer = arg2<br/>preloadedState = undefined"]
B -- No --> D{"Multiple enhancers<br/>detected?"}
D -- Yes --> E["throw Error:<br/>'compose them together'"]
D -- No --> F["enhancer = arg3<br/>preloadedState = arg2"]
C --> G["Continue to enhancer check"]
F --> G
この設計は実用的で、呼び出し側が createStore(reducer, undefined, enhancer) と書かずに済みます。ただし、その分だけ関数内の処理は複雑になります。また、複数のエンハンサーを compose() でまとめるべきところを別々の引数として渡してしまうミスも、ここでガードしています。
非推奨の扱いはTypeScriptの @deprecated JSDocタグによって createStore に付与されており、legacy_createStore は同一の実装に対してそのタグを外しただけのものです。
内部では同じ引数で createStore をそのまま呼び出しています。IDEに表示される警告があるかないか — 違いはそれだけです。
6つのクロージャ変数
第1回で触れたとおり、ストアのプライベートな状態は6つの let 変数に格納されています。
| 変数 | 型 | 役割 |
|---|---|---|
currentReducer |
Reducer<S, A> |
現在のルートreducer。replaceReducer で差し替えられる |
currentState |
S | PreloadedState | undefined |
現在の状態ツリー |
currentListeners |
Map<number, ListenerCallback> | null |
dispatch中に使用するリスナーのスナップショット |
nextListeners |
Map<number, ListenerCallback> |
subscribe/unsubscribeの変更が加えられる作業コピー |
listenerIdCounter |
number |
リスナーを一意に識別するための単調増加ID |
isDispatching |
boolean |
reduce中のdispatch呼び出しを防ぐ再入ガード |
システム全体で変化しうる状態はこの6変数だけです。返されるストアオブジェクトのすべてのメソッド — dispatch、subscribe、getState、replaceReducer — は、クロージャを通じてこれら6つの変数を読み書きします。
補足:
currentStateの型が単なるSではなくS | PreloadedState | undefinedになっているのは、INIT dispatchが実行される前の状態が未加工のプリロード値(Partial<S>のような形状を取りうる)である場合があるためです。INIT以降はreducerによってS型が保証されます。getState()が返り値をSにキャストしているのは、使い勝手のために意図的に行っている小さな妥協です。
エンハンサーによる短絡処理
エンハンサーが渡されると、createStore はすぐに処理を返し、ストアの生成をエンハンサーに委ねます。
sequenceDiagram
participant App
participant createStore
participant enhancer
participant createStore2 as createStore (round 2)
App->>createStore: (reducer, enhancer)
createStore->>enhancer: enhancer(createStore)
enhancer->>createStore2: createStore(reducer, preloadedState)
Note over createStore2: No enhancer this time —<br/>runs the full function
createStore2-->>enhancer: base store
enhancer-->>App: enhanced store
enhancer(createStore)(reducer, preloadedState) という呼び出しでは、エンハンサーが引数として createStore 自体を受け取ります。エンハンサーはそれを使ってベースとなるストアを取得し、変更を加えた上で返します。これが applyMiddleware を支える根本的なメカニズムです。
再帰的な構造に注目してください。createStore は2回呼ばれます — アプリからの呼び出し(エンハンサーあり)と、エンハンサーからの呼び出し(エンハンサーなし)です。2回目の呼び出しが関数本体の全処理を実行し、実際のストアを生成します。
dispatch():コアサイクル
状態が実際に変化するのは dispatch 関数の中です。順を追って見ていきましょう。
sequenceDiagram
participant Caller
participant dispatch
participant isPlainObject
participant Reducer
participant Listeners
Caller->>dispatch: dispatch(action)
dispatch->>isPlainObject: isPlainObject(action)?
alt Not a plain object
isPlainObject-->>dispatch: false
dispatch-->>Caller: throw Error
end
dispatch->>dispatch: typeof action.type === 'string'?
dispatch->>dispatch: isDispatching === false?
dispatch->>dispatch: isDispatching = true
dispatch->>Reducer: currentReducer(currentState, action)
Reducer-->>dispatch: nextState
dispatch->>dispatch: isDispatching = false
dispatch->>dispatch: currentListeners = nextListeners
dispatch->>Listeners: forEach(listener => listener())
dispatch-->>Caller: return action
バリデーションは厳格です。actionはプレーンオブジェクト(クラスインスタンス、Promise、その他の特殊な型は不可)でなければならず、type プロパティを持ち、その型は文字列である必要があります。Redux v5ではこの最後の条件が強化されました。以前のバージョンではシンボルなど他の型も受け付けていました。
isPlainObject チェックも確認しておきましょう。
src/utils/isPlainObject.ts#L5-L16
プロトタイプチェーンをルートまでたどり、オブジェクトの直接のプロトタイプと照合します。これにより、Object.create(null)(プロトタイプなし)や標準的な {} オブジェクトは正しく識別される一方、クラスインスタンス、配列、その他の複合オブジェクトは弾かれます。
isDispatching ガードは、reducer実行中に dispatch が呼ばれること(無限ループにつながる再入処理)を防ぎます。try/finally によって、reducerが例外を投げてもフラグは確実にリセットされます。
reducerが戻り値を返した後、const listeners = (currentListeners = nextListeners) という一行がリスナーリストをアトミックにスナップショットしてイテレートします。ここから createStore の最も巧妙な部分に入ります。
リスナースナップショット:デュアルMapパターン
Reduxが解決しなければならない難しい問題があります。リスナーが通知ループ中に自分自身(または他のリスナー)をunsubscribeするケースです。リストをイテレートしながら要素を削除すると、リスナーがスキップされたりインデックスエラーが発生したりします。
解決策は、2つの Map インスタンスを使ったコピーオンライト方式です。
currentListeners はdispatch中に使われるスナップショットです。nextListeners はsubscribe/unsubscribeによる変更が加えられる作業コピーです。ensureCanMutateNextListeners 関数は、変更が必要になったタイミングで currentListeners を新しい Map に遅延コピーします。
flowchart TD
A["subscribe(listener)"] --> B{"nextListeners === currentListeners?"}
B -- Yes --> C["Copy currentListeners to new Map"]
B -- No --> D["Already diverged, safe to mutate"]
C --> D
D --> E["nextListeners.set(id, listener)"]
F["dispatch(action)"] --> G["currentListeners = nextListeners"]
G --> H["Iterate currentListeners"]
H --> I["Listeners may call subscribe/unsubscribe"]
I --> J["Mutations go to nextListeners<br/>(diverged from currentListeners)"]
Redux v5ではリスナーの格納先が配列から Map に変更されました。配列には微妙なバグがありました。dispatch中にリスナーがunsubscribeされると、配列の splice によってインデックスがずれ、別のリスナーがスキップされる可能性があったのです。Map.forEach はエントリを挿入順に訪問し、削除もきれいに扱いますが、そもそもReduxはそれに頼る必要すらありません。変更は常にもう一方の Mapに加えられるからです。
このふるまいはテストで明示的に検証されています。
test/createStore.spec.ts#L290-L344
「dispatch中にunsubscribeされても、すべてのサブスクライバーに通知する」というテストでは、3つのリスナーを用意し、listener2がdispatch中に3つ全員をunsubscribeします。それでも3つすべてがその回のdispatchでは呼ばれます — スナップショットに含まれていたからです。そして次のdispatchではどれも呼ばれません。
補足: unsubscribe関数の中で
currentListeners = nullとセットしているのは(251行目)、メモリの最適化です。unsubscribe後は古いスナップショットをdispatchが使うことはないため、参照をnullにしてガベージコレクションを促します。
ランダム化された内部アクションタイプ
createStore の初期化時、初期状態を構築するために @@redux/INIT という特殊なactionがdispatchされます。しかし、ユーザーのreducerに "@@redux/INIT" の分岐があったとしたら?内部actionを意図せず横取りしてしまうバグが生まれます。
その解決策がランダム化です。
src/utils/actionTypes.ts#L1-L17
INIT は @@redux/INIT3.h.j.2.k のような、モジュールのロードごとに異なる値になります。PROBE_UNKNOWN_ACTION はさらに徹底されており、呼び出しのたびに新しいランダムなサフィックスを生成する関数です。これは combineReducers がスライスreducerの未知のaction処理を検証するために使われます(詳細は第3回で)。
randomString() に付いている /* #__PURE__ */ アノテーションは、この呼び出しに副作用がないことをminifierに伝え、デッドコードの除去を助けます。
replaceReducer とObservableプロトコル
ストアAPIを締めくくるのは2つのメソッドです。replaceReducer はルートreducerを差し替え、@@redux/REPLACE actionをdispatchして状態を再初期化します。
これによりホットモジュールリプレースメントが実現します。bundlerがreducerモジュールをホットスワップした際に、ストアは状態を失わずに新しいreducerを取り込めます。
observable メソッドは Symbol.observable を通じてTC39 Observableプロトコルを実装しており、RxJSや他のリアクティブライブラリとの相互運用を可能にします。
sequenceDiagram
participant RxJS
participant Store Observable
participant subscribe
participant getState
RxJS->>Store Observable: subscribe(observer)
Store Observable->>getState: getState()
Store Observable->>RxJS: observer.next(initialState)
Store Observable->>subscribe: subscribe(observeState)
Note over subscribe: On every dispatch...
subscribe->>getState: getState()
subscribe->>RxJS: observer.next(newState)
$$observable シンボルはインポート時に symbol-observable.ts から解決されます。ネイティブの Symbol.observable サポートがあればそれを使い、なければ @@observable という文字列表現にフォールバックします。
最後に、ストアオブジェクトはプレーンなオブジェクトリテラルとして組み立てられて返されます。
クラスのインスタンス化はなく、new もありません。6つのミュータブルな変数をクロージャで抱えた、5つのメソッドを持つオブジェクトがあるだけです。
次回予告
createStore の完全なライフサイクル — 引数の解析からdispatchサイクル、リスナー通知まで — を追い終えました。次回は combineReducers を取り上げます。スライスreducerを生成時にどう検証するか、実行時の不要なオブジェクト生成を防ぐ参照等価性のトリック、そしてミドルウェアシステム全体を支える compose ユーティリティについて深掘りします。