主题
Redux 与状态管理(二)
本页对应 Redux 与状态管理,共 7 段视频,约 1 小时 3 分。
学习目标
- 能够使用 RTK 的
createSlice、configureStore创建并配置 store,定义商品列表、分类激活下标、购物车列表等状态及对应 reducer。 - 能够编写异步 action,通过 axios 获取商品数据并存入 store,在组件中使用
useDispatch触发 action、使用useSelector读取数据完成分类和商品列表渲染。 - 能够实现分类点击激活交互与商品列表联动显示,使用
activeIndex动态控制激活类名,并按当前分类下标进行条件渲染。 - 能够实现添加购物车逻辑:根据商品 id 判断是否已存在,存在则
count + 1,不存在则追加新商品,并在商品项组件中提交完整商品对象。 - 能够基于购物车列表计算商品总数量与总价,控制统计区域高亮及结算文案显示,并实现购物车列表渲染、数量增减和清空购物车功能。
- 能够使用
useState管理购物车显示隐藏的 UI 临时状态,区分组件本地状态与 Redux 全局状态,并在购物车为空时禁止打开购物车面板。
知识点
Redux 是什么:把状态从组件里“提”出来
Redux 是 React 最常用的集中状态管理工具,类似于 Vue 中的 Pinia(Vuex),并且可以独立于框架运行。它的作用一句话概括:通过集中管理的方式管理应用的状态。

