☰
React状态管理实战:Redux核心概念、异步方案与工程规范
2026/10/10 8:00:29 网站建设 项目流程

很多人在 React 这条路上绕不开 Redux,但坦白说,我最早接触 Redux 的时候也觉得它“重”:action、reducer、store、dispatch 这一套名词刚上手时很容易绕晕。后来用久了才发现,Redux 真正厉害的地方不是它有多少概念,而是它用一套极简的约束,把“数据从哪里来、数据怎么变、在哪个组件用”这三件事彻底理清了。

这篇笔记我不打算从零开始讲“什么是 Redux”,而是把我自己在学习和实际项目里反复踩过的坑、琢磨透的原理、以及逐步养成的一些好习惯记录下来。内容里会穿插大量代码示例、对比和场景复盘,适合已经写过一些 React、准备系统消化 Redux 的开发者参考,也适合那种“看了文档但做项目还是不会组织状态”的阶段。

1. Redux 到底改变了什么

1.1 从“多个组件自己存数据”到“全局唯一 Store”

刚开始写 React 组件的时候,大家习惯直接用 useState 和 useContext 把数据放在组件内部,或者通过 props 层层传递。这在组件少、数据关系简单的时候没什么问题,但一旦出现多个互不相关的组件需要共享同一份数据,或者数据变化发生在深层组件但影响范围在另外几条组件分支上的时候,代码很快就会变成一团乱麻。

Redux 的核心思想其实很简单:把整个应用的所有需要共享的状态,集中放到一个对象里,这个对象叫做 Store。这个 Store 是应用唯一的“数据仓库”,不管哪个组件要读数据,都从 Store 里拿;不管哪个组件要改数据,都通过统一的方式去改。这里引用 Flux 架构思想做对比会更直观:Flux 里有 Dispatcher、Store、View 和 Action 四部分,Redux 把 Dispatcher 去掉了,Store 自己兼任了部分调度职责,整个流程更短也更统一。

我自己在实际项目里体验最深的一点是:Redux 让“状态从哪儿来”这个问题不再分散。以前排查一个数据问题,要在十几个 props 里来回找,现在只要你看到组件里用了 useSelector,就知道数据一定来自全局 Store;只要看到 dispatch,就知道这里触发了某个行为。数据流是收敛的,可预测性自然就上来了。

1.2 单向数据流这条铁律

Redux 的数据流是单向的,可以简化成四步:组件 dispatch(action) -> Reducer 计算新 state -> Store 更新并通知订阅者 -> 组件从 Store 读取最新数据并渲染。

这个约束表面上看起来是限制,但实际上它保证了每一次数据变化都可以被追溯。我在小组件里随便 setState 的时候不会去想“是谁改了这个值”,但在 Redux 项目里,只要某个状态不对,你可以直接打开 Redux DevTools 看到每一次 action 的触发时间、payload 内容和 reducer 执行前后的状态对比,很多问题在五分钟内就能定位。

为什么这一步一定要“单向”?如果用双向绑定,改数据需要一个非常明确的通知机制,否则没法确定是谁先改谁后改。单向数据流把“数据变更”和“视图渲染”通过 action 这个中间层解耦,数据的“发出者”和“改变者”都在代码里变成显式的,这样就能在状态管理逻辑和组件渲染逻辑之间画出一条清晰的边界。

2. 把核心概念翻译成人话

2.1 三个核心概念的日常类比

我通常会把 Redux 这套机制类比成一个“银行柜台”。

  • 银行不会让你直接进金库改数字,你必须先填单子,这个单子就是 action。
  • 柜台工作人员拿到单子后,按照规则告诉你账户余额改成多少,这个“计算新余额”的过程就是 reducer。
  • 最终的余额记录存放在总账本上,这个总账本就是 store。

这个类比能很好说明 action 为什么是一个描述“发生了什么”的对象,而不是直接告诉 Store“把余额改成1000”。因为从“改多少”到“怎么改”之间,应该隔着一步“按规则计算”的逻辑。比如同样是“取钱”,有些账户可以透支,有些不行;是普通取款还是跨行取款,计算结果也不同。如果让组件直接写死状态,这些规则就被打散到了各个组件里,后续维护成本非常高。

