☰
Redux架构深度解析:从单向数据流到现代状态管理实践
2026/9/26 17:39:36 网站建设 项目流程

前阵子我们团队接手了一个快烂尾的后台管理系统,组件树已经叠到五六层,用户信息、权限标识、筛选条件散落在十几个页面里。改一个下拉框,要同时排查三个地方;同一个用户资料,不同的页面能展示出两个版本。那段时间我每天的工作就是从组件 props 的海洋里捞出真正的状态来源,一度非常怀疑人生。

后来我们做了一轮全局状态架构梳理,选型毫无悬念落在 Redux 上。倒不是因为它流行,而是这个项目的核心痛点就是状态变化不可追踪、共享数据四处拷贝。Redux 的架构思路恰好把"状态从哪里来、到哪里去、为什么变"这套问题彻底标准化了。

这篇就当是那次项目 Redux 架构报告的公开版本,适合正在做前端选型、或者觉得自己项目里状态已经乱到没救的读者。我会把 Redux 从设计动机到核心机制,再到现代实践和踩坑经验尽量讲透。

1. 状态散落之后:Redux架构要解决的根本问题

1.1 一个典型的"组件树状态泄露"现场

先还原一下大多数项目是怎么一步步走到需要用全局状态管理这一步的。

最开始只是一个简单的登录功能。用户信息放在 App 组件里,通过 props 传给 Header。后来侧边栏菜单要根据用户角色显示,于是user这个 props 又被传给了 Layout,Layout 再传给 Sidebar。再后来很多页面打开时都要显示当前用户名,大家发现逐层透传太麻烦,就把用户信息放进了全局 Context。

Context 方案在小项目里挺好用,但一旦多放几块全局状态,问题就来了。任何一次setState,都会让所有消费这个 Context 的组件被通知到。项目里大家习惯把弹窗开关、主题配置、接口数据全塞进同一个 Provider,性能问题立刻浮出来。更麻烦的是排查状态变更时,根本不知道是哪一行代码、在哪个回调里改掉了那笔数据,只能靠打断点一点点试。

这就是典型的"组件树状态泄露"。状态不是按业务边界设计的,而是按组件层级散落出去的。你需要在第四层组件里改一个数据,第一层的组件却要负责为它兜底。

1.2 单向数据流:让每次数据变化都有迹可循

Redux 架构的出发点很简单:把应用状态从组件树中抽出来,放到一个独立于 UI 之外的全局 Store 里。这个 Store 是唯一事实来源,任何组件都不再直接修改自己的一个拷贝,而是发出一个描述"发生了什么事"的 Action,由纯函数 Reducer 计算出新的状态,再把状态同步给 UI。

用生活化的比喻:全家只有一个总账本。想买什么不是每个人各自记一笔,而是统一用一个动作去记账,每笔账都经过同一个记账员,账目永远可对账。

这就是单向数据流。数据只能往一个方向流转:dispatch(action) → reducer(state, action) → newState → UI → 再次 dispatch。没有双向绑定,没有各改各的,所有状态变更都是收敛的。调试时可以顺着这个单向链路一路查下去,看到底是哪个 Action 引发了状态变化。

2. 不可再拆解的核心闭环:Store / Action / Reducer / Selector

2.1 Store:应用状态的唯一事实来源

Store 是整个 Redux 架构的容器,它负责把 Action 和 Reducer 串起来,并对外提供三个核心能力:读状态、发指令、订阅更新。

import { createStore } from 'redux' function counterReducer(state = { count: 0 }, action) { switch (action.type) { case 'counter/increment': return { count: state.count + 1 } default: return state } } const store = createStore(counterReducer) console.log(store.getState()) // { count: 0 } store.dispatch({ type: 'counter/increment' }) console.log(store.getState()) // { count: 1 }

注意这里store.dispatch(action)是同步的。dispatch 一调用,Redux 会立即把 action 传进 rootReducer,计算出新的 state,再通知所有注册过的订阅者。这个过程没有异步等待,没有魔法。这也是后面理解中间件为什么能处理异步的前提——Redux 本身不做异步,异步需要靠中间件在 dispatch 到达 reducer 之前或之后做文章。

Store 还有一个容易忽略的价值:它可以脱离 React 独立存在。也就是说 Redux 并不绑定 React,你可以在任何 JavaScript 环境里创建 Store、订阅变化,再把数据带到任何 UI 层。这种解耦让状态逻辑可以在纯 Node 环境里做单元测试,不需要挂载组件。

2.2 Action只描述"发生了什么",Reducer决定"状态怎么变"

