React-redux 原理与实战:告别 props 透传,用 Hooks 高效管理全局状态

发布时间:2026/10/2 22:14:40
React-redux 原理与实战:告别 props 透传,用 Hooks 高效管理全局状态 写 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 一个 actionreducer 纯函数算出新状态订阅者收到通知并更新。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. 一次性搞懂核心 APIProvider、useSelector、useDispatch2.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只有一件事拿 dispatchuseDispatch 比 useSelector 简单得多它只有一个职责返回 store 的 dispatch 方法。之后你用它派发 actionimport { 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 帮我们自动生成了三个 actionpending请求中、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拆分选择器、使用 shallowEqualdispatch 移入事件或 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。如果你用 TSRTK 的官方类型推断已经做得相当好从 configureStore 里直接导出 RootState 和 AppDispatch 类型然后给 useDispatch、useSelector 包一层泛型export type RootState ReturnTypetypeof store.getState; export type AppDispatch typeof store.dispatch; export const useAppDispatch () useDispatchAppDispatch(); export const useAppSelector T(selector: (state: RootState) T) useSelector(selector);这样每处使用都不用再写类型注解还能防止误用强烈推荐。我个人在实际开发里最大的体会是React-redux 真正重要的不是那几个 API而是全局状态到底放什么、选择器怎么设计、更新粒度怎么控制这三个问题。API 文档一次就能看明白但方案设计要反复打磨。新手上手的时候按我上面这套流程把购物车完整跑一遍再对照官方 DevTools 观察每一次 dispatch 和状态变化比看十遍文档都管用。最后建议所有新项目直接用 RTK 起步它不只是省代码更是把你从一堆容易踩的坑里提前捞了出来。后面如果你要做数据缓存、权限控制、模块化了再基于这套基础往上扩展就行。