从代码上看,一个基础 action 长这样:

{ type: 'account/withdraw', payload: { amount: 500 } }

type 是必填的,payload 是可选的负载数据。项目小的时候可以把 type 写成字符串,但项目一变大,字符串一旦打错一个字母,报错信息往往查半天。建议从一开始就养成统一管理 action type 的习惯,比如用函数生成或常量定义,这块后面我会专门说。

2.2 Reducer 为什么必须是纯函数

Reducer 的作用是“根据前一个 state 和当前 action 来计算下一个 state”。官网强调 reducer 必须是纯函数:同样的输入永远得到同样的输出,而且不修改传入的参数。

我在学习那段时间踩过一个很典型的坑:直接在 reducer 里写 state.user.name = 'xxx',然后返回 state。表面上页面数据确实变了,但 Redux DevTools 里时间旅行会用不了,因为 state 对象的引用没变,React 无法感知更新,甚至可能因为旧引用判断导致某些依赖 state 的组件不重新渲染。

正确的做法是每次都返回一个新的对象:

const initialState = { userInfo: { name: '', age: 0 }, loginStatus: false } function userReducer(state = initialState, action) { switch (action.type) { case 'user/setName': return { ...state, userInfo: { ...state.userInfo, name: action.payload } } default: return state } }

这里有一个很容易忽略的细节:浅拷贝只能展开一层,嵌套对象(如 userInfo)必须逐层展开。很多人初学时就因为少展开了一层导致状态丢失或不可控。自己实现嵌套数据更新比较繁琐,这也是为什么后来官方推出的 @reduxjs/toolkit 内置了 immer,它允许你直接写“看起来像是在修改原对象”的代码,自动帮你做不可变更新。

2.3 Store 创建与 subscribe 的底层逻辑

调用 createStore 之后,Redux 内部会维持一个 currentState 变量,同时维护一个监听函数数组。dispatch 一个 action 后,store 会执行当前 reducer、替换 currentState,然后遍历执行所有 listener。

我平时写组件很少直接用 store.subscribe,因为 react-redux 库已经把这层关联做好了。但理解这个原理很重要:为什么 react-redux 的 useSelector 能在 Store 变化后触发组件更新的底层机制,正是基于 subscribe 和 useSyncExternalStore 这套机制。

推荐大家花点时间手写一个迷你 Redux,大概二三十行就能写出来。你做一遍之后就会发现,dispatch、reducer、subscribe 之间的关系根本没有想象中那么玄妙,后续看很多复杂概念都会轻松许多。

3. React 组件怎么和 Redux 衔接

3.1 Provider 与组件树

react-redux 提供<Provider store={store}>组件。按照我的理解,Provider 做的事情本质上是 React Context 的一个封装,它将 store 对象放到了 context 里,这样任意层级的后代组件都能拿到 store。

为什么不用 React Context 直接用,而是用 react-redux?

这个问题我认真想过。React Context 可以在 prop 传递上减少样板代码,但它的重新渲染机制比较“粗”,一旦 context value 变化,所有消费该 context 的组件都会重新渲染。而 react-redux 内部会做很多精细的订阅和比对,只有当前组件确实用到的某个状态片段变了,才会触发该组件的重渲染。对于中大型应用来说,这个差异非常明显。

另外 react-redux 从 8 开始要求 React 18,底层同步机制基于 useSyncExternalStore,保证了并发渲染模式下读取到的状态和订阅的一致性。这些细节普通开发者可能不太关心,但知道它们存在,能帮你理解那些“莫名其妙的重渲染”现象。

3.2 useDispatch 与 action 触发

得到 dispatch 方法很简单:

import { useDispatch } from 'react-redux' function LoginButton() { const dispatch = useDispatch() const handleLogin = () => { dispatch({ type: 'user/login', payload: { username: 'admin' } }) } }

需要注意一个常见反模式:不要把 dispatch 出来的对象直接放在组件的深层依赖里,比如 useEffect 的依赖数组。如果你写的是 dispatch(userActions.fetchData()),每次渲染都生成了一个新的 action 对象,很容易导致 effect 反复执行。正确的做法是把 dispatch 本身作为依赖,或者把需要触发的行为封装成 useCallback。

