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"]
这套"双探测"策略能分别捕捉到两类不同的错误:
-
INIT 探测:如果
reducer(undefined, INIT)返回undefined,说明该 reducer 没有定义默认状态。这能捕获最常见的 bug——在default分支中忘记设置初始状态。 -
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 行代码却让中间件链成为可能的函数:
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 用于横向组合函数(从右到左依次应用)。在下一篇文章中,我们将看到 compose 在 applyMiddleware 中的实际运用——Redux 中最优雅的 25 行代码,并逐步追踪中间件函数是如何被串联成一条完整的 dispatch 管道的。