Action 在 Redux 里就是一个普通对象,必须有一个type字段表明这是什么事件,其余字段放必要载荷。它最核心的设计意图是:只描述发生了什么,不描述状态要怎么改。

const addTodoAction = { type: 'todos/add', payload: { id: 1, text: '写周报' } }

而 Reducer 是一个纯函数:传入当前 state 和 action,返回一个新 state。

function todosReducer(state = [], action) { switch (action.type) { case 'todos/add': return [...state, action.payload] default: return state } }

为什么要把"描述"和"执行"拆开?因为状态变更规则一旦集中到 Reducer 里,就变成了整个应用唯一会修改状态的地方。团队里任何成员想搞清楚某个字段到底在什么时机变化,只需要打开 Reducer 文件看 case 分支,不需要翻遍所有组件。这个约束看起来小,但在多人协作项目里价值极高。

2.3 Redux架构的灵魂:状态是reducer对动作日志的"回放"

我个人觉得,理解 Redux 架构最重要的一点不是那几个 API,而是意识到:Store 本质上是一份"动作日志 + 读模型"。

你可以把应用运行过程想象成一个不断追加的流水账:每当用户操作或系统事件发生,就会产生一个 Action 追加到日志里。当前状态并不是某个神秘变量被一次次原地覆盖,而是把初始状态喂给 reducer,从头到尾把所有 Action 依次执行一遍之后得到的结果。用公式表达就是:

currentState = reduce([initState, action1, action2, ...], reducer)

所以 Redux 做到时间旅行调试,并不是什么复杂魔法——它只是在 DevTools 里把所有历史 Action 重新回放了一遍。这也解释了为什么 Action 必须是可序列化的普通对象:只有能被存储、传输和重新构造的事件,才有资格被回放。如果你往 Action 里塞一个函数或者 DOM 节点,日志就废了。

这个角度还能解释很多设计约束:Reducer 必然不能有副作用,否则回放会产生不同结果;Reducer 必须保持纯函数,否则状态无法预测。理解了这一层,Redux 的种种"规矩"就不再是死记硬背的规则,而是同一套逻辑必然的推论。

2.4 combineReducers 与不可变更新

当应用变大,一个 reducer 管全部状态会膨胀到不可维护。Redux 提供的标准解法是combineReducers:按照业务域把状态切成一个个切片,各自用独立的 reducer 管理。

import { combineReducers, createStore } from 'redux' const userReducer = (state = { name: '' }, action) => { switch (action.type) { case 'user/setName': return { ...state, name: action.payload } default: return state } } const cartReducer = (state = { items: [] }, action) => { switch (action.type) { case 'cart/addItem': return { ...state, items: [...state.items, action.payload] } default: return state } } const rootReducer = combineReducers({ user: userReducer, cart: cartReducer }) // 最终 state 结构:{ user: { name }, cart: { items } }

combineReducers的实现很朴素:每次 dispatch 时,它遍历所有子 reducer,各自输入自己的 state 切片 and action,然后把返回值组装成一个完整的新 state 树。有几个坑是新人容易踩的:子 reducer 在初始化时不能返回undefined,否则会抛错;所有子 reducer 收到 action 时都应该处理default分支返回原 state,否则切换 action 类型时整个切片可能被意外重置。

另一个绕不开的话题是"不可变更新"。Redux 判断状态有没有变化,靠的是对象引用是否变化。如果你在 reducer 里做state.count++,返回的仍然是同一个对象引用,Store 就会认为状态没变,UI 不会更新。所以 reducer 必须有意识地创建新对象。浅层的展开运算符很好用,但深层嵌套数据就会写出三四层{ ...state, a: { ...state.a, b: ... } }这种代码。团队里可以引入 Immer 来抹平这个痛苦,但背后的思想必须清楚:永远不要原地改状态。

3. 中间件里藏着Redux异步架构的秘密

3.1 洋葱模型:中间件如何改造Dispatch

前面说 dispatch 是同步的,那网络请求这些异步操作放哪?答案是中间件。

Redux 中间件的签名是一个三层柯里化函数,最初上手时容易看着头晕:

const logger = (store) => (next) => (action) => { console.log('dispatching', action) const result = next(action) console.log('next state', store.getState()) return result }

拆开看其实不复杂:第一层拿到 store(实际上只有 dispatch 和 getState 两个能力),第二层拿到链表中的下一个 dispatch,第三层才在真正的 action 上做处理。next(action)就是把这个 action 传给下一个中间件,最终到达真正的 reducer。

多个中间件组合起来就是一个洋葱模型。Action 从最外层中间件进入,层层往内推进,穿过所有中间件后触发 reducer 计算新 state;返回值再原路返回给外部调用者。每一层中间件都可以在 action 往下传之前做拦截,也可以在 next 返回之后做收尾观察。

