深入 createStore:闭包、监听器与 dispatch 循环
前置知识
- ›第 1 篇:架构与项目结构
- ›JavaScript 闭包与高阶函数
- ›观察者模式(subscribe/unsubscribe)
深入 createStore:闭包、监听器与 dispatch 循环
在上一篇文章中,我们确认了 Redux 是一个基于闭包的状态容器,而非类继承体系。现在,让我们打开这个闭包,看看里面究竟是什么。createStore.ts 大约有 500 行,但其中将近一半是 JSDoc 注释和类型重载。真正的运行时逻辑出人意料地精简,每一行都有其存在的理由。
接下来我们将追踪完整的生命周期:参数如何被校验和整理、enhancer 模式如何短路 store 的创建过程、dispatch 如何驱动状态变更和监听器通知,以及那套精妙的双 Map 快照机制如何保证 dispatch 期间订阅变更的安全性。
函数签名与参数处理
createStore 接受两到三个参数:(reducer, preloadedState?, enhancer?)。但有一种常见的简写形式 —— 当不需要预加载状态时,直接将 enhancer 作为第二个参数传入。函数通过简单的类型判断来处理这种情况:
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) 这样的代码。当然,这也带来了额外的复杂度。函数还会拦截一个常见错误:将多个 enhancer 作为独立参数传入,而不是用 compose() 组合它们。
废弃逻辑通过 TypeScript 的 @deprecated JSDoc 标签作用于 createStore,而 legacy_createStore 则是一个去掉了该标签的同名函数:
它实际上只是用相同的参数调用了 createStore,唯一的区别在于 IDE 里的提示信息。
六个闭包变量
正如第 1 篇预告的那样,store 的私有状态存放在六个 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 的守卫标志 |
这六个变量是整个系统中唯一的可变状态。store 对象上的每一个方法 —— dispatch、subscribe、getState、replaceReducer —— 都通过闭包读写这同一套变量。
提示:
currentState的类型是S | PreloadedState | undefined,而不仅仅是S。这反映了一个现实:在 INIT dispatch 发生之前,状态可能是原始的预加载值(形状可能有所不同,例如Partial<S>)。INIT 之后,reducer 保证它是S。getState()方法在返回时将其强制转换为S,这是一个小小的、有意为之的类型谎言,目的是改善使用体验。
Enhancer 的短路机制
当传入 enhancer 时,createStore 会做一件出乎意料的事 —— 它立即返回,将所有 store 创建工作委托给 enhancer:
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) 这个调用意味着 enhancer 接收到的是 createStore 本身。它可以调用它来获取一个基础 store,对其进行修改,再返回修改后的版本。这正是 applyMiddleware 的核心运作机制。
注意这里的递归结构:createStore 被调用了两次 —— 第一次由你的应用调用(携带 enhancer),第二次由 enhancer 调用(不携带 enhancer)。第二次调用才会真正走完函数体,创建出实际的 store。
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 属性,且 type 必须是字符串。Redux v5 收紧了最后这条检查 —— 早期版本还接受 symbol 等其他类型。
isPlainObject 的检查逻辑值得细看:
src/utils/isPlainObject.ts#L5-L16
它沿着原型链一路向上走到根节点,再检查对象的直接原型是否与之匹配。这样可以正确识别 Object.create(null) 对象(无原型)和标准的 {} 对象,同时拒绝类实例、数组和其他复杂对象。
isDispatching 守卫防止 reducer 在执行期间调用 dispatch —— 这种重入会造成无限循环。try/finally 确保即使 reducer 抛出异常,该标志也能被重置。
reducer 返回后,关键的一行 const listeners = (currentListeners = nextListeners) 原子地对监听器列表做了快照并进行遍历。这就引出了 createStore 中最微妙的部分。
监听器快照:双 Map 模式
Redux 必须处理一个棘手的场景:监听器在通知循环中取消订阅自身(或其他监听器)。如果在遍历列表时中途删除元素,就可能跳过某些监听器或引发索引错误。
解决方案是基于写时复制的双 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 期间取消订阅时,数组 splice 会导致索引偏移,可能使某个监听器被跳过。Map.forEach 按插入顺序遍历条目,对删除操作也有更好的容忍性 —— 但 Redux 甚至不需要依赖这一点,因为所有变更都发生在另一个 Map 上。
测试文件对这一行为有明确的验证:
test/createStore.spec.ts#L290-L344
测试用例「notifies all subscribers about current dispatch regardless if any of them gets unsubscribed in the process」设置了三个监听器,其中 listener2 在 dispatch 期间取消了所有三个监听器的订阅。但这三个监听器在那次 dispatch 中仍然全部触发 —— 因为它们都在快照中 —— 而在下一次 dispatch 时则一个都不会触发。
提示: 在 unsubscribe 函数中将
currentListeners置为null(第 251 行)是一种内存优化。取消订阅后,旧快照不再被 dispatch 所需要,将引用置空可以让垃圾回收器及时释放内存。
随机化的内部 action 类型
createStore 初始化时会 dispatch 一个特殊的 @@redux/INIT action 来填充初始状态。但如果用户的 reducer 恰好对 "@@redux/INIT" 做了处理怎么办?这会是一个 bug —— 用户的 reducer 会意外拦截内部 action。
解决方案是随机化:
src/utils/actionTypes.ts#L1-L17
INIT 会变成类似 @@redux/INIT3.h.j.2.k 这样的字符串 —— 每次模块加载时都不同。PROBE_UNKNOWN_ACTION 更进一步:它是一个函数,每次调用都会生成新的随机后缀,供 combineReducers 用来验证 reducer 是否正确处理了未知 action(更多内容将在第 3 篇介绍)。
randomString() 前的 /* #__PURE__ */ 注释告诉压缩工具这个调用没有副作用,从而实现更好的死代码消除。
replaceReducer 与 Observable 协议
store API 还有两个方法。replaceReducer 替换根 reducer,并 dispatch 一个 @@redux/REPLACE action 来重新初始化状态:
这一机制支持热模块替换(HMR):当 bundler 热替换某个 reducer 模块时,store 可以在不丢失状态的情况下切换到新的 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 在导入时从 symbol-observable.ts 解析,该文件会优先检测原生 Symbol.observable 支持,若不存在则回退到 @@observable 字符串约定。
最后,store 对象以普通对象字面量的形式组装并返回:
没有类实例化,没有 new。就是一个对象,其中五个方法闭合在六个可变变量上。
下一篇
我们已经完整追踪了 createStore 的生命周期 —— 从参数解析,到 dispatch 循环,再到监听器通知。下一篇文章将聚焦于 combineReducers:它如何在创建时校验各个 slice reducer、运行时如何通过引用相等性避免不必要的对象创建,以及支撑整个中间件系统的 compose 工具函数。