Read OSS

combineReducers:Reducer 组合、校验与引用相等性优化

中级

前置知识

  • 第 1、2 篇文章
  • 了解 dispatch 如何调用根 reducer

combineReducers:Reducer 组合、校验与引用相等性优化

在第 2 篇文章中,我们看到 dispatch 会调用 currentReducer(currentState, action)——一个接收整棵状态树并返回下一棵状态树的函数。对于任何稍具规模的应用来说,这个"单一函数"实际上是由多个更小的 reducer 通过 combineReducers 组合而成的树状结构。Redux "组合优于配置"的设计理念,在这里得到了最直观的体现。

combineReducers 在两件事上做得极为出色:在创建阶段进行严格校验,让错误尽早暴露;在热路径(hot path)上通过引用相等性检查进行优化,避免在状态未发生变化时分配新的状态对象。接下来让我们深入了解其实现原理。

创建阶段的校验:assertReducerShape

当你调用 combineReducers({ todos: todosReducer, filters: filtersReducer }) 时,组合后的 reducer 并不会立即返回。Redux 会先逐一探测每个 slice reducer,验证其行为是否符合预期:

src/combineReducers.ts#L62-L94

flowchart TD
    A["assertReducerShape(reducers)"] --> B["For each reducer..."]
    B --> C["Call reducer(undefined, { type: ActionTypes.INIT })"]
    C --> D{"Returns undefined?"}
    D -- Yes --> E["throw Error:<br/>'returned undefined during initialization'"]
    D -- No --> F["Call reducer(undefined, { type: PROBE_UNKNOWN_ACTION() })"]
    F --> G{"Returns undefined?"}
    G -- Yes --> H["throw Error:<br/>'returned undefined for unknown action'"]
    G -- No --> I["✓ Reducer is valid"]

这套"双探测"策略能分别捕捉到两类不同的错误:

  1. INIT 探测:如果 reducer(undefined, INIT) 返回 undefined,说明该 reducer 没有定义默认状态。这能捕获最常见的 bug——在 default 分支中忘记设置初始状态。

  2. PROBE_UNKNOWN_ACTION 探测:这一步会发送一个随机生成的 action 类型(还记得第 2 篇中提到的 PROBE_UNKNOWN_ACTION() 吗?它是一个每次调用都会生成全新随机字符串的函数)。如果 reducer 对未知 action 返回 undefined,说明它要么显式处理了 @@redux/INIT 并对其他所有情况返回 undefined,要么使用了一个返回 undefined 的兜底分支。

为什么要用随机字符串?因为如果使用固定字符串,一个"不诚实"的 reducer 可能恰好匹配到该字符串并通过测试,而在面对真正未知的 action 时仍然出错。

提示: 这些探测只在调用 combineReducers 时执行一次。它们在任何用户 action 被 dispatch 之前就能发现配置错误。如果校验抛出异常,该错误会被捕获并在每次后续 dispatch 时重新抛出,即使是延迟初始化的场景也能立即暴露问题。

组合后的 Reducer 函数

校验通过后,combineReducers 返回 combination 函数——这才是每次 dispatch 时真正被调用的 reducer:

src/combineReducers.ts#L157-L201

运行时的逻辑是一个简洁的循环:

flowchart TD
    A["combination(state, action)"] --> B["hasChanged = false"]
    B --> C["For each key in finalReducerKeys"]
    C --> D["previousStateForKey = state[key]"]
    D --> E["nextStateForKey = reducer(previousStateForKey, action)"]
    E --> F{"nextStateForKey === undefined?"}
    F -- Yes --> G["throw Error"]
    F -- No --> H["nextState[key] = nextStateForKey"]
    H --> I["hasChanged = hasChanged OR<br/>nextStateForKey !== previousStateForKey"]
    I --> C
    C -- Done --> J{"hasChanged OR<br/>key count changed?"}
    J -- Yes --> K["return nextState"]
    J -- No --> L["return state ← same reference!"]

引用相等性优化体现在第 195–199 行:

src/combineReducers.ts#L195-L199

如果每个 slice reducer 都返回了与之前相同的状态(通过 === 引用比较),且 key 的数量没有变化,combineReducers 就会直接返回原始状态对象。这便是"无变化"时的快速路径。