这就在 dispatch 链路上开了一条"隧道",异步逻辑、日志记录、错误上报都能塞进去,而不污染 reducer 的纯函数属性。

3.2 thunk与saga怎么选

异步中间件最常用的是redux-thunk和redux-saga。thunk 的思路极简,整个核心代码就一段:

const thunk = (store) => (next) => (action) => { if (typeof action === 'function') { return action(store.dispatch, store.getState) } return next(action) }

它的意思很简单:dispatch 时如果发现传入的是函数,就执行这个函数,并把dispatch和getState作为参数传进去。于是你可以写一个返回函数的 action creator,在函数内部自由做异步请求,拿到结果后再 dispatch 普通 action。

const fetchUser = () => async (dispatch) => { dispatch({ type: 'user/fetchStart' }) const res = await fetch('/api/user') const data = await res.json() dispatch({ type: 'user/fetchSuccess', payload: data }) }

saga 则是把异步流程表达成 generator 函数,通过 yield 各种 effect 描述命令,比如call发请求、put派发 action、takeLatest处理竞态。它更适合复杂编排场景:支付流程、定时轮询、取消上一个请求、串联多个接口依赖关系。thunk 在这些场景里容易写成回调地狱,而 saga 的代码读起来就像同步流程。

选型建议:大多数中后台项目的异步需求,thunk 就够用了;如果出现"用户连续点击导致重复请求""需要监听多个 action 做联动"这类复杂流程,再考虑 saga。不要一开始就上最重的方案。

3.3 在项目里让中间件各司其职

实际项目里的架构姿势大概是:logger 类中间件放在最外层,保证记录的 action 是完整原始对象,state 是最终结果;thunk 作为通用异步方案放在内层;如果接入了 saga,saga 任务会和组件完全解耦,单独放在sagas/目录里维护。

这里有一个我强调过很多次的点:中间件虽强大,但它不是给你堆业务屎山的。不要把太多逻辑塞进 action creator,更不要在中间件里直接写 UI 或路由。中间件层应该保持轻、专注,只负责"dispatch 链路上的横切关注点"——异步、日志、去重、重试。复杂的业务状态流转逻辑,放在独立的业务模块或 saga 任务里,而不是全部堆在中间件这个夹层。

4. Redux Toolkit之后,架构该怎么摆

4.1 createSlice改变了写Reducer的方式

以前写 Redux 被人诟病无非是模板代码太多。Redux Toolkit(RTK)出现以后,官方把整套架构姿势重新整理了一遍,现在这是 Redux 的标准写法。

import { createSlice, configureStore } from '@reduxjs/toolkit' const userSlice = createSlice({ name: 'user', initialState: { name: '', status: 'idle' }, reducers: { setName(state, action) { state.name = action.payload } } }) const store = configureStore({ reducer: { user: userSlice.reducer } }) store.dispatch(userSlice.actions.setName('张三'))

注意看setName里的写法:state.name = action.payload,这看起来就是在直接改 state,不是违背了不可变更新原则吗?其实 RTK 内部用 Immer 包装了 reducer,你写的是对草稿状态的修改操作,Immer 在背后会产生新的不可变对象。心智上可以延续"直接改"的直觉,但团队新人必须先理解这层原理,否则会误以为 Redux 已经允许可变更新了。

configureStore还省掉了很多配置,默认集成了 thunk 中间件和 DevTools 支持。过去写 Store 需要手动拼applyMiddleware的日子基本结束了。

4.2 客户端状态与服务端状态要分清

这些年我在多个项目里看到同一个问题:团队把接口返回的数据原封不动全塞进 Redux。订单列表、用户资料、商品详情,全都变成 Redux state 里的几个巨型切片。结果每次请求,都要手动维护loading、error、data三个字段,还要自己实现缓存、刷新、重试。

这类状态本质上是服务端状态的缓存副本,它和 Redux 擅长管理的客户端状态是两种东西。服务端状态的核心诉求是缓存失效、过期清理、自动重拉,这些恰恰是 React Query、SWR 这类数据请求库的主场。而 Redux 真正适合的是客户端状态:用户偏好、购物车、全局弹窗、跨模块共享的交互数据。

状态的边界判断可以这样问自己:如果这个数据是后端返回的镜像,UI 只是消费它,那优先考虑请求层缓存方案;如果是用户在当前操作过程中产生的、需要多个模块共享的状态,那放 Redux 很合理。现在写项目,我一般会让数据请求库处理接口缓存,Redux 只存全局客户端状态,两边分工明确,代码量反而更少。

