Read OSS

深入 createStore:闭包、监听器与 dispatch 循环

中级

前置知识

  • 第 1 篇:架构与项目结构
  • JavaScript 闭包与高阶函数
  • 观察者模式(subscribe/unsubscribe)

深入 createStore:闭包、监听器与 dispatch 循环

在上一篇文章中,我们确认了 Redux 是一个基于闭包的状态容器,而非类继承体系。现在,让我们打开这个闭包,看看里面究竟是什么。createStore.ts 大约有 500 行,但其中将近一半是 JSDoc 注释和类型重载。真正的运行时逻辑出人意料地精简,每一行都有其存在的理由。

接下来我们将追踪完整的生命周期:参数如何被校验和整理、enhancer 模式如何短路 store 的创建过程、dispatch 如何驱动状态变更和监听器通知,以及那套精妙的双 Map 快照机制如何保证 dispatch 期间订阅变更的安全性。

函数签名与参数处理

createStore 接受两到三个参数:(reducer, preloadedState?, enhancer?)。但有一种常见的简写形式 —— 当不需要预加载状态时,直接将 enhancer 作为第二个参数传入。函数通过简单的类型判断来处理这种情况:

src/createStore.ts#L115-L129

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 则是一个去掉了该标签的同名函数:

src/createStore.ts#L487-L499

它实际上只是用相同的参数调用了 createStore,唯一的区别在于 IDE 里的提示信息。

六个闭包变量

正如第 1 篇预告的那样,store 的私有状态存放在六个 let 绑定中:

src/createStore.ts#L146-L153

变量 类型 用途
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 对象上的每一个方法 —— dispatchsubscribegetStatereplaceReducer —— 都通过闭包读写这同一套变量。

提示: currentState 的类型是 S | PreloadedState | undefined,而不仅仅是 S。这反映了一个现实:在 INIT dispatch 发生之前,状态可能是原始的预加载值(形状可能有所不同,例如 Partial<S>)。INIT 之后,reducer 保证它是 SgetState() 方法在返回时将其强制转换为 S,这是一个小小的、有意为之的类型谎言,目的是改善使用体验。

Enhancer 的短路机制

当传入 enhancer 时,createStore 会做一件出乎意料的事 —— 它立即返回,将所有 store 创建工作委托给 enhancer:

src/createStore.ts#L131-L144

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 是状态实际发生变化的地方。让我们一步步追踪它的执行过程:

src/createStore.ts#L280-L319

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 实例:

src/createStore.ts#L150-L151

currentListeners 是 dispatch 期间使用的快照,nextListeners 是 subscribe/unsubscribe 实际操作的目标。ensureCanMutateNextListeners 函数采用懒拷贝策略,只在即将发生变更时,才将 currentListeners 复制到一个新的 Map

src/createStore.ts#L162-L169

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 来重新初始化状态:

src/createStore.ts#L330-L346

这一机制支持热模块替换(HMR):当 bundler 热替换某个 reducer 模块时,store 可以在不丢失状态的情况下切换到新的 reducer。

observable 方法通过 Symbol.observable 实现了 TC39 Observable 协议,从而与 RxJS 等响应式库互操作:

src/createStore.ts#L354-L390

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 对象以普通对象字面量的形式组装并返回:

src/createStore.ts#L392-L405

没有类实例化,没有 new。就是一个对象,其中五个方法闭合在六个可变变量上。

下一篇

我们已经完整追踪了 createStore 的生命周期 —— 从参数解析,到 dispatch 循环,再到监听器通知。下一篇文章将聚焦于 combineReducers:它如何在创建时校验各个 slice reducer、运行时如何通过引用相等性避免不必要的对象创建,以及支撑整个中间件系统的 compose 工具函数。