如果用了 Redux Toolkit,它会自动生成 actions,你只管 dispatch 那个由 createAction 或 createSlice 生成的 action creator 就行,不需要每次都手写对象字面量,少了很多低级错误。

3.3 useSelector 的细节:返回值对比与重渲染陷阱

useSelector 接收一个 selector 函数,返回从 state 中提取的数据。但 react-redux 默认使用严格相等(===)来判断返回值是否有变化。如果返回的是新对象,即使内容一样,也会导致组件重新渲染。

当初我在一个列表页里写了这样的代码:

const allData = useSelector(state => ({ list: state.list, total: state.total }))

结果发现每点一次按钮,页面都重新渲染,而且角色列表没有任何变化时也会触发刷新。原因很简单:这个箭头函数每次都返回一个新对象,react-redux 认为数据变了。

解决办法有几种:

  • 分开两次调用 useSelector,分别取 list 和 total,避免合并对象。
  • 使用浅相等比较,调用 useSelector 时传入第二个参数,可以使用 react-redux 自带的 shallowEqual。
  • 使用 reselect 的 createSelector 生成缓存 selector,避免不必要的重复计算。

下面是一个用 createSelector 的经典示例:

import { createSelector } from 'reselect' const selectUserList = state => state.user.list const selectFilterStatus = state => state.user.filterStatus const selectFilteredUsers = createSelector( [selectUserList, selectFilterStatus], (users, status) => { return users.filter(item => item.status === status) } )

只有当 users 或 status 变化时,这个 selector 才会重新计算。否则直接使用上一次缓存的计算结果。这在高频交互页面里能省下不少不必要的计算开销。

3.4 容器组件与展示组件分离

有人会觉得现在 Hooks 已经很方便了,还搞容器/展示组件分离是不是过时了?

我的观点是:这个思想永远不会过时,只是形式变了。以前 container 组件负责 connect,展示组件负责渲染;现在用 Hooks 后,可以把带 useSelector/useDispatch 的逻辑部分拆成一个自定义 Hook,把纯渲染部分变成无状态组件。这样便于测试、复用,也让页面组件文件的大小控制在可维护范围内。

4. 异步逻辑与 Redux 的配合方案

4.1 Redux 本身不处理异步

如果你认真学过 Redux 的源码和设计,会发现它本身是个同步状态机:dispatch(action) -> reducer -> state 更新,这个过程没有异步的影子。但真实业务里充斥着接口请求、定时器、事件监听等异步操作,所以必须有一种方式把这些异步行为接入到 Redux 的数据流里。

我见过的最初级的做法是:在组件里发请求,然后在 .then 里再 dispatch 两个 action,一个表示请求开始,一个表示请求成功。这种写法能用,但会导致组件里夹杂大量业务请求逻辑,不利于复用和维护。

4.2 从 thunk 到 saga,再到 RTK 全家桶的演进

在学习不同异步方案时,我把主流几种都实际写了一遍:

  • redux-thunk:核心是允许 action creator 返回一个函数,这个函数接收 dispatch 和 getState 作为参数。你可以在函数里执行异步逻辑,在恰当的时机多次 dispatch。它非常轻量,只有几行代码,适合大部分请求场景。
  • redux-saga:使用 ES6 Generator 函数,把副作用逻辑写成“可中断、可监听”的流程。它能够处理更复杂的并发场景,比如取消请求、监听多个 action 后做串联处理,但学习成本明显更高。
  • @reduxjs/toolkit 自带的 createAsyncThunk:它是官方推荐的现代方案,把“pending、fulfilled、rejected”三种状态封装成一套自动机制。

我个人的体会是,中小型项目直接用 createAsyncThunk 就足够了,写起来非常顺。下面是一个典型示例:

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' export const fetchUserList = createAsyncThunk( 'user/fetchUserList', async (params, { rejectWithValue }) => { try { const res = await api.getUserList(params) return res.data } catch (error) { return rejectWithValue(error.response.data) } } ) const userSlice = createSlice({ name: 'user', initialState: { list: [], loading: false, error: null }, reducers: {}, extraReducers: builder => { builder .addCase(fetchUserList.pending, state => { state.loading = true state.error = null }) .addCase(fetchUserList.fulfilled, (state, action) => { state.loading = false state.list = action.payload }) .addCase(fetchUserList.rejected, (state, action) => { state.loading = false state.error = action.payload }) } })