4.3 Selector是第二层架构

很多团队只用 state 和 dispatch,却忽略了 selector 这一层。Selector 的作用是把状态树中的原始数据,计算成组件真正需要的派生数据。

import { createSelector } from 'reselect' const selectTodos = (state) => state.todos.items const selectFilter = (state) => state.todos.filter export const selectVisibleTodos = createSelector( [selectTodos, selectFilter], (todos, filter) => todos.filter((todo) => todo.status === filter) )

createSelector的价值是缓存。只有当输入 selector 的返回值变化时,它才会重新计算结果;如果输入没变,它直接返回上一次缓存的结果。这能避免过滤器列表、排序结果这类昂贵的派生在每次渲染时都重算一遍,也避免了 useSelector 里返回新对象导致的重渲染。

我在团队里定的规范是所有跨模块读取状态必须走 selector,组件禁止直接钻取state.a.b.c。这样一旦状态结构要调整,只需要改动 selector 这一层,组件基本不受影响。这层抽象没人强制的时候,大家都会偷懒,但它在架构演进时能救你一命。

5. 团队实践:踩过的坑与收敛后的架构规范

5.1 所有状态都进Redux的结果

我们团队早期犯过一个很蠢的错误:把输入框的 value 也 dispatch 到 Redux。结果每个按键都触发整个 Store 的一次更新,所有注册了 useSelector 的组件都要跑一遍 selector 比对。一次输入没完成,几十上百个组件集体检查一遍自己关心的状态有没有变,性能问题非常典型。

后来我们逼着自己在写任何状态前先分类:只影响当前组件内的状态(比如某个弹窗的展开动画、临时草稿、输入框文本)一律用useState或组件局部状态;需要跨组件共享、需要可回溯的状态,才进 Redux。这套分类规范比任何性能优化技巧都管用,因为大部分性能问题其实是架构设计问题。

5.2 嵌套数据的更新噩梦与归一化

另一个坑来自后端接口太"贴心"。某个文章模块的接口把作者信息、评论列表全都嵌在文章对象里,state 深度一度达到四层。改一条评论内容,reducer 里要先复制文章对象、再复制评论数组、再找到那一条评论展开。代码写得像俄罗斯套娃,稍不注意引用就搞错,更新完发现 UI 根本不刷新。

后来我们规范了状态结构,统一用归一化数据模型。核心思路是:实体只存一份,用byId + ids维护,对象之间用 id 关联,而不是嵌套完整对象。

const state = { articles: { byId: { 1: { id: 1, title: '...', authorId: 101, commentIds: [501, 502] } }, ids: [1] }, users: { byId: { 101: { id: 101, name: '张三' } }, ids: [101] }, comments: { byId: { 501: { id: 501, text: '好文' }, 502: { id: 502, text: '赞' } }, ids: [501, 502] } }

改成这样之后,更新一条评论只需要改comments.byId[501],不再牵扯文章对象。查找文章内容时通过 id 关联去查用户和评论,组合关系清晰,也没有冗余数据导致的多处同步问题。这个调整让相关 reducer 代码量少了一半,调试的时候一眼就看出是谁在改数据。

5.3 选Redux前先回答三个问题

如果你们的项目还没上 Redux,正在纠结要不要上,我建议先回答三个问题:

  1. 是否真的有多个模块需要共享同一份状态?如果只有一两个相邻组件需要通信,父子组件传参或者组件局部状态就够,不要全局化。
  2. 这些状态的变化是否需要审计、追踪、回放?涉及到复杂业务规则、需要明确"每一步为什么变"的场景,Redux 价值最大。
  3. 现有的 state 方案是不是真的搞不定?如果你连 App 级 Context 都还没写崩,那大概率没有必要立刻引入 Redux。

三个问题里至少有一个回答为"是",才值得把 Redux 放进架构。它为项目带来的约束感一开始会让人觉得不适应,但恰恰是这些约束,让状态在复杂项目里还能保持可预测。如果只是为了"别人都用我也要用",建议冷静一晚上。

最后再分享一个我自己的体会。Redux 架构拆到最后,那些 Action、Reducer、Store 的命名都不重要,真正重要的是它逼着你回答一个核心问题:"我的状态会以什么方式、因为什么事件、按什么规则发生变化?" 很多项目真正缺的不是一个状态管理库,而是对这个问题的认真回答。所以建议准备上 Redux 或重构现有状态方案之前,先把当前状态按"服务端缓存、客户端全局、组件局部"三类画一张清单,再回来决定往哪走,这个步骤至少能帮你避开一半日后要踩的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询