Redux 的 combineReducers 完全指南:切片 Reducer 组合、状态形状设计与源码级原理剖析
【免费下载链接】reduxA JS library for predictable global state management项目地址: https://gitcode.com/gh_mirrors/re/redux
combineReducers是 Redux 提供的最常用的高阶 reducer 工具函数,用于把多个"切片 reducer"(slice reducer)合并成一个根 reducer,从而把庞大的状态树按领域拆分为独立管理的"切片"(slice)。本篇文章以官方文档 Using combineReducers 为核心骨架,结合仓库源码 src/combineReducers.ts 与其测试用例 test/combineReducers.spec.ts,系统讲解它的核心概念、状态形状定义方式、内置规则约束,以及它不擅长处理的场景与替代方案。读完本文,你将理解 combineReducers 的完整调用语义、初始化行为、引用相等性(referential equality)优化,并能在自己的项目中正确组织切片 reducer、规避常见陷阱。
Core Concepts:为什么需要 combineReducers
在大多数 Redux 应用中,最典型的状态形状是一个普通 JavaScript 对象,每个顶层键(top-level key)对应一块领域数据(如todos、filter、posts)。与之配套,最主流的 reducer 编写方式是维护一组"切片 reducer"函数:每个切片 reducer 具有完全相同的(state, action)签名,各自负责管理自己那一小块状态的更新。
多个切片 reducer 可以同时响应同一个 action——每个切片独立决定是否需要更新自己,最终由外层把这些切片组合成新的状态对象。由于这种模式极其普遍,Redux 直接内置了combineReducers工具来落地这一行为。
从代码结构看,combineReducers是典型的高阶 reducer(higher-order reducer):它接收一个以 reducer 函数为值的对象(ReducersMapObject),返回一个新的 reducer 函数,这一点在其源码注释中有明确定义(见 src/combineReducers.ts):
Turns an object whose values are different reducer functions, into a single reducer function. It will call every child reducer, and gather their results into a single state object, whose keys correspond to the keys of the passed reducer functions.
使用 combineReducers 前必须明确的四个要点
官方文档 Using combineReducers 开篇就强调了几条重要认知:
- 它只是一个简化常见场景的工具函数,并非强制要求。你不必在自己的应用中使用它,它也无法覆盖所有可能的场景。对于它处理不了的用例,完全需要自己编写自定义 reducer 逻辑,详见 Beyond combineReducers。
- Redux 本身对状态如何组织不持意见,但 combineReducers 强制执行若干规则,以帮助用户避免常见错误。具体规则清单见 combineReducers API 文档。
- "Redux 分发 action 时是否会调用所有 reducer?"由于整个 store 只有一个根 reducer,默认答案是"不会"。但
combineReducers的行为恰恰是"会":为了组装出新的状态树,它会把当前切片状态和当前 action 分别传给每一个切片 reducer,给每个切片响应并更新自己的机会。因此,使用combineReducers在某种意义上确实"调用了所有 reducer"——至少是它所包裹的全部切片 reducer。 - 可以在 reducer 结构的任意层级使用它,而不只是创建根 reducer。把多个 combined reducer 在不同位置组合起来再拼成根 reducer,是极其常见的做法。
定义 State Shape:状态形状由谁决定
初始化 store 状态有两条途径:一是createStore的第二个参数preloadedState(主要用于从 localStorage 等服务端/持久化来源恢复旧状态);二是根 reducer 在state参数为undefined时返回初始状态。这两者如何配合的详细机制见 Initializing State,但使用combineReducers时还有额外需要注意的点:
combineReducers接收一个以切片 reducer 为值的对象,并生成一个输出状态对象的函数,输出对象的键与输入对象完全一致。
这意味着,如果没有向createStore传入preloadedState,那么传入combineReducers的键名就直接决定了输出状态对象的键名。而这种"键名即状态形状"的关联,在使用默认导出(default export)和对象字面量简写(object literal shorthand)等 ES Module 特性时并不那么显而易见,容易造成困惑。
简写语法如何"意外"定义状态形状
文档给出了一个非常典型的例子——使用对象字面量简写时,状态键名会等于导入的变量名:
// reducers.js export default theDefaultReducer = (state = 0, action) => state export const firstNamedReducer = (state = 1, action) => state export const secondNamedReducer = (state = 2, action) => state // rootReducer.js import { combineReducers, createStore } from 'redux' import theDefaultReducer, { firstNamedReducer, secondNamedReducer } from './reducers' // 使用对象字面量简写语法定义对象形状 const rootReducer = combineReducers({ theDefaultReducer, firstNamedReducer, secondNamedReducer }) const store = createStore(rootReducer) console.log(store.getState()) // {theDefaultReducer : 0, firstNamedReducer : 1, secondNamedReducer : 2}注意:因为使用了对象字面量简写,结果状态中的键名与 import 的变量名完全相同。这未必是你想要的行为,也常常是初学者对现代 JS 语法不熟悉时产生困惑的根源。
更重要的是,theDefaultReducer这类名字作为状态键名也很别扭——状态键应该反映它承载的数据领域或类型,而不是把 "reducer" 这个词写进键名。文档因此给出两个修正建议:要么在切片 reducer 对象中显式指定键名,要么在 import 时小心地重命名变量以配合简写语法。
更好的写法:显式控制键名
import { combineReducers, createStore } from 'redux' // 把默认导入重命名为我们想要的任何名字,也可以重命名具名导入 import defaultState, { firstNamedReducer, secondNamedReducer as secondState } from './reducers' const rootReducer = combineReducers({ defaultState, // 键名与仔细重命名过的默认导出一致 firstState: firstNamedReducer, // 用明确的键名替代变量名 secondState // 键名与仔细重命名过的具名导出一致 }) const reducerInitializedStore = createStore(rootReducer) console.log(reducerInitializedStore.getState()) // {defaultState : 0, firstState : 1, secondState : 2}这个状态形状更贴切地反映了数据本身,因为我们特意为传给combineReducers的键设置了清晰的名字。
底层实现:combineReducers 到底做了什么
要真正理解combineReducers的语义,最好的方式是阅读它的实现源码 src/combineReducers.ts。整个实现可以分为"构造阶段"与"调用阶段"两部分。
构造阶段:过滤、告警与形状校验
在调用combineReducers(reducers)的那一刻,源码会依次执行:
过滤非函数值:遍历传入对象的键,只保留
typeof reducers[key] === 'function'的条目进入finalReducers(src/combineReducers.ts)。这对应测试用例ignores all props which are not a function——传入布尔值、字符串、嵌套对象都会被忽略,最终状态只包含真正的 reducer 函数(见 test/combineReducers.spec.ts)。对缺失的 reducer 发出告警:开发环境下若某个键对应的值为
undefined,会输出No reducer provided for key "..."的警告(src/combineReducers.ts),对应测试warns if a reducer prop is undefined。形状断言(assertReducerShape):对每个切片 reducer 执行两次探测调用(src/combineReducers.ts):
- 用
{ type: ActionTypes.INIT }调用reducer(undefined, ...),若返回undefined则抛出"初始化时返回 undefined"的错误; - 用
ActionTypes.PROBE_UNKNOWN_ACTION()(一个随机生成的私有 action 类型)再次探测,若返回undefined则抛出错误,提示不要处理redux/*命名空间下的私有 action,未知 action 必须返回当前状态。
这一步与 src/utils/actionTypes.ts 中定义的私有 action 类型机制密切相关:
INIT、REPLACE、PROBE_UNKNOWN_ACTION都是带随机后缀的字符串,应用程序不应直接引用它们。- 用
缓存形状断言错误:如果断言阶段抛错,错误会被暂存(
shapeAssertionError),等到组合函数第一次被实际调用时再抛出(src/combineReducers.ts)。测试throws an error on first call if a reducer returns undefined initializing验证的正是这一行为(test/combineReducers.spec.ts)。
调用阶段:逐切片分发与引用相等性优化
组合函数combination(state = {}, action)是每次分发 action 时真正执行的逻辑(src/combineReducers.ts):
- 开发环境下,通过
getUnexpectedStateShapeWarningMessage检查输入状态形状:若状态不是普通对象、或包含未知键,会给出精确的警告信息(如Unexpected key "bar" found in preloadedState argument passed to createStore)。并且unexpectedKeyCache保证同一个未知键只告警一次,对应测试only warns for unexpected keys once(test/combineReducers.spec.ts)。 - 遍历每个切片 reducer,取出当前切片状态
previousStateForKey,调用reducer(previousStateForKey, action)得到nextStateForKey。 - 若某个切片返回
undefined,立即抛出包含 action 类型与切片键名的详细错误(对应测试throws an error if a reducer returns undefined handling an action)。 - 引用相等性优化:只有任一切片的新状态与旧状态引用不同(
nextStateForKey !== previousStateForKey),或者状态键的数量发生变化时,才返回新建的nextState对象;否则直接返回原state引用(src/combineReducers.ts)。这正是两个测试的核心断言:所有子 reducer 都保持引用相等时,组合结果也保持相等(reducer(initialState, { type: 'FOO' })严格等于initialState);只要有一个切片变化,整体就返回新对象(见 test/combineReducers.spec.ts)。
这个"返回原引用还是新对象"的行为对性能至关重要:它让 Redux 能够廉价地判断状态是否真的发生了变化,从而避免无谓的重渲染。理解这一点,也就理解了为什么每个切片 reducer 都必须遵循"未识别 action 返回原状态"的约定——只有这样才能保住引用相等性带来的优化。
传给 combineReducers 的 reducer 必须满足的规则
正如 API 文档 combineReducers 所强调的,combineReducers是"轻度固执"(mildly opinionated)的,它倾向于帮助初学者避免常见陷阱,因此强制约束了传入 reducer 的行为。任何传给它的 reducer 都必须满足三条规则:
- 对任何未识别的 action,必须原样返回传入的第一个参数
state(测试maintains referential equality if the reducers it is combining do正是对该约定的验证)。 - 永远不能返回
undefined。通过早期的return语句很容易误写出这种 bug,所以combineReducers会选择直接抛错,而不是让错误在别处慢慢显现。如果确实不想让某个切片持有值,可以返回null而不是undefined。 - 当传入的
state为undefined时,必须返回该 reducer 自己的初始状态(根据上一条规则,初始状态同样不能是undefined)。推荐用参数默认值语法state = 初始值实现,也可以显式检查第一个参数是否为undefined。
需要特别警惕的是:即使你给createStore(combineReducers(...), initialState)传了初始状态,combineReducers依然会用undefined去探测每个切片 reducer(这正是上面提到的assertReducerShape阶段)。因此,你必须确保自己的 reducer 在收到undefined时也能正常工作,哪怕你在自己的代码里从不打算让它真的收到undefined。
规则背后的错误信息
结合源码可以看到这些规则对应的完整错误文案(src/combineReducers.ts 与 src/combineReducers.ts):
- 初始化时返回
undefined:The slice reducer for key "counter" returned undefined during initialization. ... - 探测到处理了私有 action:
The slice reducer for key "counter" returned undefined when probed with a random type. Don't try to handle '@@redux/INIT...' or other actions in "redux/*" namespace. ... - 处理某个 action 时返回
undefined:When called with an action of type "whatever", the slice reducer for key "counter" returned undefined. To ignore an action, you must explicitly return the previous state. ...
这些信息量极大的报错信息,正是combineReducers"帮助初学者避免常见陷阱"的设计意图的直接体现。
实战示例:从切片 reducer 到完整 store
完整可运行的示例
以下示例完整改编自 API 文档 combineReducers 的 Example 章节,演示切片 reducer、组合与分发的完整链路:
// reducers/todos.js export default function todos(state = [], action) { switch (action.type) { case 'ADD_TODO': return state.concat([action.text]) default: return state } } // reducers/counter.js export default function counter(state = 0, action) { switch (action.type) { case 'INCREMENT': return state + 1 case 'DECREMENT': return state - 1 default: return state } } // reducers/index.js import { combineReducers } from 'redux' import todos from './todos' import counter from './counter' export default combineReducers({ todos, counter }) // App.js import { createStore } from 'redux' import reducer from './reducers/index' const store = createStore(reducer) console.log(store.getState()) // { // counter: 0, // todos: [] // } store.dispatch({ type: 'ADD_TODO', text: 'Use Redux' }) console.log(store.getState()) // { // counter: 0, // todos: [ 'Use Redux' ] // }可以看到:分发ADD_TODO时,todos切片响应并更新,counter切片由于不识别该 action 而原样返回自己的状态——两者互不干扰,且counter的引用被保留,这正是前面讲的逐切片分发语义。
仓库示例项目中的真实用法
在本仓库的多个示例项目中,combineReducers都是组装根 reducer 的标准方式。例如 examples/todos/src/reducers/index.js 和 examples/todomvc/src/reducers/index.js 的结构完全一致:
import { combineReducers } from 'redux' import todos from './todos' import visibilityFilter from './visibilityFilter' export default combineReducers({ todos, visibilityFilter })同样的模式还出现在 examples/async/src/reducers/index.js、examples/shopping-cart/src/reducers/index.js、examples/real-world/src/reducers/index.js、examples/universal/common/reducers/index.js 与 examples/todos-with-undo/src/reducers/index.js 中。你可以直接阅读这些示例,观察真实应用中状态形状如何被组织、切片之间如何协作。
combineReducers 与 preloadedState 的初始化博弈
在"没有 combineReducers 的简单 reducer"场景下,preloadedState永远赢过state = ...默认值:因为传给 reducer 的state就是preloadedState,它不是undefined,默认参数语法不会生效。
而使用combineReducers时行为更微妙(详见 Initializing State):
- 在
preloadedState中指明了对应切片的 reducer,会收到那份状态; - 未被指明的切片 reducer 会收到
undefined,正因如此才回退到各自state = ...指定的默认值。
function a(state = 'lol', action) { return state } function b(state = 'wat', action) { return state } const combined = combineReducers({ a, b }) import { createStore } from 'redux' // 不传 preloadedState:两个切片都回退到默认值 const store = createStore(combined) console.log(store.getState()) // { a: 'lol', b: 'wat' } // 传部分 preloadedState:a 用指定值,b 回退默认值 const store2 = createStore(combined, { a: 'horse' }) console.log(store2.getState()) // { a: 'horse', b: 'wat' }总体结论:preloadedState优先于 reducer 内部的默认值。这让 reducer 可以用默认参数声明"对自己有意义的初始数据",同时允许在从持久化存储或服务端水合(hydrate)store 时载入已有数据(可整体载入,也可部分载入)。
这里还有一个常被忽略的细节:那些用preloadedState填充初始状态的 reducer,仍然必须提供默认值,因为所有 reducer 在初始化时都会被传入undefined。所以它们应被写成"收到undefined就返回某个非undefined的值",且无需在默认值里重复preloadedState的那部分内容。
何时不该用 combineReducers:越界场景与自定义方案
combineReducers被刻意设计为只覆盖一个常见用例:把纯 JavaScript 对象组成的状态树,按切片委托给各个切片 reducer。它不处理以下场景(详见 Beyond combineReducers):
- 状态树由 Immutable.js 的 Map 等非常规结构构成;
- 需要把状态树的其他部分作为额外参数传给某个切片 reducer;
- 需要对切片 reducer 的调用顺序做特殊"排序";
- 它也不关心某个切片 reducer 内部如何完成工作。
对这些场景,答案很简单:"别用combineReducers——你需要自定义 reducer 逻辑。" 一旦超出combineReducers的核心用例,就进入了自定义 reducer 的领域。
跨切片共享数据:三种思路
思路一:父级 reducer 显式传参。当sliceReducerA需要sliceReducerB的数据时,写一个自定义组合函数,在特定 action 下把需要的数据作为额外参数传入:
function combinedReducer(state, action) { switch (action.type) { case 'A_TYPICAL_ACTION': { return { a: sliceReducerA(state.a, action), b: sliceReducerB(state.b, action) } } case 'SOME_SPECIAL_ACTION': { return { // 显式把 state.b 作为额外参数传入 a: sliceReducerA(state.a, action, state.b), b: sliceReducerB(state.b, action) } } default: return state } }思路二:把共享数据放进 action。用 thunk 之类的方案在 dispatch 前把另一切片的数据塞进 action payload,父 reducer 就无需做任何特殊处理:
function someSpecialActionCreator() { return (dispatch, getState) => { const state = getState() const dataFromB = selectImportantDataFromB(state) dispatch({ type: 'SOME_SPECIAL_ACTION', payload: { dataFromB } }) } }思路三:组合两个 reducer。用combineReducers处理"简单"场景,另写一个crossSliceReducer处理"特殊"场景,再由外层rootReducer依次调用:
const combinedReducer = combineReducers({ a: sliceReducerA, b: sliceReducerB }) function crossSliceReducer(state, action) { switch (action.type) { case 'SOME_SPECIAL_ACTION': { return { a: handleSpecialCaseForA(state.a, action, state.b), b: sliceReducerB(state.b, action) } } default: return state } } function rootReducer(state, action) { const intermediateState = combinedReducer(state, action) const finalState = crossSliceReducer(intermediateState, action) return finalState }高阶组合:给切片套上可复用逻辑
Redux 的 reducer 说到底只是函数,而combineReducers只是工具箱里的一件工具。函数可以包含 switch 之外的任意条件逻辑,可以被组合、包裹、互相调用。例如,想让某个切片支持"撤销"且只响应特定 action,可以这样组合:
const undoableFilteredSliceA = compose( undoReducer, filterReducer('ACTION_1', 'ACTION_2'), sliceReducerA ) const rootReducer = combineReducers({ a: undoableFilteredSliceA, b: normalSliceReducerB })combineReducers完全不知道也不关心管理a的 reducer 内部有什么特殊之处——你不需要为了支持撤销而修改combineReducers,只需把需要的零件组装成一个新的组合函数即可。
TypeScript 视角:类型推断带来的状态形状保证
在本仓库的 src/types/reducers.ts 中,combineReducers的类型签名展现了强大的类型推导能力。它的泛型重载(src/combineReducers.ts)基于ReducersMapObject,并通过以下工具类型精确还原状态形状:
StateFromReducersMapObject<M>:从 reducer 映射对象推导合并后的状态类型;ActionFromReducersMapObject<M>:推导整个组合 reducer 能处理的 action 联合类型;PreloadedStateShapeFromReducersMapObject<M>:推导可接受的preloadedState形状(允许部分键缺失)。
也就是说,在 TypeScript 项目中:
const rootReducer = combineReducers({ posts: postsReducer, comments: commentsReducer }) // rootReducer 的 state 参数类型被精确推导为 // { posts: PostsState; comments: CommentsState }组合结果状态、action 类型与preloadedState三者都由切片 reducer 的声明自动推导,类型错误会在编译期暴露,而非等到运行时。这与"状态键名由传入对象的键决定"的运行时语义完全一致,从类型层面再次印证了"键名即状态形状"这一核心规律。
总结与最佳实践清单
combineReducers是 Redux 中最常见的高阶 reducer,其设计目标明确、行为可预期。综合本文所述,使用时的最佳实践可以归纳为:
- 始终为每个切片 reducer 提供非
undefined的默认状态(推荐state = 初始值语法),即使你计划用preloadedState初始化——因为combineReducers在构造阶段会用undefined探测所有切片。 - 对未识别的 action 原样返回
state,既满足规则约束,也保住引用相等性带来的性能优化。 - 状态键名应反映数据领域,而不是变量名或 "reducer" 字样;需要时在 import 阶段重命名,或显式指定
combineReducers对象的键。 - 充分利用"键名即状态形状"的语义:
combineReducers可以在 reducer 树任意层级嵌套使用,把大型状态树逐步拆分到粒度合适的切片。 - 认识到它的边界:跨切片共享数据、非常规状态容器、调用顺序控制等场景,应当走向自定义 reducer 或第三方 reducer 工具(本仓库文档 Beyond combineReducers 提供了完整思路),而不是试图把
combineReducers改造成万能方案。
延伸阅读
- combineReducers API 参考:参数、返回值与三条规则约束的官方定义
- Beyond combineReducers:combineReducers 之外的更高级 reducer 组织方式
- Initializing State:preloadedState 与 reducer 默认值之间的完整博弈关系
- structuring-reducers 目录总览:Reducer 结构化的系列文档入口
- createStore API:根 reducer 与 preloadedState 的完整调用约定
【免费下载链接】reduxA JS library for predictable global state management项目地址: https://gitcode.com/gh_mirrors/re/redux
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考