为什么需要它?因为组件之间共享数据时,如果只靠 props 一层层往下传,层级一深就会非常绕;兄弟组件之间通信也没有天然通道。怎么想?把“多个组件都要用的数据”从组件内部搬到组件外部的一个统一仓库里,组件只负责读和改,不再互相传。怎么做?在项目里建立 store,把状态、修改状态的方法集中定义,再通过 Provider 注入到组件树。易错点:不是所有状态都该进 Redux。转写稿在“控制购物车显示隐藏”里专门强调:这个显示状态只在当前组件有效、用完即没,属于 UI 临时状态,用 useState 就够了,不必放进 store。
为什么要使用 Redux:三个理由
- 独立于组件,无视组件之间的层级关系,简化通信问题;
- 单向数据流清晰,易于定位 bug;
- 调试工具配套良好,方便调试。
为什么需要这三点?因为中大型应用里,状态来源一多,最怕“不知道谁改了数据”。怎么想?Redux 把修改入口收窄:组件不能直接改 state,只能 dispatch 一个 action,由 reducer 统一处理。怎么做?转写稿的调试过程很直观:打开浏览器 Network,能看到接口请求;再打开 Redux 调试工具,能看到确实触发了 setFoodsList 这样的 action,右侧 state 里 foods 模块的数据被存了下来。易错点:不要绕过 dispatch 直接改 store 里的对象,否则调试工具里的 action 记录就失去意义。
用案例理解数据流:分类与商品列表渲染
转写稿用美团案例演示:左侧分类列表、右侧商品列表都要从后端拿数据。为什么需要 Redux?因为这两块数据要被多个组件共享。怎么想?把数据放进 store 的 foods 模块,用异步 action 请求接口,组件只负责触发和渲染。怎么做?先启动 mock 服务(提供可请求的接口)和前端服务,再写 store:
js
import { createSlice } from '@reduxjs/toolkit'
import axios from 'axios'
const foodsStore = createSlice({
name: 'foods',
initialState: { foodsList: [] },
reducers: {
setFoodsList(state, action) {
state.foodsList = action.payload
}
}
})
// 异步 action
const fetchFoodsList = () => {
return async (dispatch) => {
const res = await axios.get('接口地址')
dispatch(setFoodsList(res.data))
}
}
export const { setFoodsList } = foodsStore.actions
export default foodsStore.reducer
export { fetchFoodsList }然后在入口组合:
js
import { configureStore } from '@reduxjs/toolkit'
import foodsReducer from './modules/foods'
const store = configureStore({
reducer: { foods: foodsReducer }
})
export default store再在 index.js 里用 react-redux 的 Provider 包住 App,并传入 store。
易错点:转写稿里口述的“create.Slice”“configure store”“exos”分别是 createSlice、configureStore、axios;异步函数必须 return 一个函数并接收 dispatch,否则 thunk 不生效。
组件里怎么用:useDispatch 与 useSelector
为什么需要两个 Hook?因为组件要“发动作”和“读数据”。怎么想?useDispatch 拿到 dispatch 方法,useSelector 从 store 里映射出需要的字段。怎么做?在根组件里触发一次请求:
js
const dispatch = useDispatch()
useEffect(() => {
dispatch(fetchFoodsList())
}, [dispatch])渲染时:
js
const { foodsList } = useSelector(state => state.foods)易错点:useSelector 里的 state.foods 必须和 configureStore 里注册的模块名一致;转写稿里反复提醒“得跟 store 里配置的模块名字匹配上”。
增删改查里的典型写法与调试
添加购物车时,核心判断是“当前商品是否已添加过”。怎么想?用 find 按 id 查找,找到就 count 加一,找不到就 push。怎么做?
js
addCart(state, action) {
const item = state.cartList.find(i => i.id === action.payload.id)
if (item) item.count++
else state.cartList.push(action.payload)
}统计总价时用 reduce 累加“单价 × 数量”,再用 toFixed(2) 保留两位小数;购物车数量为 0 时不给高亮类名。易错点:转写稿里减号逻辑一开始会减成负数,后来补了判断——count 已经等于 0 就直接 return;另外 Redux 里没有 Vue 那种“计算属性”,每次数据变化组件重新渲染时会重新计算,所以直接在组件里算即可。
从“列表渲染”理解 RTK 数据流
为什么需要:分类和商品列表都来自后端,不能继续用静态模拟数据;需要把异步请求结果放进 store,再由组件订阅渲染。
怎么想:数据属于跨组件共享状态,用 RTK 管理;请求是异步操作,所以先写同步 reducer,再写异步 action。
怎么做:在 foods 子模块里定义 list,同步 setFoodsList 负责写入;异步 fetchFoodsList 内用 axios.get 取数据,再 dispatch(setFoodsList(data))。入口用 configureStore 组合 reducer,并用 Provider 注入。组件里用 useDispatch 触发、useEffect 只请求一次,用 useSelector 取 state.foods.list 渲染。
易错点:createSlice 的模块名要和 useSelector 里的字段匹配;异步函数要返回函数并接收 dispatch;请求地址来自 mock 服务启动后给出的接口地址,不要写错。
js
const foodsSlice = createSlice({
name: 'foods',
initialState: { list: [] },
reducers: {
setFoodsList(state, action) { state.list = action.payload }
}
})
const fetchFoodsList = () => {
return async (dispatch) => {
const res = await axios.get('接口地址')
dispatch(setFoodsList(res.data))
}
}调试时看 Network 是否发出请求,再看 Redux DevTools 是否出现 setFoodsList,确认数据进 store 后再渲染。
分类激活与右侧列表切换
为什么需要:点击左侧分类要切换激活态,同时右侧只显示对应商品列表。
怎么想:这是典型 tab 切换:先记录当前项下标 activeIndex,再用它和当前 index 比较,决定类名和内容是否显示。
怎么做:在 store 新增 activeIndex 和 changeActiveIndex;菜单项点击时 dispatch(changeActiveIndex(index));类名用 activeIndex === index && 'active';右侧列表同样用 activeIndex === index 做条件渲染。
易错点:不要只改样式不存下标;activeIndex 要取 store 中的值,不能误用局部变量。
js
onClick={() => dispatch(changeActiveIndex(index))}
className={activeIndex === index ? 'active' : ''}转写中的调试过程是:默认 activeIndex 为 0,点第二项变 1,点第三项变 2,说明下标记录成功;再加条件后,右侧随点击切换。
添加购物车与统计区域
为什么需要:点击商品加号要把商品加入 cartList,并统计数量、总价和高亮状态。
怎么想:先判断购物车中是否已有该商品:用 find 按 id 查找;找到则 count + 1,找不到则 push 新项。数量用 cartList.length,总价用 reduce 累加“单价 × 数量”。
怎么做:新增 addCart reducer;商品项组件点击加号时提交完整商品对象。统计组件用 useSelector 取 cartList,计算 totalPrice;cartList.length > 0 时显示高亮和结算文案。
易错点:find 找不到返回 undefined,判断要写清楚;总价初始值给 0;Redux 没有 Vue 那种计算属性,但组件重新渲染时会重新计算。
js
addCart(state, action) {
const item = state.cartList.find(i => i.id === action.payload.id)
if (item) item.count += 1
else state.cartList.push(action.payload)
}调试:点同一商品加号,count 从 1 变 2,不新增项;点另一商品才新增一项。
购物车增减、清除与边界
为什么需要:购物车列表要能增减数量、清空列表。
怎么想:增减都要先按 id 找到当前项;减少到 0 时不能再减成负数。清空直接把 cartList 置空数组。
怎么做:新增 incrementCount、decrementCount、clearCart;减到 0 时直接 return。组件中加号、减号分别 dispatch,清空按钮提交 clearCart。
易错点:减数量忘记边界判断;传参要传对象且包含 id;清空后统计区域应恢复灰色。
js
decrementCount(state, action) {
const item = state.cartList.find(i => i.id === action.payload.id)
if (!item || item.count <= 0) return
item.count -= 1
}控制购物车显示隐藏
为什么需要:点击统计区域弹出购物车和蒙层,点击蒙层收起。
怎么想:这是只在当前组件使用的 UI 临时状态,不必放 Redux,用 useState 即可。
怎么做:声明 visible;统计区域点击时,只有 cartList.length > 0 才 setVisible(true);蒙层点击设 false;蒙层和购物车列表用 visible 控制类名。
易错点:没有购物车数据时不应弹出空列表;不要把所有状态都塞进 store,是否共享是判断标准。
js
const [visible, setVisible] = useState(false)
const openCart = () => {
if (cartList.length > 0) setVisible(true)
}至此,分类、商品列表、加购、统计、购物车操作和显隐交互形成完整闭环。
本节目标与效果
本节做一个最小可运行的计数器:页面上有“-”“数量”“+”三个元素,点击按钮后数量变化。需求限定为:不和任何框架绑定、不使用构建工具,只通过 CDN 引入 Redux,用纯 Redux 管理状态。