看到这里你应该能感觉到,Redux Toolkit 的写法比手写 switch case 舒服太多了。这套东西不是凭空来的,它就是把原来那些“经验老手才会做的事”全部内置做了。

4.3 异步请求的竞态问题拆解

异步最容易被忽视的就是竞态。比如用户搜索关键字,输入变化很快,每次都会发出请求,如果后发的请求先返回,先发的请求后返回,界面显示的内容可能和用户当前输入不一致。

以前用 thunk 写这个逻辑时比较麻烦,需要自己在组件里记一个 requestId 或者用 AbortController 取消旧请求。用 createAsyncThunk 就方便很多,每个 async thunk 都会附带一个 requestId,在 extraReducers 里你可以通过 action.meta.requestId 去辨别,还可以配合 AbortController 把过期请求真正取消掉。

我在实际项目里还养成了一个习惯:所有列表请求的 loading 状态不要写成一个全局的 boolean,否则多个地方同时请求时会互相干扰。最好按“接口名/模块名”来拆分 loading 字段,类似loading: { fetchList: false, fetchDetail: false },这样可控性会好很多。

5. 工程实践中总结的规范与坑

5.1 action 命名与文件组织

我见过不少项目的 action type 五花八门:

  • SAVE_USER_INFO
  • save_user
  • user/saveInfo

前两种很容易在项目变大后产生冲突,或者让人wonder不知道在哪个模块里。后面这种“模块/行为”的命名方式才是主流,它把一堆相关的 action 自动归到同一个命名空间下。比如用户模块里的 setAvatar,完整 type 是user/setAvatar,既清晰又能在调试工具里快速过滤查找。

文件组织上,早年间流行把 actions、reducers、selectors 各自放在一个大目录里。后来大家用多了发现,这样改一个 user 模块的需求,要同时改三个地方,跳来跳去很痛苦。现在的趋势是“按功能或页面切片”,每个 slice 一个文件,里面同时包含 reducers、asyncThunks 和 selectors。用 Redux Toolkit 的 createSlice 之后,这个组织方式天然就能做到了。

5.2 实体化状态:别把嵌套数据当成唯一真理

后端返回的 JSON 通常嵌套很深,比如:

{ "id": 1, "author": { "id": 10, "name": "Tom" }, "comments": [ { "id": 100, "content": "nice", "reply": { "id": 200, "content": "thanks" } } ] }

新手很容易直接把这份对象原封不动存进 store。但到后续业务里,你可能会经常需要“根据 commentId 找到某条评论”“根据 authorId 判断当前用户是否是作者”,每次都要遍历查找,性能和代码可读性都很差。

更合理的做法是把嵌套结构“扁平化”存储,把文章、作者、评论都存成以 id 为 key 的字典结构,通过 id 去引用关系。这种思路就是实体化(normalize)建模。这个经验让我在写复杂业务时轻松很多,特别是类似消息流、订单列表这类数据,常看常新。

5.3 Selector 单独管理与复用

只看最简单的useSelector(state => state.user.name)时,selectors 显得没什么价值。但当你遇到这种场景就不一样了:多个组件都需要“根据当前筛选条件和搜索关键字,从 state 里的原始列表中计算出展示列表”。

如果每个组件里都写一遍同样的 filter 逻辑,不仅重复,而且一旦规则变了,要到处找。把这一类“派生数据”提取成独立的 selector 函数,放到模块文件里统一导出,所有组件都引用同一个 selector,既复用又便于单元测试。

用 reselect 时还要留意缓存失效的事实因素。createSelector 只有在输入参数变化时才会重新计算,所以你传入的原始切片state.user.list只要引用不变,就会直接走缓存。这需要你在写 reducer 的更新逻辑时严格遵守不可变更新,确保没有意义的数据变更不会产生新的引用。

5.4 中间件顺序与调式