这对 React-Redux 的性能至关重要。当 useSelector=== 比较前后状态时,相同的引用意味着不会触发重新渲染。如果 combineReducers 每次都创建新对象,那么每次 dispatch 都会导致所有已连接的组件重新渲染。

第 197–198 行还有一个细节值得注意:hasChanged 还会检查 finalReducerKeys.length !== Object.keys(state).length。这是为了应对状态中存在 reducer 映射表之外的额外 key 的情况——通常发生在 replaceReducer 切换到 slice 更少的 reducer 之后。

开发模式下的警告与缓存

在开发环境中,combination 还会额外检查状态对象中是否存在与任何 reducer 都不对应的"意外 key":

src/combineReducers.ts#L14-L60

unexpectedKeyCache 对象(第 145–148 行)确保每个意外 key 只会触发一次警告。没有这个缓存,每次 dispatch 都会重复输出相同的警告信息。该缓存作用域限定在 combineReducers 的闭包内,因此创建新的组合 reducer 会重置所有警告记录。

注意第 50 行的判断:当 action 类型为 ActionTypes.REPLACE 时,警告会被抑制。这是因为 replaceReducer 本身就会导致状态结构与新 reducer 映射表之间产生临时的不匹配,属于预期行为。

所有这些代码都包裹在 process.env.NODE_ENV !== 'production' 的条件判断中,在生产构建中会被完全剔除。

compose:函数式编程的基础原语

在下一篇文章进入 applyMiddleware 之前,我们需要先理解 compose——这个仅有 16 行代码却让中间件链成为可能的函数:

src/compose.ts#L46-L61

compose 实现的是从右到左的函数组合:compose(f, g, h) 的结果等价于 (...args) => f(g(h(...args)))。其内部使用 Array.reduce 将多个函数依次折叠组合。

flowchart LR
    A["compose(f, g, h)"] --> B["reduce: (a, b) => (...args) => a(b(...args))"]
    B --> C["Result: (...args) => f(g(h(...args)))"]

    subgraph "Execution order"
        H["h(...args)"] --> G["g(result)"] --> F["f(result)"]
    end

三种特殊情况的处理:

  • 零参数:返回恒等函数 (arg) => arg
  • 一个参数:直接返回该函数本身,不做任何包装
  • 两个及以上参数:使用 reduce 从右到左依次组合

类型重载(第 13–44 行)为最多 4 个函数提供了精确的返回类型,超出数量则回退到 (...args: any[]) => R。这是 TypeScript 中一种常见的处理方式,用于应对可变参数泛型无法精确表达类型关系的场景。

compose 正是组合多个 enhancer 的方式:createStore(reducer, compose(applyMiddleware(thunk), devTools()))。它在 applyMiddleware 内部也被用于构建中间件链——我们将在第 4 篇文章中详细探讨。

bindActionCreators:绑定 dispatch

bindActionCreators 是 Redux 中最简单的组合工具,它将 action creator 封装起来,使其在调用时自动触发 dispatch:

src/bindActionCreators.ts#L58-L83

实现支持两种调用形式:传入单个函数时返回单个绑定函数,传入函数对象时返回对应的绑定函数对象。核心的辅助函数只有四行:

src/bindActionCreators.ts#L9-L16

它使用 .apply(this, args) 来保留 this 绑定——这个细节在基于 class 的组件中尤为重要,因为 action creator 可能需要引用组件实例。显式声明的 this: any 参数是 TypeScript 用于指定 this 上下文类型的语法。

提示: bindActionCreators 主要用于将 action creator 传递给不应感知 Redux 存在的组件。在现代 React-Redux 的 hooks 体系中,useDispatch + 直接调用 dispatch(actionCreator()) 的模式已基本取代了它的使用场景。但在非 React 的上下文中,如果你希望将 dispatch 与 action 创建解耦,它依然是一个实用的工具。

下一步

我们已经介绍了两种核心组合工具——combineReducers 用于纵向组合 reducer(按状态切片),compose 用于横向组合函数(从右到左依次应用)。在下一篇文章中,我们将看到 composeapplyMiddleware 中的实际运用——Redux 中最优雅的 25 行代码,并逐步追踪中间件函数是如何被串联成一条完整的 dispatch 管道的。