转写稿主要讲的是美团案例中的 RTK、异步 action、购物车等内容,与本节“纯 Redux 计数器”不是同一个需求,因此不能把 RTK、axios、thunk 等代码硬套进来。这里只借用转写稿反复强调的调试顺序:先看 action 是否触发,再看 store 状态是否更新,最后看视图是否重新渲染。转写稿中出现的“dipatch”应写作 dispatch,“reucer”应写作 reducer,本节代码里也要避免这类拼写错误。
步骤一:定义 reducer 函数
为什么需要:Redux 不允许视图随便改状态,必须有一个“根据 action 计算新状态”的纯函数,这就是 reducer。
怎么想:计数器状态可以设计成 { count: 0 };收到 INCREMENT 返回 count + 1,收到 DECREMENT 返回 count - 1,不认识的动作返回原状态。
怎么做:写 counterReducer(state = { count: 0 }, action),用 switch(action.type) 分支返回新对象。
易错点:不要写 state.count++ 直接改原对象;必须返回新对象。default 必须 return state,否则首次初始化或未知 action 会返回 undefined。
步骤二:使用 createStore 创建 store
为什么需要:reducer 只是规则,store 才是真正保存状态、接收 action、通知订阅者的实例。
怎么想:把 reducer 交给 Redux.createStore,Redux 内部会先调用一次 reducer 得到初始状态。
怎么做:const store = Redux.createStore(counterReducer)。
易错点:Redux 来自 CDN 脚本,必须保证 redux.min.js 在业务 <script> 之前加载;否则会报 Redux is not defined。纯 Redux 没有 configureStore,不要混入 RTK 写法。
步骤三:订阅数据变化并更新视图
为什么需要:Redux 状态变化不会自动改 DOM,需要 subscribe 在每次 dispatch 后得到通知。
怎么想:订阅回调里用 getState() 取最新状态,再写入 #count 的 innerText。
怎么做:store.subscribe(() => { console.log(store.getState()); document.getElementById('count').innerText = store.getState().count })。
易错点:只 dispatch 不 subscribe,控制台可能看到状态变了但页面不动;subscribe 回调不会自动传新状态参数,仍要调用 getState()。
步骤四:用 dispatch 提交 action
为什么需要:视图不能直接改 store,只能“告诉” Redux 想做什么。
怎么想:点击“+”提交 { type: 'INCREMENT' },点击“-”提交 { type: 'DECREMENT' }。
怎么做:给两个按钮绑定 click,在回调里 store.dispatch({ type: 'INCREMENT' }) 或 DECREMENT。
易错点:action 必须是对象且带 type;type 字符串要和 reducer 的 case 完全一致。写成 increment 小写或漏写 type,状态不会按预期变化。
步骤五:用 getState 获取最新状态
为什么需要:更新视图、调试、后续计算都依赖当前最新状态。
怎么想:getState() 返回当前完整状态对象,本节是 { count: n },所以取 .count。
怎么做:在订阅回调中读取并渲染;调试时在控制台点按钮后观察 store.getState() 输出。
易错点:不要缓存旧状态对象后假设它会自动变;每次变化后重新 getState()。若页面数字不变,依次检查:按钮事件是否触发、dispatch 的 type 是否匹配、reducer 是否返回新对象、subscribe 是否更新 DOM。
完整代码
html
<button id="decrement">-</button>
<span id="count">0</span>
<button id="increment">+</button>
<script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>
<script>
// 定义reducer函数
// 内部主要的工作是根据不同的action 返回不同的state
function counterReducer (state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 }
case 'DECREMENT':
return { count: state.count - 1 }
default:
return state
}
}
// 使用reducer函数生成store实例
const store = Redux.createStore(counterReducer)
// 订阅数据变化
store.subscribe(() => {
console.log(store.getState())
document.getElementById('count').innerText = store.getState().count
})
// 增
const inBtn = document.getElementById('increment')
inBtn.addEventListener('click', () => {
store.dispatch({
type: 'INCREMENT'
})
})
// 减
const dBtn = document.getElementById('decrement')
dBtn.addEventListener('click', () => {
store.dispatch({
type: 'DECREMENT'
})
})
</script>课件图示








