Redux 核心概念与数据流:从单向数据流到 Action、Reducer 与 Store 的实现原理
本文基于 Redux 官方教程 Fundamentals 第二篇 Redux Concepts and Data Flow,系统讲解 Redux 的术语体系(Action、Reducer、Store、dispatch、Selector)、三大设计原则(单一数据源、状态只读、纯函数更新)以及完整的应用数据流。并结合本仓库的核心源码(createStore、isPlainObject 等)与 counter-vanilla 示例,印证这些概念在实现层面的具体行为与约束。读完你不仅掌握 Redux 的词汇与心智模型,还能从源码级理解"为什么 action 必须是带字符串 type 的普通对象"、"为什么不能在 reducer 中 dispatch"这类规则的存在原因。
引言
在 Part 1: Redux 概述 中,我们介绍了 Redux 是什么、为什么使用它,以及配套使用的其他 Redux 库,并看到一个可运行的 Redux 应用长什么样、由哪些部分组成。本篇则深入其中:把 Part 1 里一笔带过的术语与概念逐一展开,并进一步说明数据是如何在一个 Redux 应用中流动的。
背景概念:先理解 Redux 解决什么问题
状态管理与单向数据流
先看一个小的 React 计数器组件。它用组件内部状态跟踪一个数字,并在按钮被点击时递增:
function Counter() {
// State: a counter value
const [counter, setCounter] = useState(0)
// Action: code that causes an update to the state when something happens
const increment = () => {
setCounter(prevCounter => prevCounter + 1)
}
// View: the UI definition
return (
<div>
Value: {counter} <button onClick={increment}>Increment</button>
</div>
)
}
这是一个自包含的应用,由三部分组成:
- state(状态):驱动应用的"唯一事实来源";
- view(视图):基于当前状态的声明式 UI 描述;
- actions(动作):由用户输入等触发的、引起状态更新的事件。
这是"单向数据流"的一个小例子:
- 状态描述应用在某一时点的形态;
- UI 根据该状态渲染;
- 当某件事发生(如用户点击按钮),状态基于发生的事被更新;
- UI 基于新状态重新渲染。
但当多个组件需要共享和使用同一份状态、且这些组件分散在应用的不同位置时,这种简单模式就会失效。有时可以通过"状态提升"(lifting state up)把状态上移到父组件来解决,但并不总是可行。
一种解决方式:把共享状态从组件中抽离出来,放到组件树之外的集中式位置。这样,整个组件树就变成了一个大"视图",任何组件无论位于树的哪个位置,都可以访问状态或触发动作。通过定义并分离状态管理中的各个概念、并强制视图与状态之间相互独立的规则,代码获得了更多的结构化与可维护性。
这就是 Redux 的基本思想:在全应用中用一个集中式的 store 保存全局状态,并遵循特定的模式来更新该状态,从而使代码可预测。
Immutability:不可变更新
"Mutable"意为"可变的"。如果某样东西是 "immutable"(不可变的),它就永远无法被改变。
JavaScript 的对象和数组默认都是可变的。创建对象后,可以修改其字段内容;创建数组后,同样可以修改内容:
const obj = { a: 1, b: 2 }
// 对象引用没变,但内部内容变了
obj.b = 3
const arr = ['a', 'b']
// 数组同理,可以修改内部内容
arr.push('c')
arr[1] = 'd'
这叫做对对象或数组的"变异"(mutating)。在内存中它仍是同一个对象/数组引用,但内部内容已经改变。
要以不可变方式更新值,你的代码必须对已有对象/数组做"拷贝",然后修改拷贝出来的副本。
可以手动用 JavaScript 的对象/数组展开运算符(spread)实现,也可以用那些返回新数组副本而非变异原数组的数组方法:
const obj = {
a: {
// 要安全地更新 obj.a.c,必须逐级拷贝
c: 3
},
b: 2
}
const obj2 = {
// 拷贝 obj
...obj,
// 覆盖 a
a: {
// 拷贝 obj.a
...obj.a,
// 覆盖 c
c: 42
}
}
const arr = ['a', 'b']
// 创建 arr 的新副本,把 "c" 追加到末尾
const arr2 = arr.concat('c')
// 或者,先拷贝原数组:
const arr3 = arr.slice()
// 再修改副本:
arr3.push('c')
Redux 要求所有状态更新都以不可变方式完成。 这一点之所以关键,稍后在数据流与 Store 通知机制中会体现出来。
Redux 核心术语
Action:描述"发生了什么"的普通对象
Action 是一个带有 type 字段的普通 JavaScript 对象。可以把 action 理解为一个事件,描述应用中刚发生的某件事。
type 字段应该是一个给该动作起描述性名称的字符串,例如 "todos/todoAdded"。通常按 "domain/eventName" 的形式书写:前半部分是该 action 所属的功能或类别,后半部分具体发生了什么。
Action 对象还可以携带其他字段,提供关于所发生事件的更多信息。按惯例,这些信息放在一个叫 payload 的字段里。典型的 action 对象如下:
const addTodoAction = {
type: 'todos/todoAdded',
payload: 'Buy milk'
}
这一约定在类型定义中同样被固化下来。在 src/types/actions.ts 中,Action 类型的核心就是 type 字段:
export type Action<T extends string = string> = {
type: T
}
注释中明确写道:Actions 是获取数据进入 store 的唯一途径,type 必须是字符串,因为字符串是可序列化的。此外仓库还提供了 isAction 工具函数,用于判断一个值是否满足"普通对象且 type 为字符串"这一标准:
export default function isAction(action: unknown): action is Action<string> {
return (
isPlainObject(action) &&
'type' in action &&
typeof (action as Record<'type', unknown>).type === 'string'
)
}
Reducer:处理事件的"事件监听器"
Reducer 是一个函数,接收当前的 state 和一个 action 对象,判断是否需要更新状态,并返回新状态:(state, action) => newState。可以把 reducer 看作事件监听器:它根据接收到的 action(事件)的 type 来处理事件。
为什么叫 "Reducer"?因为它们与传给
Array.reduce()方法的回调函数很相似——详见下文"深入"部分。
Reducer 必须始终遵守几条规则:
- 只能根据
state和action两个参数计算新的状态值; - 不允许修改现有的
state,必须通过拷贝现有state、修改拷贝值的方式做"不可变更新"; - 不允许做任何异步逻辑、计算随机值,或引起其他"副作用"。
Reducer 内部的逻辑通常遵循同一套步骤:
- 先判断该 reducer 是否关心这个 action
- 如果是,拷贝 state,在副本上写入新值并返回
- 否则,原样返回现有 state
一个展示这些步骤的小型 reducer 示例:
const initialState = { value: 0 }
function counterReducer(state = initialState, action) {
// Check to see if the reducer cares about this action
if (action.type === 'counter/incremented') {
// If so, make a copy of `state`
return {
...state,
// and update the copy with the new value
value: state.value + 1
}
}
// otherwise return the existing state unchanged
return state
}
Reducer 内部可以使用任意逻辑决定新状态:if/else、switch、循环等等。
这个约束在类型层面同样体现:src/types/reducers.ts 中 Reducer 类型被定义为接收"积累值(状态)与当前值(action)"、返回新积累值的纯函数,其注释强调 Reducer 是 Redux 中最重要的概念,且"不要在 reducer 中放 API 调用"。
深入:为什么它们叫 "Reducer"?
Array.reduce() 方法让你拿到一组值,逐个处理,最终返回一个结果——可以理解为"把数组缩减成一个值"。
Array.reduce() 接收一个回调函数作为参数,数组中每个元素触发一次调用,回调接收两个参数:
previousResult:上次回调返回的值;currentItem:当前正在处理的数组元素。
首次执行回调时没有 previousResult,所以还需要传入一个初始值作为第一次的 previousResult。
如果要把一组数字相加求和,可以写出这样的 reduce 回调:
const numbers = [2, 5, 8]
const addNumbers = (previousResult, currentItem) => {
console.log({ previousResult, currentItem })
return previousResult + currentItem
}
const initialValue = 0
const total = numbers.reduce(addNumbers, initialValue)
// {previousResult: 0, currentItem: 2}
// {previousResult: 2, currentItem: 5}
// {previousResult: 7, currentItem: 8}
console.log(total)
// 15
注意这个 addNumbers "reduce 回调"函数自身不需要记住任何东西:它接收 previousResult 和 currentItem,处理之后返回新结果。
Redux 的 reducer 函数与这个 "reduce 回调" 是完全相同的思想:它接收"上次的结果"(即 state)和"当前项"(即 action 对象),基于这两个参数决定新的状态值并返回。
如果我们构造一个 Redux action 数组,调用 reduce() 并传入 reducer 函数,就能得到同样的最终结果:
const actions = [
{ type: 'counter/incremented' },
{ type: 'counter/incremented' },
{ type: 'counter/incremented' }
]
const initialState = { value: 0 }
const finalResult = actions.reduce(counterReducer, initialState)
console.log(finalResult)
// {value: 3}
可以说:Redux reducer 把(时间上的)一串 action 归约成单一状态。 区别在于:Array.reduce() 一次性完成,而 Redux 中这个过程发生在整个应用的生命周期内。
Store:全局状态的容器
当前 Redux 应用状态存放在一个叫做 store 的对象中。
Store 通过传入一个 reducer 来创建,并有一个 getState 方法返回当前状态值:
import { configureStore } from '@reduxjs/toolkit'
const store = configureStore({ reducer: counterReducer })
console.log(store.getState())
// {value: 0}
这里有一个值得注意的实现细节:教程示例使用的是 Redux Toolkit 的 configureStore,而本仓库核心包中的 createStore 在源码注释里已被标记为 @deprecated,官方建议迁移到 @reduxjs/toolkit 的 configureStore(见 src/createStore.ts 的弃用说明)。两者概念一致:把 reducer 交给 store 创建函数,由 store 持有状态树。
从源码看 store 的初始化过程比"保存一个初始值"更微妙。在 createStore 的结尾,创建 store 时会主动 dispatch 一个内部 INIT action:
// When a store is created, an "INIT" action is dispatched so that every
// reducer returns their initial state. This effectively populates
// the initial state tree.
dispatch({ type: ActionTypes.INIT } as A)
也就是说,初始状态并不是"预先填进去"的,而是 store 用 INIT action 跑一遍 reducer、把返回值作为初始 state 得到的。这正是教程所说"store 调用一次根 reducer,并把返回值保存为初始 state"的实现来源。
Dispatch:更新状态的唯一途径
Store 上有一个 dispatch 方法。更新状态的唯一方式,是调用 store.dispatch() 并传入一个 action 对象。Store 会用保存的 state 执行 reducer 函数、把返回值作为新 state 存起来,随后你调用 getState() 就能拿到更新后的值:
store.dispatch({ type: 'counter/incremented' })
console.log(store.getState())
// {value: 1}
可以把 dispatch 一个 action 理解为在应用中"触发一个事件":某件事发生了,我们想让 store 知道。Reducer 就像事件监听器,当它们听到自己关心的 action,就更新状态作为响应。
dispatch 的底层实现(src/createStore.ts)揭示了它对 action 的严格校验,解释了为什么"action 必须是带字符串 type 的普通对象":
function dispatch(action: A) {
if (!isPlainObject(action)) {
throw new Error(
`Actions must be plain objects. ...`
)
}
if (typeof action.type === 'undefined') {
throw new Error(
'Actions may not have an undefined "type" property. ...'
)
}
if (typeof action.type !== 'string') {
throw new Error(
`Action "type" property must be a string. ...`
)
}
if (isDispatching) {
throw new Error('Reducers may not dispatch actions.')
}
try {
isDispatching = true
currentState = currentReducer(currentState, action)
} finally {
isDispatching = false
}
const listeners = (currentListeners = nextListeners)
listeners.forEach(listener => {
listener()
})
return action
}
几个要点:
- "普通对象"的判定由 isPlainObject 完成:它沿原型链走到顶端,确认对象的原型就是
Object.prototype(或null),从而排除类实例、数组之外的"伪对象"。测试用例 test/createStore.spec.ts 验证了 dispatch 一个 Promise、函数、Date、null时会分别抛出明确描述实际类型('Promise'、'function'、'date'等)的错误。 type必须存在且为字符串:dispatch{}或{ type: undefined }都会抛出Actions may not have an undefined "type" property(见 test/createStore.spec.ts);type是布尔、数字等类型时抛出Action "type" property must be a string。- reducer 执行期间禁止再 dispatch(
Reducers may not dispatch actions)、禁止getState()/subscribe(),源码通过isDispatching标志位强制这些约束,测试 test/createStore.spec.ts 覆盖了"在 reducer 中途调用 subscribe 会抛错"的场景。 - 通知订阅者:reducer 执行完毕后,store 遍历当前订阅者快照逐一调用,这正是教程"数据流"中"store 通知所有已订阅的 UI 部分"这一步的实现。
这些校验也解释了为什么 dispatch 的 JSDoc 里说:基础实现只支持普通对象 action;若想 dispatch Promise、Observable 或 thunk,需要引入中间件包装 store(参见本仓库 src/types/store.ts 中 dispatch 的文档注释)。
Selector:从状态中提取数据的函数
Selector 是一类知道如何从 store 状态值中提取特定信息的函数。 随着应用变大,这能帮助避免各处重复读取同一数据的逻辑:
const selectCounterValue = state => state.value
const currentValue = selectCounterValue(store.getState())
console.log(currentValue)
// 2
Selector 体现了"视图从 store 中按需取数"的模式:组件不直接依赖整个 state 的形状,而是依赖一小段提取逻辑。
三大核心设计原则
Redux 设计的意图可以总结为三个核心概念:
单一事实来源(Single Source of Truth)
应用的全局状态作为一个对象存放在唯一的 store 中。任何一条数据只应存在于一个位置,而不是在多处复制。
这让调试和检查应用状态随时间变化变得更加容易,也让需要与整个应用交互的逻辑得以集中。
提示:这并不意味着应用的每一份状态都必须放进 Redux store!某份状态应属于 Redux 还是 UI 组件,应根据它在哪里被需要来决定。
状态只读(State is Read-Only)
改变状态的唯一方式是 dispatch 一个 action——描述"发生了什么"的对象。
这样 UI 就不会意外覆盖数据,也更容易追溯某次状态更新为何发生。由于 action 是普通 JS 对象,它们可以被记录日志、序列化、存储,并在之后回放以用于调试或测试。
通过纯函数 Reducer 进行修改
为了指定状态树如何基于 action 更新,你编写 reducer 函数。Reducer 是纯函数:接收上一个状态和一个 action,返回下一个状态。像其他函数一样,你可以把 reducer 拆分为更小的函数来分担工作,或为常见任务编写可复用的 reducer。
"纯函数"在实现上意味着同样的输入必然产生同样的输出、且不产生副作用——这也是热重载、时间旅行等特性得以成立的前提(见 src/types/reducers.ts 的类型注释)。
Redux 应用的数据流
前文提到的"单向数据流"描述了更新应用的这一序列:
- 状态描述应用在某一时点的形态;
- UI 基于该状态渲染;
- 当某件事发生(如用户点击按钮),状态基于发生的事被更新;
- UI 基于新状态重新渲染。
具体到 Redux,可以把这些步骤进一步细化:
初始设置:
- 使用根 reducer 函数创建一个 Redux store;
- Store 调用一次根 reducer,把返回值保存为初始
state(对应源码中创建 store 时 dispatch 的INITaction); - UI 首次渲染时,UI 组件访问 Redux store 的当前状态,用这些数据决定渲染什么。同时它们订阅 store 的后续更新,以便在状态变化时获知。
更新:
- 应用中发生某件事,例如用户点击按钮;
- 应用代码向 Redux store dispatch 一个 action,如
dispatch({type: 'counter/incremented'}); - Store 再次调用 reducer 函数,参数是上一份
state和当前action,并把返回值保存为新的state; - Store 通知所有已订阅的 UI 部分:store 已更新;
- 每个需要从 store 获取数据的 UI 组件检查自己关心的那部分状态是否变化(这正是不可变更新的意义:只要某分支没变,引用就保持不变,比较引用即可判断);
- 每个发现自己数据发生变化的组件强制用新数据重新渲染,从而更新屏幕上显示的内容。
上面配图(Redux 数据流示意图)直观展示了这一流程。
源码级的数据流印证
仓库中最贴近这段描述的运行示例是 examples/counter-vanilla/index.html,它不用任何框架,完整呈现了"状态、渲染、订阅、dispatch"四要素:
function counter(state, action) {
if (typeof state === 'undefined') {
return 0
}
switch (action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}
var store = Redux.createStore(counter)
var valueEl = document.getElementById('value')
function render() {
valueEl.innerHTML = store.getState().toString()
}
render()
store.subscribe(render)
document
.getElementById('increment')
.addEventListener('click', function () {
store.dispatch({ type: 'INCREMENT' })
})
这段代码与教程描述一一对应:Redux.createStore(counter) 创建 store 并触发 INIT 得到初始值 0;render() 完成首次渲染;store.subscribe(render) 让 UI 订阅后续更新;按钮点击触发 store.dispatch({ type: 'INCREMENT' }),store 内部调用 currentReducer(currentState, action) 得到新状态,再遍历订阅者调用 render(),完成一次完整的单向数据流循环。
另外,从 createStore 源码 的结构还可以看到一个健壮性细节:store 在每次 dispatch 前会对订阅者列表做快照(ensureCanMutateNextListeners 在必要时浅拷贝 nextListeners),避免在 dispatch 过程中有人 subscribe/unsubscribe 导致正在遍历的监听器列表被意外修改——这保证了"在本次 dispatch 中,所有在 dispatch 开始前注册的订阅者都会被调用"这一行为契约(其 JSDoc 与 src/types/store.ts 中的说明一致)。
本篇小结
- Redux 的意图可以总结为三条原则
- 全局应用状态保存在单一的 store 中;
- store 状态对其余部分只读;
- 使用 reducer 函数响应 action 来更新状态。
- Redux 采用"单向数据流"的应用结构
- 状态描述应用在某时点的形态,UI 基于该状态渲染;
- 当应用中发生某事时:
- UI dispatch 一个 action;
- Store 运行 reducers,状态基于发生的事被更新;
- Store 通知 UI 状态已变化;
- UI 基于新状态重新渲染。
现在,你已经熟悉了描述一个 Redux 应用各组成部分的关键概念与术语。接下来,在 Part 3: State, Actions, and Reducers 中,我们将开始动手构建一个真正的 Redux 应用,看这些部件如何协同工作。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00

