写 React 的朋友应该都有过这种经历:一个用户信息要传给三层之外的子组件,中间两个组件明明用不到这个数据,却得一层一层把 props 传下去,写完自己都觉得别扭。项目小还好说,等组件树一深、状态一多,光靠 useState 加手动传参会把人折磨疯。React-redux 就是冲着这个问题来的,它是 Redux 官方出的 React 绑定库,让 React 组件可以访问一个统一的状态仓库(store),你可以在任意层级读取数据、派发修改动作,彻底告别 props 疯狂透传。这篇文章我会从原理、API 到完整实操和踩坑记录,把实际用 React-redux 的经验一次性讲透,适合刚接触状态管理的新手,也适合已经用了但经常被奇奇怪怪的报错困扰的同学。
1. 先把话说清楚:React-redux 到底帮你干了什么
1.1 从 props 透传的痛说起
如果你经历过"改一个字段要顺着组件树改五六个组件"的时刻,应该能理解状态管理的必要性。先看一个很常见的反面场景:
// App 里有一份用户信息,需要传给 UserProfile function App() { const [user, setUser] = useState({ name: '小明', level: 5 }); return <Layout user={user} setUser={setUser} />; } // Layout 自己不关心 user,但为了往下传必须接收 function Layout({ user, setUser }) { return <MainArea user={user} setUser={setUser} />; } // MainArea 也不关心,继续往下传 function MainArea({ user, setUser }) { return <UserProfile user={user} setUser={setUser} />; }这段代码的问题一眼就能看出来:中间的 Layout 和 MainArea 完全不需要 user 和 setUser,但它们还是要声明这两个 props,否则数据就到不了 UserProfile。这只是两层透传,如果项目里还有导航栏、弹窗、权限判断都要用到这份用户数据,你就得在所有路径上把这两个 props 铺一遍,改一个名字牵连一大片,代码里全是"离合器式"的参数传递,维护起来非常痛苦。
还有一个隐患是性能。父组件一旦 setUser,整条链路上的组件都会重新渲染,哪怕中间的组件和用户数据半毛钱关系都没有。你会被迫去写 React.memo、useCallback 来优化,而优化本身又引入新的复杂性。状态管理库解决的就是这类问题:把"这个数据放在哪里"和"这个数据怎么传下去"彻底分离,让数据只属于真正需要它的组件。
1.2 Redux 与 React-redux 的分工
很多人把 Redux 和 React-redux 混在一个词里说,其实它们是两套东西。Redux 是一个跟框架无关的 JavaScript 状态管理库,核心就三样:store、action、reducer。它能做的只是维护一份全局状态、响应 dispatch、执行纯函数更新,它根本不认识 React 组件,也不知道什么虚拟 DOM 和生命周期。
React-redux 才是专门为 React 打造的"桥梁",负责把 Redux 的 store 和 React 的组件树连接起来。打个比方:Redux 是一间管理规范的中央仓库,货物(状态)集中存放;React-redux 是仓库到各个店铺(组件)的配送专线。没有这条专线,仓库再好你也得自己开着货车去拉货——也就是自己写 context、自己订阅 store 变化、自己手动 setState 触发更新,费时费力还容易漏掉分支。
那为什么不直接用 React 自带的 Context?简单说,Context 适合"低频、少量"的全局数据,比如主题、语言;而 Redux 面向的是"高频、复杂"的业务状态,它自带一套可预测的更新流程:dispatch 一个 action,reducer 纯函数算出新状态,订阅者收到通知并更新。React-redux 内部还做了精细的订阅和比对,性能上比裸 Context 稳定得多,尤其是组件多、更新频繁的中大型项目。
1.3 从 connect 到 Hooks 的演进
老项目里你会看到大量 connect 高阶组件写法:
connect(mapStateToProps, mapDispatchToProps)(MyComponent)这是 React-redux 老版本(v5、v6、v7 早期)的主流写法。它把一个组件包进高阶组件里,由高阶组件负责从 store 读取数据、绑定 dispatch,再以 props 的形式传给真正的组件。用起来也没问题,就是模板代码多:每接入一个组件就要写 mapStateToProps、mapDispatchToProps 两个函数,类型推导也绕,初学者很容易被绕晕。
现在官方主推的是 Hooks 写法,也就是 v7.1 之后加入的 useSelector 和 useDispatch。这两个 Hooks 把原来 connect 干的事大大简化了:useSelector 负责从 store 里取值,useDispatch 负责拿到 dispatch 函数。代码量至少少一半,可读性也好很多。我后面讲的实操部分全部基于 Hooks 写法,不是说我否定 connect——老项目里它还在大量服役,迁移也不是无痛的——但新项目你用 Hooks 就对了,这是官方推荐的方向。
2. 一次性搞懂核心 API:Provider、useSelector、useDispatch
2.1 Provider:给组件树接上"数据供线"
React-redux 的 Provider 是一个组件,它接收一个 store 作为 props,把 store 放到 React 的 context 里,让整棵组件树都能拿到。一般在应用入口包一次就行:
import { Provider } from 'react-redux'; import { store } from './store'; function App() { return ( <Provider store={store}> <UserProfile /> </Provider> ); }不包 Provider 就使用 useSelector 或 useDispatch,会直接报错:"Could not find 'store' in the context of 'Connect(Component)'",意思是组件没找到 store。这个报错在 SSR 或者组件库测试场景里很常见,使用子组件单独测试、Storybook 写 story 的时候都要记得手动包一层 Provider。
Provider 还有一个容易被忽略的细节:它可以嵌套。在大型项目里,如果你有多个 store,或者某个模块需要独立的 store 实例,可以用多层 Provider 包住不同的子树。不过除非必要,别这么干,单个 store 才是 Redux 设计的主流用法,多 store 会带来状态不同步的麻烦。
2.2 useSelector:精确读取你想要的状态
useSelector 是 React-redux 提供的核心 Hook,它接收一个"选择器函数",从 store 的完整状态里挑出你需要的部分:
import { useSelector } from 'react-redux'; function UserProfile() { const user = useSelector((state) => state.user); const level = useSelector((state) => state.user.level); return <div>{user.name},当前等级 {level}</div>; }这里有个关键机制:useSelector 会对选择器的返回值做"引用比较"(默认是 === 比较)。如果返回的是基本类型,比如数字、字符串,只要值没变就不会重新渲染;但如果你返回的是每次都会新建的对象、数组,比如:
const user = useSelector((state) => { return { name: state.user.name, level: state.user.level }; });那你每次 store 更新后都会拿到一个新对象引用,React-redux 认为"值变了",组件就会重新渲染,哪怕 name 和 level 一个都没变。这就是很多"莫名奇妙一直渲染"问题的根源。解决办法有两个:一是拆细选择器,一个字段一个 useSelector;二是用 reselect 库或者 @reduxjs/toolkit 里的 createSelector 做记忆化选择器,只有依赖项真的变了才会生成新引用。
另一个实用技巧是 useSelector 的第二个参数:equalityFn。默认比较是严格相等,但你可以传 shallowEqual 让反应更"宽松":
import { useSelector, shallowEqual } from 'react-redux'; const [userName, userLevel] = useSelector( (state) => [state.user.name, state.user.level], shallowEqual );这样状态里其他字段更新时,这个组件也不会跟着重新渲染,适合一次需要取多个字段的场景。
2.3 useDispatch:只有一件事,拿 dispatch
useDispatch 比 useSelector 简单得多,它只有一个职责:返回 store 的 dispatch 方法。之后你用它派发 action:
import { useDispatch } from 'react-redux'; import { increment } from './counterSlice'; function Counter() { const dispatch = useDispatch(); return ( <button onClick={() => dispatch(increment())}> 加一 </button> ); }需要注意一个 "useCallback 配合" 的细节:如果这个组件往下层传回调,或者这个回调用在 useEffect 依赖里,记得把 dispatch 包一下:
const dispatch = useDispatch(); const handleClick = useCallback(() => { dispatch(increment()); }, [dispatch]);dispatch 函数本身是稳定的,React-redux 保证它在任何情况下引用不变,所以放进 useCallback 依赖数组是安全的,不会引起无效重建。如果你不包 useCallback,每次渲染都新建一个函数,子组件的 React.memo 优化就会被破坏,性能优化等于白做。
3. 从零到一完整实操:搭一个购物车并接入异步请求
3.1 环境准备与工具选型
先说明一下我现在建新项目的标准姿势:直接用 @reduxjs/toolkit(下面简称 RTK),而不是裸写 Redux。RTK 是 Redux 官方团队推出的"现代化工具包",它把 store 配置、reducer 编写、异步请求(createAsyncThunk)全收纳到一起,原来几十行配置现在几行搞定,还顺手内置了 immer,让 reducer 里可以直接用"可变"写法,大幅降低心智负担。
npm install react-redux @reduxjs/toolkit如果你的网络环境有需要,也可以配置镜像源,但这不是重点。安装完之后我们建一个非常典型的小购物车:包含商品列表、购物车条目、结算金额,还要模拟一个从后端拉数据的异步请求。
我要反复强调一个理念:Redux 不是用来存所有东西的。那些只在一个组件内使用的临时输入框值、弹窗开关,老老实实放 useState 里就行。只有需要跨组件共享、或者需要被多个页面使用的数据,才值得放进 store。新手最容易犯的错就是"万物皆可 Redux",结果 store 里堆了一堆垃圾状态,每次更新都引发一大片组件重渲染。
3.2 设计状态结构与创建 store
首先设计状态结构。购物车应用至少要两块:商品列表(来自后端)和购物车条目(本地操作)。我通常会这样组织:
// store/index.js import { configureStore } from '@reduxjs/toolkit'; import cartReducer from './cartSlice'; import productsReducer from './productsSlice'; export const store = configureStore({ reducer: { cart: cartReducer, products: productsReducer, }, });configureStore 会自动帮我们加上 Redux DevTools 支持、默认的中间件(包括 redux-thunk),不需要再手动装一堆开发依赖。接着写 cartSlice:
// store/cartSlice.js import { createSlice } from '@reduxjs/toolkit'; const initialState = { items: [], // [{ productId, name, price, quantity }] }; const cartSlice = createSlice({ name: 'cart', initialState, reducers: { addToCart(state, action) { const { product } = action.payload; const existing = state.items.find((item) => item.productId === product.id); if (existing) { existing.quantity += 1; } else { state.items.push({ productId: product.id, name: product.name, price: product.price, quantity: 1, }); } }, removeFromCart(state, action) { const { productId } = action.payload; state.items = state.items.filter((item) => item.productId !== productId); }, }, }); export const { addToCart, removeFromCart } = cartSlice.actions; export default cartSlice.reducer;注意 createSlice 的 reducers 里,我直接写了existing.quantity += 1这种"看起来在改状态"的代码。这要归功于 RTK 内置的 immer,它会记录你的修改操作,然后替你做一份不可变的新状态。你不需要手动展开对象、复制数组,代码写起来舒服得多。但有个坑:不要在 reducer 里写state = something这种重新赋值的语句,要返回新值请直接return ...,或者对嵌套属性做修改而不是替换整个 state。
3.3 异步请求:用 createAsyncThunk 拉取商品列表
购物车总得从服务端拿点商品数据吧。这时候用 createAsyncThunk:
// store/productsSlice.js import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; export const fetchProducts = createAsyncThunk( 'products/fetchProducts', async (_, { rejectWithValue }) => { try { const res = await fetch('/api/products'); if (!res.ok) throw new Error('请求失败'); return await res.json(); } catch (err) { return rejectWithValue(err.message); } } ); const productsSlice = createSlice({ name: 'products', initialState: { list: [], status: 'idle', // idle | loading | succeeded | failed error: null, }, reducers: {}, extraReducers: (builder) => { builder .addCase(fetchProducts.pending, (state) => { state.status = 'loading'; }) .addCase(fetchProducts.fulfilled, (state, action) => { state.status = 'succeeded'; state.list = action.payload; }) .addCase(fetchProducts.rejected, (state, action) => { state.status = 'failed'; state.error = action.payload; }); }, }); export default productsSlice.reducer;这里有一个关键认知:异步请求的结果永远不会"自动"到达 store,必须经过 dispatch 一个 action 把返回的数据写进去。createAsyncThunk 帮我们自动生成了三个 action:pending(请求中)、fulfilled(成功)、rejected(失败),你在 extraReducers 里处理这三个状态即可。这是一个真正的经验点:永远不要在组件里直接调接口然后把结果塞到 useState,那样一旦跳转页面刷新,数据就没了;经过 Redux 的请求结果,才真正做到全局共享、可控、可追踪。
3.4 组件接入:读状态、发动作、通知用户
组件层的事情就简单了。商品列表组件负责发起请求和渲染列表:
// components/ProductList.jsx import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { fetchProducts } from '../store/productsSlice'; import { addToCart } from '../store/cartSlice'; export default function ProductList() { const dispatch = useDispatch(); const { list, status, error } = useSelector((state) => state.products); useEffect(() => { if (status === 'idle') { dispatch(fetchProducts()); } }, [dispatch, status]); if (status === 'loading') return <div>加载中...</div>; if (status === 'failed') return <div>加载失败:{error}</div>; return ( <div> {list.map((product) => ( <div key={product.id}> <span>{product.name} - {product.price}元</span> <button onClick={() => dispatch(addToCart({ product }))}> 加入购物车 </button> </div> ))} </div> ); }useEffect 里判断 status 是否 idle,是为了避免重复发请求。这个模式很常见,但要注意:如果把 fetchProducts 直接放到依赖数组里,RTK 会警告你不要这么做,因为它每次渲染都是新引用。正确做法是把 createAsyncThunk 生成的 thunk 函数看成一个稳定的"动作描述",依赖它没有意义。
再写一个购物车组件展示条目和总额:
// components/Cart.jsx import { useSelector, useDispatch } from 'react-redux'; import { removeFromCart } from '../store/cartSlice'; export default function Cart() { const items = useSelector((state) => state.cart.items); const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0); return ( <div> {items.length === 0 && <p>购物车空空如也</p>} {items.map((item) => ( <div key={item.productId}> <span>{item.name} x {item.quantity}</span> <button onClick={() => dispatch(removeFromCart({ productId: item.productId }))}> 移除 </button> </div> ))} <p>合计:{total}元</p> </div> ); }到这里,一个最小可用的 React-redux 购物车就算跑通了。你可以自己跑一下试试,用 Redux DevTools 观察每次 dispatch 的记录,状态的变化一目了然。
4. 踩坑实录:常见问题与排查心得
4.1 useSelector 引发的无限渲染
这是出现频率最高的问题。症状是控制台报错 "Maximum update depth exceeded",页面卡死。原因通常是两种:要么在组件里直接调用了 dispatch 而没包在事件或 useEffect 里,要么返回了新引用导致组件反复重渲染,渲染又触发更新,形成死循环。
排查方法其实很简单:把目标组件的 useSelector 返回值打印出来,看每次渲染引用是否相同;如果是对象或数组,换成分别选择基本类型字段,或者用 shallowEqual。另外一个我踩过多次的坑是:不要在同一次渲染里调用 dispatch 然后立刻依赖同一个状态去 dispatch 另一个 action——这看起来是"业务逻辑",其实应该合并成一个 thunk 或放在 useEffect 里处理。
4.2 状态更新了,界面却不刷新
比无限渲染更气人的是"改了没反应"。多数情况和不可变更新有关。如果你用裸 Redux 写 reducer,不小心直接 push 了原数组或者改了原对象:
// 错误写法:直接修改了 state.items state.items.push(newItem);React-redux 默认通过引用比较判断状态是否变化,原数组引用没变,组件自然不更新。裸 Redux 必须返回全新对象才能触发更新;如果用了 RTK 的 createSlice,它会自动处理不可变更新,只要不写出state =这种重新赋值且不 return 的情况就没事。
还有一个隐蔽原因:selector 取的位置不对。比如你从state.cart取出了 items,但 reducer 更新的是state.cart.items,如果引用比较的对象层级错了,就可能出现数据变了但读取方没感知的错觉。遇到"不刷新"的问题,先打开 Redux DevTools 确认 reducer 执行后 state 到底变没变,再确认 selector 取的是不是最新的引用。
4.3 异步请求的"三道坎"
用 createAsyncThunk 时,新手会遇到三类经典问题。第一,忘记处理 pending 状态,导致页面出现一瞬间的空白或者旧数据被清空;第二,在 reducers 里写了异步逻辑,结果 reducer 变成副作用函数,状态不可预测;第三,请求失败后没有反馈,用户点了按钮毫无反应。
我的建议是永远用一个 status 字段显式管理请求的三个状态,并且把 error 存进 state,让界面知道发生了什么。另外,如果同一个接口在多个组件里需要数据,不要各自去 dispatch 一次,把请求逻辑提到页面级组件或者专门的数据加载组件里,集中发起请求,避免重复请求浪费流量。
4.4 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 找不到 store 报错 | 组件没被 Provider 包裹 | 在入口统一包 Provider,测试/Storybook 单独包裹 |
| 无限更新循环 | useSelector 返回新引用或在渲染期 dispatch | 拆分选择器、使用 shallowEqual,dispatch 移入事件或 effect |
| 数据变了界面不动 | 裸 Redux 直接修改原对象/数组 | 返回新对象,或用 RTK createSlice |
| 页面刷新后数据丢失 | 状态只存在组件内,没有进 store | 把需要持久化的数据写入 store,必要时配合缓存 |
| 异步请求重复发送 | 多个组件各自 dispatch 同一 thunk | 提升到页面级统一请求,或用状态标志拦截 |
| 性能下降、全部重渲染 | 选择器粒度太粗,或未做 memo 优化 | 拆细选择器、对组件包 memo、用 createSelector 记忆化 |
5. 关于连接 React 与 Redux 的工程化建议
如果你已经能跑通上面的购物车,说明核心用法已经掌握。剩下的问题就是怎么在真实项目里把它用得更舒服。这里分享几个我自己摸索出来的工程习惯。
第一,文件结构按功能模块组织,不按类型组织。很多人喜欢建一个 actions 文件夹、一个 reducers 文件夹,把所有 action 堆一起。小项目没问题,项目一大你就会发现,改一个用户模块要同时改三个文件夹里的文件。我推荐的姿势是每个功能一个文件夹:features/user、features/cart,里面放这个模块的 slice、组件、请求函数,内聚性高,删功能直接删文件夹,非常清爽。
第二,selector 不要随手写在组件里。我习惯把常用的选择器用 createSelector 提取成带名字的函数,放在 slice 文件里导出:
// store/userSlice.js export const selectUserName = (state) => state.user.name; export const selectCartCount = (state) => state.cart.items.length;这样组件里写useSelector(selectUserName),语义清晰,也方便做记忆化缓存。更关键的是,当状态结构变化时,只需要改 selector 一处,所有引用它的组件不用动,这会极大降低重构成本。
第三,把"业务动作"封装成 thunk,而不是在组件里散落地写多段 dispatch。比如"登录"涉及设置 token、写入用户信息、拉取权限三个动作,你可以在 userSlice 里写一个 login thunk 一气呵成,组件只调dispatch(login(params))。这样组件逻辑会很薄,测试也简单——组件测不测无所谓,thunk 的逻辑单独测就行了。
第四,关于 TypeScript。如果你用 TS,RTK 的官方类型推断已经做得相当好,从 configureStore 里直接导出 RootState 和 AppDispatch 类型,然后给 useDispatch、useSelector 包一层泛型:
export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch; export const useAppDispatch = () => useDispatch<AppDispatch>(); export const useAppSelector = <T>(selector: (state: RootState) => T) => useSelector(selector);这样每处使用都不用再写类型注解,还能防止误用,强烈推荐。
我个人在实际开发里最大的体会是:React-redux 真正重要的不是那几个 API,而是"全局状态到底放什么、选择器怎么设计、更新粒度怎么控制"这三个问题。API 文档一次就能看明白,但方案设计要反复打磨。新手上手的时候,按我上面这套流程把购物车完整跑一遍,再对照官方 DevTools 观察每一次 dispatch 和状态变化,比看十遍文档都管用。最后建议所有新项目直接用 RTK 起步,它不只是省代码,更是把你从一堆容易踩的坑里提前捞了出来。后面如果你要做数据缓存、权限控制、模块化了,再基于这套基础往上扩展就行。