代码示例
示例 1:纯 Redux 实现计数器(不依赖任何框架与构建工具)
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Redux 快速体验 - 计数器</title>
<style>
body { font-family: system-ui, sans-serif; display: flex; gap: 12px; align-items: center; padding: 40px; }
button { width: 40px; height: 40px; font-size: 20px; cursor: pointer; }
#count { min-width: 40px; text-align: center; font-size: 24px; }
</style>
</head>
<body>
<button id="decrement">-</button>
<span id="count">0</span>
<button id="increment">+</button>
<script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>
<script>
// 1. 定义 reducer 函数
// 内部主要的工作是根据不同的 action 返回不同的 state
function counterReducer (state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 }
case 'DECREMENT':
return { count: state.count - 1 }
default:
return state
}
}
// 2. 使用 reducer 函数生成 store 实例
const store = Redux.createStore(counterReducer)
// 3. 订阅数据变化(数据一旦变化,可以得到通知)
store.subscribe(() => {
console.log(store.getState())
document.getElementById('count').innerText = store.getState().count
})
// 4. 增:dispatch 提交 action 对象,告诉 reducer 你想怎么改数据
const inBtn = document.getElementById('increment')
inBtn.addEventListener('click', () => {
store.dispatch({ type: 'INCREMENT' })
})
// 5. 减
const dBtn = document.getElementById('decrement')
dBtn.addEventListener('click', () => {
store.dispatch({ type: 'DECREMENT' })
})
</script>
</body>
</html>关键行与易错点
createStore(counterReducer):创建 store 实例时,Redux 会立刻用undefined调用一次 reducer,所以state = { count: 0 }的默认值是初始化状态的唯一来源。switch (action.type)的default: return state:必须原样返回旧的 state。漏写或返回undefined,store 会报错/状态被清空。- reducer 里只能返回新对象(
return { count: state.count + 1 }),不能state.count++直接改原对象,否则订阅者拿到的还是同一引用,视图不更新。 subscribe回调不会立即执行,只在dispatch之后触发;页面首次显示的数字来自 HTML 里写死的0。dispatch的参数必须是带type属性的普通对象,type写错(大小写不一致)会走到default分支,表现为“点了没反应”。
示例 2:按“五步法”拆解 + 首屏手动渲染
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Redux 五步法拆解</title>
<style>
body { font-family: system-ui, sans-serif; padding: 40px; }
button { width: 40px; height: 40px; font-size: 20px; cursor: pointer; }
#count { display: inline-block; min-width: 40px; text-align: center; font-size: 24px; }
#log { color: #888; }
</style>
</head>
<body>
<button id="decrement">-</button>
<span id="count">0</span>
<button id="increment">+</button>
<p id="log"></p>
<script src="https://unpkg.com/redux@latest/dist/redux.min.js"></script>
<script>
// 步骤 1:定义 reducer(根据当前想要做的修改返回一个新的状态)
function counterReducer (state = { count: 0 }, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 }
case 'DECREMENT':
return { count: state.count - 1 }
default:
return state
}
}
// 步骤 2:createStore 传入 reducer,生成 store 实例
const store = Redux.createStore(counterReducer)
const countEl = document.getElementById('count')
const logEl = document.getElementById('log')
// 步骤 5 的前置动作:先拿一次初始状态,把首屏渲染出来
countEl.innerText = store.getState().count
logEl.innerText = '当前 state:' + JSON.stringify(store.getState())
// 步骤 3:subscribe 订阅数据的变化
store.subscribe(() => {
const state = store.getState() // 取最新状态
countEl.innerText = state.count // 更新到视图
logEl.innerText = '当前 state:' + JSON.stringify(state)
})
// 步骤 4:dispatch 提交 action 对象,触发数据变化
document.getElementById('increment').addEventListener('click', () => {
store.dispatch({ type: 'INCREMENT' })
})
document.getElementById('decrement').addEventListener('click', () => {
store.dispatch({ type: 'DECREMENT' })
})
</script>
</body>
</html>关键行与易错点
store.getState()是唯一读取状态的入口,不要在 reducer 外部直接改 store 里的对象;拿到的是引用,改它等于绕过 reducer。subscribe只在dispatch后触发,所以首屏数据要单独getState()渲染一次,这是新手最常见的“页面一开始是 0、但状态其实已经不是 0”的 bug 来源。- 每次
dispatch都会通知所有订阅者,示例里只订阅了一次;如果给每个按钮都写一遍subscribe,会出现重复渲染。 JSON.stringify(state)便于在页面上观察状态流转,注意 state 里若出现循环引用会抛错,实际调试建议直接用 Redux DevTools。- 三个方法的分工要记牢:
dispatch提交 action → reducer 计算新 state →subscribe收到通知 →getState取值更新视图,这条单向数据流是后面 RTK 写异步 action(createAsyncThunk+dispatch)的基础。
常见错误
1. 点击加号后购物车出现重复商品行
- 现象:同一商品点两次加号,
cartList里出现两条记录,各自count: 1 - 原因:reducer 里直接
push,没有先判断当前商品是否已在列表中 - 修法:先
state.cartList.find(item => item.id === action.payload.id),命中则count + 1,未命中才push;find返回undefined即为判断依据
2. 减号一路减成负数
- 现象:数量可以减到
-1、-2,界面显示异常 - 原因:只写了
item.count -= 1,没有做下界保护 - 修法:在 reducer 中前置判断
if (item.count <= 0) return,把"到 0 就不动"这条规则放在数据层而不是视图层(视图层禁用按钮是第二道防线,不是唯一防线)
3. dispatch 在异步 action 里报 dispatch is not a function
- 现象:
fetchFoodList内部调用dispatch直接抛错,或 action 根本没发出去 - 原因:
return function () {...}时漏写了形参dispatch,或者把 thunk 写成了普通对象返回 - 修法:严格写成
return (dispatch) => { ... };同时确认configureStore默认已集成 thunk,不需要手动加中间件
4. useSelector 取到 undefined,页面白屏或渲染报错
- 现象:
state.food.foodList是undefined,.map直接崩 - 原因:
configureStore里注册的 key(如food)与useSelector中写的模块名不一致,或 slice 的name与注册 key 混用 - 修法:以
configureStore({ reducer: { food: foodReducer } })中的 key 为唯一路径来源;给初始值兜底state.food.foodList ?? []
5. 每次刷新页面都发两次接口请求
- 现象:Network 面板里
fetchFoodList出现两次 - 原因:
useEffect没传依赖数组,任何 state 变化都重新执行 - 修法:
useEffect(() => { dispatch(fetchFoodList()) }, [dispatch]),把dispatch作为依赖而不是留空
6. 把购物车弹层的显示隐藏塞进了 store
- 现象:一个只服务于单个组件的开关状态,却在 slice 里新增了
visible字段和配套 reducer - 原因:没区分"跨组件共享状态"和"UI 临时状态"
- 修法:只在本组件内用
useState;判断标准是——这个状态是否被两个及以上组件读写
练习
练习 1|边界与上限 在现有 incrementCount / decrementCount 基础上,给数量加上"上限 99、下限 0"的双向保护。要求:保护逻辑写在 reducer 里,同时在 Count 组件上同步做按钮禁用,并说明为什么两处都要做。
练习 2|从 index 迁移到 id 当前菜单激活用的是 activeIndex(下标)。请改造成用分类 id 记录激活项,并回答:切换后,商品列表的条件渲染需要改动哪些地方?如果后端返回的分类顺序发生变化,两种方案分别会怎样?
练习 3|新增删除单项 在购物车列表中为每一项加一个删除按钮,点击后从 cartList 中移除该项。要求:新增独立 reducer(不要在 clearCart 里塞参数),组件中通过 dispatch 提交,并在 Redux DevTools 中确认 action 名称与 payload 结构正确。
练习 4|抽离总价计算 把统计区的 reduce 总价计算从组件中抽出来,封装成独立的 selector 函数(如 selectTotalPrice)。要求:说明抽离后组件内还剩哪些代码,以及当 cartList 变化时该计算是否仍会自动更新、为什么。
练习 5|排障演练 故意在 foodSlice 的 setFoodList 中把 state.foodList = action.payload 改成 return { ...state, list: action.payload }。运行后用 Redux DevTools 与 useSelector 分别观察,写出你看到的现象、定位过程和最终结论。
原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 美团案例-分类和商品列表渲染 | 17:22 |
| 2 | 美团案例-点击分类激活交互实现 | 07:29 |
| 3 | 美团案例-商品列表切换显示 | 02:47 |
| 4 | 美团案例-添加购物车实现 | 08:20 |
| 5 | 美团案例-统计区域功能实现 | 07:44 |
| 6 | 美团案例-购物车列表功能实现 | 12:10 |
| 7 | 美团案例-控制购物车显示和隐藏 | 07:09 |
小结
- 这套案例的所有 reducer 都在回答同一个问题:"找到目标项 → 决定改还是不改 → 改多少",
find+ 条件判断是最常复用的骨架。 - 业务规则(不能减到负数、不能重复加购)必须落在 reducer 层,视图层的禁用/隐藏只是体验补充,不是数据正确性的保障。
- 判断一个状态该不该进 store,只看一件事:是否跨组件共享。购物车弹层开关是典型的
useState场景。 configureStore中注册的 key 就是useSelector的访问路径,这两处是强耦合的,改名必须同步改。- Redux 没有"计算属性",但也不需要——组件重渲染时会用最新 state 重新计算,所以把派生值写成纯函数(selector)反而更清晰、更易测。
- 异步数据流固定为三段式:组件
useEffect触发 → thunk 内dispatch同步 action → reducer 落地 state,任何一段断了都表现为"数据没到"。 - 调试顺序建议固定为:先看 Network 有没有请求 → 再看 DevTools 有没有 action → 最后看
useSelector取到的值,能避免大部分盲目改代码。 - 这个案例的完整链路(列表渲染 → 类名切换 → 条件渲染 → 增删改 → 显示隐藏)本质上都是同一个"数据驱动视图"模式的重复应用,掌握一次即可迁移到任意业务模块。