createStore 时可以传入 applyMiddleware(thunk)。如果你以后要用 Redux DevTools 的 time travel 功能,还要把 composeWithDevTools 包一层。官方现在推荐直接使用 toolkit 的configureStore,它默认帮我们集成了这些中间件和 devtools 配置,不用自己费心去配。

遇到“store 里数据没更新但页面也不刷新”这类问题时,先别怀疑 React,第一件事是打开 Redux DevTools 看 action 是否被触发,再检查 reducer 有没有返回新引用。大部分情况下,问题都出在不小心修改了原对象,或者 selector 返回了新构造的对象,而不是用了旧引用。

我还调试过一个印象很深的 bug:某个 action 在网络请求异常时被重复 dispatch,导致所有页面都同时进入了 loading 状态,整个应用瞬间感觉卡死。后来检查发现,这是组件卸载后的回调里依然触发了 dispatch。React 18 里虽然没有像之前那样警告 “Can't perform a React state update on an unmounted component” 了,但业务上的误操作还是得靠习惯规避——在组件卸载或请求完成后,注意清理副作用或者用标志位判断。

6. 一套简单可复用的状态模块模板

说了这么多理论,直接给你一个纯函数化、按照 Redux Toolkit 推荐的模块模板。我自己的新项目基本从这套结构改一改就可以复用。

目录结构:

src/ app/ store.js features/ user/ userSlice.js userSelectors.js index.js

store.js:

import { configureStore } from '@reduxjs/toolkit' import userReducer from '../features/user/userSlice' export const store = configureStore({ reducer: { user: userReducer } })

userSlice.js:

import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' import { api } from '../../services/api' export const fetchUserInfo = createAsyncThunk( 'user/fetchUserInfo', async (userId, { rejectWithValue }) => { const res = await api.getUserInfo(userId) if (res.code === 0) { return res.data } return rejectWithValue(res.message) } ) const userSlice = createSlice({ name: 'user', initialState: { info: null, status: 'idle' // idle | loading | success | failed }, reducers: { clearUserInfo(state) { state.info = null state.status = 'idle' } }, extraReducers: builder => { builder .addCase(fetchUserInfo.pending, state => { state.status = 'loading' }) .addCase(fetchUserInfo.fulfilled, (state, action) => { state.status = 'success' state.info = action.payload }) .addCase(fetchUserInfo.rejected, state => { state.status = 'failed' }) } }) export const { clearUserInfo } = userSlice.actions export default userSlice.reducer

在组件里使用:

import { useSelector, useDispatch } from 'react-redux' import { fetchUserInfo, clearUserInfo } from './features/user/userSlice' function UserProfile({ userId }) { const dispatch = useDispatch() const userInfo = useSelector(state => state.user.info) const status = useSelector(state => state.user.status) useEffect(() => { dispatch(fetchUserInfo(userId)) return () => dispatch(clearUserInfo()) }, [dispatch, userId]) if (status === 'loading') return <Spin /> return <div>{userInfo?.name}</div> }

这类模板在中小型项目里几乎是标准答案。它保持了全局 Store 的单一数据源,也让每个功能模块内部自治:Slice 管状态、AsyncThunk 管异步、Selector 管派生数据。团队协作时,每个人负责一个 features 目录,很少会产生代码冲突。

我也见过有人吐槽 Redux 光学习成本就劝退了一批人。但我的真实体会是,React 里不该所有 state 都进 Redux,只把跨模块共享状态、请求状态和解耦后需要被持久化的数据放进来就够了;组件里的局部 UI 状态,比如弹窗开关、选中 tab,用 useState 管理反而更舒服。State 放哪里的原则,应该是“局部归局部,全局归全局”,这句话想明白之后 Redux 的很多争论都自然消解了。

如果你现在正处在“Redux 看懂了,写业务写不明白”的阶段,建议不要死磕概念,而是拿一个真实的小项目练手,从 createSlice 和 createAsyncThunk 开始用,先把这个痛快、顺手的流程跑通,再回头翻老文档里的 action、reducer、middleware 那些底层原理,你会发现自己突然有了一种“原来如此”的感觉。这套状态管理模式能存在这么多年,本身已经说明它沉淀了太多宝贵的工程经验。

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

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

立即咨询