Redux 基础 Reducer 结构与 State Shape 设计指南
【免费下载链接】reduxA JS library for predictable global state management项目地址: https://gitcode.com/gh_mirrors/re/redux
本文基于当前仓库中 BasicReducerStructure.md 展开,系统讲解 Redux 应用中"唯一根 reducer"的职责与书写范式,以及顶层状态树(State Shape)的领域划分原则。读完本文,你将掌握从单函数 reducer 到
switch+ 默认参数的标准写法,能够依据领域数据(Domain Data)、应用状态(App State)与 UI 状态(UI State)三类数据合理设计 state 结构,并理解仓库源码 createStore.ts 与 combineReducers.ts 中与之对应的底层机制。
唯一的根 Reducer 与其职责
首先要明确一个核心事实:你的整个应用实际上只有一个 reducer 函数——就是传给createStore作为第一个参数的函数。仓库源码 createStore.ts 在入口处即校验该参数必须是函数,否则抛出Expected the root reducer to be a function错误;随后这个函数会被存储为currentReducer,每次dispatch时以currentReducer(currentState, action)的方式被调用(createStore.ts)。
这一个唯一的根 reducer 最终需要完成以下几件事:
- 处理首次调用:第一次调用时传入的
state为undefined,reducer 必须先提供一个默认状态值,再处理后续的 action; - 判断要做的工作:根据传入的旧状态与派发的 action,决定需要执行何种更新;
- 产出新状态:若确实需要变更,则创建包含更新数据的新对象/新数组并返回;
- 无变更则原样返回:若无需任何变化,则直接返回现有 state 本身。
在 TypeScript 类型层面,reducer 被严格定义为(state, action) => newState形式的纯函数。见 reducers.ts:
export type Reducer< S = any, A extends Action = UnknownAction, PreloadedState = S > = (state: S | PreloadedState | undefined, action: A) => S注意state参数类型中显式包含undefined,这正是在类型层面提醒你:reducer 必须能够处理"首次调用时 state 为 undefined"的情形。
最简单的 reducer 写法:单函数声明
编写 reducer 逻辑最直白的方式,是把所有逻辑放进一个函数声明中:
function counter(state, action) { if (typeof state === 'undefined') { state = 0 // 如果 state 是 undefined,用默认值初始化 } if (action.type === 'INCREMENT') { return state + 1 } else if (action.type === 'DECREMENT') { return state - 1 } else { return state // 遇到无法识别的 action 时原样返回 } }这个简单函数已经完整满足了上文列出的全部基本要求:
- 当没有状态时返回默认值,完成 store 的初始化;
- 依据
action.type判断应做的更新类型,并返回新值; - 当无需任何工作时返回之前的 state。
标准写法:switch + 默认参数
上述写法中的重复if/else语句很快会让人疲劳,因此实践中几乎都改用switch语句;同时,可以用默认参数值来替代显式的typeof state === 'undefined'检查。改造后的版本如下:
function counter(state = 0, action) { switch (action.type) { case 'INCREMENT': return state + 1 case 'DECREMENT': return state - 1 default: return state } }这就是一个典型 Redux reducer 的基本结构,值得注意三个约定:
default分支必须原样返回state(而不是返回undefined),这是被仓库源码强制要求的:在 combineReducers.ts 的assertReducerShape中,会用随机 action 探测每个 reducer,若其返回了undefined会直接抛出错误,提示"对于未知 action 必须返回当前 state";- 初始化时
state = 0的默认值恰好对应了"首次调用传入 undefined"的场景; - 保持纯函数特性:不修改入参、不产生副作用,这正是 Redux 支持热重载(hot reloading)与时间旅行调试的基础(见 reducers.ts 中对 reducer 必须为纯函数的说明)。
仓库实例:examples/todos 中的 reducer
当前仓库 examples/todos/src/reducers/todos.js 就是上述结构的真实落地:
const todos = (state = [], action) => { switch (action.type) { case 'ADD_TODO': return [ ...state, { id: action.id, text: action.text, completed: false } ] case 'TOGGLE_TODO': return state.map(todo => todo.id === action.id ? { ...todo, completed: !todo.completed } : todo ) default: return state } }可以看到它同时体现了"不可变更新"(用展开运算符创建新数组、用map生成新对象)与"未知 action 返回原 state"两条准则。仓库中的 visibilityFilter.js 则展示了用常量VisibilityFilters.SHOW_ALL作为默认参数的另一种初始化方式。
State Shape:把状态当作数据来组织
Redux 鼓励你从"需要管理的数据"角度思考应用:任一时刻的数据集合就是应用的 "state",其结构与组织方式被称为 "shape"。state 的 shape 直接决定了你如何组织 reducer 逻辑。
一个 Redux state 的树顶通常是普通 JavaScript 对象(当然也可以是单个数字、数组或专门的数据结构,但绝大多数库都假定顶层值是普通对象)。最常见的组织方式,是在顶层对象中按"领域/切片"(domain / slice)进一步划分数据子树。例如一个基础 Todo 应用的 state 可能是:
{ visibilityFilter: 'SHOW_ALL', todos: [ { text: 'Consider using Redux', completed: true, }, { text: 'Keep all state in a single tree', completed: false } ] }上例中todos与visibilityFilter都是顶层 key,各自代表某类特定概念的"数据切片"。这一 shape 与仓库 examples/todos/src/reducers/index.js 中用combineReducers({ todos, visibilityFilter })组合出的状态结构完全对应——combineReducers返回的新 reducer 会输出与传入对象键名一致的 state 对象(详见 UsingCombineReducers.md 中的示例)。
三类数据划分
大多数应用需要处理多种类型的数据,可大致归为三类:
- 领域数据(Domain data):应用需要展示、使用或修改的数据(如"从服务器取回的全部 Todo");
- 应用状态(App state):与应用行为相关的数据(如"Todo #5 当前被选中"、"正在请求获取 Todo 数据");
- UI 状态(UI state):表示 UI 当前如何显示的数据(如"EditTodo 模态框当前已打开")。
关键原则:按数据而非 UI 组件树定义 shape
因为 store 是应用的核心,你应当依据领域数据和应用状态来定义 state shape,而不是依据 UI 组件树。例如state.leftPane.todoList.todos这种嵌套就是一个坏设计——"todos"是整个应用的核心概念,而非 UI 的某个局部,因此todos切片应当位于状态树顶层。
UI 树与 state shape 之间几乎不会存在一一对应关系。唯一的例外是你显式地在 Redux store 中跟踪各类 UI 数据,但即便如此,UI 数据的结构与领域数据的结构也大概率不同。
一个典型应用的 state shape 大致如下:
{ domainData1 : {}, domainData2 : {}, appState1 : {}, appState2 : {}, ui : { uiState1 : {}, uiState2 : {}, } }这种"顶层按领域切片、UI 状态集中收敛"的布局,让每个顶层 key 都能对应一个独立的 slice reducer,从而为下一步使用combineReducers拆分 reducer 逻辑铺平道路(参见 SplittingReducerLogic.md 与 UsingCombineReducers.md)。
深入源码:初始化与"每个 reducer 都会被调用"
理解了根 reducer 与 state shape 之后,有两个底层机制值得结合源码加深认识。
其一:store 创建时自动派发 INIT action。在 createStore.ts 中,store 创建完成后会立即dispatch({ type: ActionTypes.INIT }),从而让 reducer 返回初始状态、填充初始状态树。这就是"第一次调用 reducer 时 state 为 undefined、默认参数生效"的触发点。如果还传入了preloadedState(createStore的第二个参数),它会直接作为首次调用时传入的state,此时默认参数不再生效——preloadedState优先于 reducer 内部的默认值,详细规则见 InitializingState.md。
其二:combineReducers会调用它包裹的每一个 slice reducer。从源码 combineReducers.ts 可以看到,组合后的 reducer 会遍历所有 slice reducer,把各自的切片状态与当前 action 传入,收集每个切片的新状态并汇总成新的 state 对象;它还会通过引用比较判断是否发生变化,只有真正变化时才返回新对象,否则返回原 state。因此"dispatch 一个 action 时是否所有 reducer 都被调用"的答案取决于你的根 reducer 是否由combineReducers构成——若是,则每个被包裹的 slice reducer 都有机会响应该 action。
另外,combineReducers在初始化时会调用assertReducerShape强制校验:每个 slice reducer 在收到undefined状态时必须返回非undefined的初始值,且对未知 action 必须返回当前状态(combineReducers.ts)。这正是本文强调"默认参数初始化"与"default 分支原样返回"两大写法的底层原因——它们不仅是风格约定,更是被源码校验的硬性要求。
延伸阅读
- SplittingReducerLogic.md:如何把根 reducer 拆分为多个 slice reducer
- UsingCombineReducers.md:
combineReducers的完整使用方式与 state key 命名陷阱 - InitializingState.md:
preloadedState与 reducer 默认参数如何协作 - ImmutableUpdatePatterns.md:不可变更新的各种模式
- StructuringReducers.md:整个 reducer 组织系列的索引与前置概念
【免费下载链接】reduxA JS library for predictable global state management项目地址: https://gitcode.com/gh_mirrors/re/redux
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考