Redux Toolkit 现代状态管理深度解析:从原理到购物车实战
导读:本文系统讲解 Redux 的设计哲学与核心三原则,深入剖析 Redux Toolkit(RTK)的底层机制——包括 Immer 的 Proxy 拦截原理、createSlice 的代码生成逻辑、createAsyncThunk 的三态状态机,以及 createSelector 的记忆化算法。全文附完整可运行的购物车交互演示(纯 HTML/JS),并提供 TypeScript 集成最佳实践、高频面试考点精讲。适合已掌握 React 基础、希望在生产级项目中正确使用状态管理的前端开发者。
目录
- 零、导读与学习价值
- 一、多组件状态共享的困境与 Redux 的解题思路
- 二、Redux 三大核心原则
- 三、Redux Toolkit 解决了什么问题
- 四、createSlice 底层原理深度解析
- 五、createAsyncThunk 异步三态状态机
- 六、createSelector 记忆化选择器
- 七、configureStore 与 TypeScript 类型集成
- 八、完整购物车实战
- 九、本地持久化与 Redux Persist
- 十、交互式购物车 HTML 演示
- 总结
- 参考资料
零、导读与学习价值
0.1 核心名词速查
| 术语 | 一句话解释 |
|---|---|
| Store | Redux 的全局状态容器,整个应用只有一个 |
| State | Store 中存储的状态树对象 |
| Action | 描述"发生了什么"的普通 JS 对象,必须有 type 字段 |
| Reducer | 纯函数,接收旧 state 和 action,返回新 state |
| Dispatch | 派发 action 的函数,是触发状态变更的唯一入口 |
| Selector | 从 state 中提取派生数据的函数 |
| Slice | RTK 中将 action + reducer 合并为一个模块的概念 |
| Immer | 通过 ES6 Proxy 实现"写时复制"的不可变数据库 |
| Thunk | 一种 Redux 中间件,允许 dispatch 函数而非对象(用于异步) |
| createSlice | RTK API,自动生成 action type、action creator 和 reducer |
| createAsyncThunk | RTK API,封装异步操作并自动生成 pending/fulfilled/rejected |
| createSelector | Reselect 的记忆化 selector 工厂(RTK 已集成) |
| RootState | 从 store.getState 推断出的完整状态树类型 |
| AppDispatch | 从 store.dispatch 推断出的 dispatch 函数类型 |
| Redux Persist | 将 Redux state 持久化到 localStorage/sessionStorage 的库 |
0.2 为什么要掌握 Redux Toolkit
Redux 是 React 生态中历史最悠久、使用最广泛的状态管理方案,至今仍是大型团队协作项目的首选。掌握 RTK 有以下工程价值:
- 职场硬通货:绝大多数中大型 React 项目(电商、ERP、管理后台)均使用 Redux 或 RTK,面试必考
- 框架基础:理解 Redux 的 Flux 架构思想,对理解 Vuex、Pinia、Zustand 等其他状态管理方案有直接帮助
- 调试能力:Redux DevTools 的时间旅行调试是其他方案难以匹敌的工程优势
- TypeScript 友好:RTK 原生支持 TypeScript,与类型系统深度集成,是类型安全状态管理的最佳实践
一、多组件状态共享的困境与 Redux 的解题思路
1.1 名词解释
- Props Drilling(属性穿透):父组件将数据通过 props 逐层传递给深层子组件,中间层组件不使用该数据却不得不"转发"的现象。
- 状态提升(State Lifting):将多个兄弟组件共享的状态移到它们最近的公共祖先组件中管理。
- 单向数据流:数据只能从父组件流向子组件(通过 props),子组件通过回调函数通知父组件变更。
1.2 Props Drilling 与状态提升的边界
React 的设计原则是单向数据流:状态由父组件持有,通过 Props 向下传递,子组件通过回调函数触发父组件更新。这个模型在浅层组件树中运行良好,但随着应用规模增大,出现三类典型困境:
困境一:Props Drilling
App(持有 user 状态)
└── Layout
└── Sidebar
└── UserMenu
└── Avatar(真正需要 user 的组件)
中间三层(Layout、Sidebar、UserMenu)需要透传 user prop,即便它们自身完全不使用这个数据。每新增一层组件,就需要修改所有中间层的 props 定义,维护成本极高。
困境二:兄弟组件状态同步
购物车图标(在 Header 中)需要显示商品数量,商品列表页(在 Main 中)的"加入购物车"按钮修改购物车状态。二者分属不同分支,若将状态提升到公共祖先 App,则任何购物车更新都会导致整个 App 重渲染。
困境三:状态变更难以追踪
多个组件都能修改同一份状态时,出现 bug 后很难确定是哪个组件的哪次操作导致了状态异常,调试成本极高。
1.3 Redux 的核心思路
Redux 将应用状态抽取到组件树之外,存储在独立的全局 store 中。任何组件都可以直接"订阅"所需状态,也可以直接"派发"修改状态的动作,彻底绕开 Props 层级。
graph TD
A[组件A - Header购物车图标] -->|useSelector| S[Redux Store<br/>全局状态]
B[组件B - 商品列表页] -->|dispatch action| S
C[组件C - 侧边栏] -->|useSelector| S
S -->|state变化通知| A
S -->|state变化通知| C
【代码注释】此图展示 Redux 的核心架构:Store 作为单一数据源居中,所有组件通过 useSelector 订阅状态、通过 dispatch 修改状态,组件之间不再需要直接通信。这消除了 Props Drilling,并使状态变更路径完全可追踪。市面应用:淘宝、京东等电商平台的购物车、用户信息、商品筛选条件等全局状态均采用类似架构。
【实战要点】
- 经典应用场景:购物车(多页面共享)、用户登录信息(全局鉴权)、主题色/语言设置(全局配置)、通知消息列表(跨组件触发)。
- 常见坑:不加甄别地把所有状态放入 Redux。表单输入值、弹窗开关、Tab 选中状态等 UI 局部状态放
useState即可,放入 Redux 只会增加复杂度、降低组件可复用性。 - 最佳实践:区分"UI 状态"(组件私有)和"业务状态"(跨组件共享);只将真正需要跨组件共享或需要持久化的业务状态放入 Redux。
【本章小结】
| 问题场景 | 推荐方案 |
|---|---|
| 组件内部 UI 状态 | useState |
| 父子组件少量共享 | 状态提升 + props |
| 跨模块共享配置 | useContext |
| 全局业务状态(购物车、用户) | Redux / RTK |
| 服务器数据缓存 | React Query / RTK Query |
【面试考点】
Q:React 中什么情况下应该引入 Redux?
A:当应用出现以下特征时才考虑引入 Redux:(1)多个非父子关系的组件需要共享同一份数据;(2)状态变更逻辑复杂,需要明确的变更记录和调试能力;(3)需要状态持久化或时间旅行调试。对于简单的跨组件状态,useContext + useReducer 已足够,引入 Redux 会增加不必要的复杂度。判断标准是「这个状态的变更是否需要被多个独立模块感知」。
二、Redux 三大核心原则
2.1 名词解释
- 单一数据源(Single Source of Truth):整个应用的状态存储在唯一一个 store 的对象树中。
- State 只读(State is Read-Only):不能直接修改 state,唯一的修改方式是 dispatch 一个 action。
- 纯函数 Reducer(Pure Function Reducer):Reducer 必须是纯函数——给定相同的输入,始终返回相同输出,无副作用。
2.2 原则详解与底层意义
原则一:单一数据源
整个应用的 state 存在于一个对象树中,且这个对象树只存在于唯一的 store 里。
这个约束带来三个工程收益:
- 全局可见性:Redux DevTools 可以随时查看完整的应用状态快照
- 调试便利:服务端渲染时可以直接将服务端的 state 序列化后注入客户端
- 状态一致性:不存在"哪个组件的状态才是真相"的歧义
原则二:State 只读
不能直接修改 store 中的状态,唯一的修改方式是 dispatch 一个描述"发生了什么"的 action 对象:
// 错误:直接修改 state(Redux 中绝对禁止)
store.getState().cart.items.push(newItem) // ❌
// 正确:dispatch action
store.dispatch({ type: 'cart/addToCart', payload: newItem }) // ✅
【代码注释】action 是一个普通 JS 对象,type 字段描述操作类型(约定用 域/操作 格式),payload 携带操作所需数据。这个约束让每一次状态变更都对应一条明确的"操作记录",是 Redux DevTools 时间旅行调试的基础。市面应用:大型电商平台使用 action 日志来还原用户操作路径,辅助客诉排查。
原则三:纯函数 Reducer
Reducer 是一个纯函数:
// reducer 的本质:(previousState, action) => newState
function cartReducer(state = initialState, action) {
switch (action.type) {
case 'cart/addToCart':
// 必须返回新对象,不能修改 state!
return { ...state, items: [...state.items, action.payload] }
default:
return state
}
}
【代码注释】纯函数的两个要求:(1)相同输入必须返回相同输出;(2)不产生副作用(不修改参数、不发起网络请求、不写全局变量)。正因为 reducer 是纯函数,Redux DevTools 才能"重放"任意历史 action 来复现状态——这是时间旅行调试的理论基础。
纯函数约束的代价是手写不可变更新极其繁琐:
// 手写深层嵌套的不可变更新
return {
...state,
cart: {
...state.cart,
items: state.cart.items.map(item =>
item.id === id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
}
这正是 RTK 中 Immer 的价值所在(第四章详述)。
2.3 单向数据流可视化
sequenceDiagram
participant U as 用户界面(View)
participant D as dispatch()
participant R as Reducer
participant S as Store(State)
U->>D: 用户点击"加入购物车"
D->>R: dispatch({ type: 'cart/addToCart', payload: product })
R->>R: 纯函数计算新 state(Immer 处理不可变性)
R->>S: 返回新 state
S->>U: 通知所有 useSelector 订阅者
U->>U: 数据变化的组件重新渲染
【代码注释】这是 Redux 的经典单向数据流。关键点:数据流是单向的(View → Action → Reducer → Store → View),不存在反向数据流;Reducer 是同步纯函数,异步操作(网络请求)由 Thunk 中间件在 dispatch 和 Reducer 之间处理。市面应用:这个模式直接影响了 Vuex(Vue 的官方状态管理)和 Pinia 的设计,理解 Redux 数据流就理解了主流状态管理方案的共同范式。
【实战要点】
- 经典应用场景:Redux DevTools 的"时间旅行"功能依赖单向数据流——因为每个 action 都是纯粹的意图描述,可以在任意时间点重放,复现历史状态。
- 常见坑:在 Reducer 中执行异步操作(如
fetch)——Reducer 必须是同步纯函数,异步操作必须在 Thunk 或 Saga 中处理,否则状态更新时序不可预测。 - 最佳实践:action 的
type字段采用域名/操作名命名规范(如cart/addToCart),便于 DevTools 中一眼识别操作归属。
【本章小结】
| 原则 | 约束 | 工程价值 |
|---|---|---|
| 单一数据源 | 全局唯一 store | 状态全局可见、服务端渲染方便 |
| State 只读 | 只能 dispatch action | 状态变更可追踪、可回放 |
| 纯函数 Reducer | 无副作用、相同输入相同输出 | 时间旅行调试、可测试性 |
记忆口诀:单源只读纯函数——单一数据源,状态只读,纯函数处理。
【面试考点】 Q:为什么 Redux 要求 Reducer 是纯函数? A:纯函数有两个关键特性:(1)相同输入永远返回相同输出;(2)无副作用。这两点共同保证了 Redux 的可预测性。因为纯函数不依赖外部状态,只依赖参数,所以给定任意历史 action 序列,可以从初始状态重放所有 action 精确还原到任意时间点的状态,这是时间旅行调试的理论基础。如果 Reducer 有副作用(比如直接修改传入的 state),重放就会产生不同结果,调试价值消失。
Q:Redux 的单向数据流与 React 的单向数据流有什么关系? A:React 组件层面的单向数据流(props 从父到子)和 Redux 的单向数据流(View → Action → Reducer → Store → View)是两个层面的设计,但思想一致:数据流方向固定,变更路径可追踪。Redux 把 React 的「组件级单向流」提升到了「应用级单向流」,使跨组件状态共享也具备可预测性。
三、Redux Toolkit 解决了什么问题
3.1 名词解释
- 样板代码(Boilerplate):为实现一个功能必须重复编写的固定格式代码,本身不包含业务逻辑。
- RTK(Redux Toolkit):Redux 官方推荐的最佳实践工具集,大幅减少样板代码。
- Immer:基于 ES6 Proxy 的不可变数据库,让开发者以"可变"的写法生成不可变数据。
- createSlice:RTK 的核心 API,将 action type、action creator、reducer 合并为一个"切片"。
3.2 传统 Redux 的痛点
以添加购物车商品为例,传统 Redux 需要编写以下全部代码:
// 1. 定义 action type 常量(防止字符串拼写错误)
const CART_ADD_ITEM = 'cart/addItem'
const CART_REMOVE_ITEM = 'cart/removeItem'
const CART_SET_QUANTITY = 'cart/setQuantity'
// 2. 编写 action creator 函数
function addItem(product) {
return { type: CART_ADD_ITEM, payload: product }
}
function removeItem(id) {
return { type: CART_REMOVE_ITEM, payload: id }
}
function setQuantity(id, quantity) {
return { type: CART_SET_QUANTITY, payload: { id, quantity } }
}
// 3. 编写 reducer(手写不可变更新)
function cartReducer(state = { items: [] }, action) {
switch (action.type) {
case CART_ADD_ITEM: {
const existing = state.items.find(i => i.id === action.payload.id)
if (existing) {
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: item.quantity + 1 }
: item
)
}
}
return { ...state, items: [...state.items, { ...action.payload, quantity: 1 }] }
}
case CART_REMOVE_ITEM:
return { ...state, items: state.items.filter(i => i.id !== action.payload) }
case CART_SET_QUANTITY:
return {
...state,
items: state.items.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
)
}
default:
return state
}
}
【代码注释】这段传统 Redux 代码有三个明显问题:(1)action type 字符串需要手动管理常量,一旦拼写错误,调试极困难;(2)手写展开运算符(...state、...state.items.map)的不可变更新极易出错,深层嵌套对象更是噩梦;(3)每个功能都需要重复这三步(常量→creator→reducer),纯机械劳动。市面上,这种写法在 2019 年前的 React 项目中极为普遍,维护成本是现代项目的 3-5 倍。
3.3 RTK 的解决方案对比
// RTK 版本:等价于上面全部传统代码
import { createSlice, PayloadAction } from '@reduxjs/toolkit'
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] as ICartItem[] },
reducers: {
addItem(state, action: PayloadAction<IProduct>) {
const existing = state.items.find(i => i.id === action.payload.id)
if (existing) {
existing.quantity += 1 // 直接"修改"!Immer 处理不可变性
} else {
state.items.push({ ...action.payload, quantity: 1 }) // 直接 push!
}
},
removeItem(state, action: PayloadAction<number>) {
state.items = state.items.filter(i => i.id !== action.payload)
},
setQuantity(state, action: PayloadAction<{ id: number; quantity: number }>) {
const item = state.items.find(i => i.id === action.payload.id)
if (item) item.quantity = action.payload.quantity
}
}
})
// 自动生成的 action creators:cartSlice.actions.addItem / removeItem / setQuantity
// 自动生成的 action types:'cart/addItem' / 'cart/removeItem' / 'cart/setQuantity'
export const { addItem, removeItem, setQuantity } = cartSlice.actions
export default cartSlice.reducer
【代码注释】RTK 版本将传统 Redux 约 60 行代码压缩到 25 行,且几乎没有样板代码。关键改进:(1)name + reducerKey 自动生成 action type,无需手写常量;(2)Immer 让 reducer 可以直接"修改" state,告别层层展开;(3)action creator 从 reducer key 自动推断,无需手动编写。市面应用:2020 年后新建的 React 项目几乎全部使用 RTK,Redux 官方文档也明确推荐 RTK 作为唯一正式写法。
graph LR
A[传统Redux] -->|大量样板代码| B[action type常量]
A --> C[action creator函数]
A --> D[switch/case reducer]
A --> E[手写不可变更新]
F[RTK] -->|createSlice一键生成| G[自动action type]
F --> H[自动action creator]
F --> I[简洁reducer写法]
F -->|Immer内置| J[直接mutate写法]
【代码注释】此图对比传统 Redux 和 RTK 的代码量分布。RTK 把左侧四个独立概念合并成一个 createSlice 调用,Immer 消除了手写不可变更新的负担。这不只是代码量减少,更是认知负担的大幅降低。
【实战要点】
- 经典应用场景:RTK 是 2020 年后 React 生态的标配,GitHub、Figma、Notion 等产品的 React 状态管理均已迁移到类 RTK 方案。
- 常见坑:仍然在 RTK 的 reducer 中手写展开运算符(
...state)——这是多余的,Immer 已经处理不可变性,直接state.xxx = yyy即可;但要注意,如果想替换整个 state 对象,必须return新对象而不是修改state。 - 最佳实践:升级到 RTK 后,先迁移 reducer 写法,再逐步替换 action type 常量,最后删除手写的 action creator。不要混用旧写法和 RTK,会造成维护混乱。
【本章小结】
| 维度 | 传统 Redux | Redux Toolkit |
|---|---|---|
| Action Type | 手写字符串常量 | name/key 自动生成 |
| Action Creator | 手写函数 | 自动生成 |
| Reducer | switch/case | 对象方法 |
| 不可变更新 | 手写展开运算符 | Immer 内置,直接 mutate |
| 异步处理 | 手动配置中间件 | createAsyncThunk 内置 |
| DevTools | 手动配置 | configureStore 自动集成 |
| 代码量 | 多(样板代码多) | 少(逻辑代码为主) |
【面试考点】
Q:Redux Toolkit 相比传统 Redux 解决了哪些问题?
A:三个核心问题:(1)样板代码——createSlice 自动生成 action type 和 action creator,消除手写常量和 switch/case;(2)不可变更新——内置 Immer,允许在 reducer 中直接 "mutate" state,底层自动生成新对象;(3)异步处理——createAsyncThunk 封装异步操作,自动生成 pending/fulfilled/rejected 三个 action,无需手动配置 redux-thunk。此外,configureStore 自动集成 DevTools 和常用中间件,大幅降低配置成本。
四、createSlice 底层原理深度解析
4.1 名词解释
- ActionCreatorMap:createSlice 内部维护的映射表,将 reducer key 映射到对应的 action creator 函数。
- Proxy(ES6):ES6 引入的元编程机制,可以拦截对象的读写操作,是 Immer 的实现基础。
- Draft(草稿对象):Immer 创建的 Proxy 包装对象,对 draft 的所有写操作都被拦截记录。
- 结构共享(Structural Sharing):生成新对象时,未修改的子树复用原对象的引用,而非深拷贝,是不可变数据的性能优化策略。
- Produce:Immer 的核心函数,接收 baseState 和 recipe 函数,返回新的不可变 state。
4.2 createSlice 的代码生成机制
createSlice 在运行时(不是编译时)执行以下逻辑:
// createSlice 的简化实现(概念示意)
function createSlice({ name, initialState, reducers }) {
const actionCreators = {}
const sliceReducers = {}
// 遍历 reducers 对象的每个 key
Object.keys(reducers).forEach(key => {
// 1. 生成 action type:格式为 "name/key"
const type = `${name}/${key}`
// 2. 生成 action creator 函数
actionCreators[key] = (payload) => ({ type, payload })
// 3. 注册 reducer(用 Immer 包装,允许直接 mutate)
sliceReducers[type] = (state, action) =>
produce(state, draft => {
reducers[key](draft, action) // 传入 draft,不是真实 state
})
})
// 4. 组合 reducer
function reducer(state = initialState, action) {
const caseReducer = sliceReducers[action.type]
return caseReducer ? caseReducer(state, action) : state
}
return { actions: actionCreators, reducer }
}
【代码注释】这段伪代码揭示了 createSlice 的核心:它是一个代码生成器,在 JS 运行时通过遍历 reducers 对象的键,动态创建 action creator 和 reducer 分支。关键点:传给 reducer 函数的 state 参数实际上是 Immer 的 draft 对象,不是真实 state;produce 函数负责将对 draft 的修改转化为新的不可变对象。市面应用:Pinia(Vue 3 状态管理)的 defineStore 采用了非常相似的自动化设计。
4.3 Immer 的 Proxy 拦截原理
Immer 是 RTK 最核心的底层依赖,理解它是理解"为什么可以直接 mutate state"的关键。
sequenceDiagram
participant R as Reducer函数
participant P as Immer.produce()
participant D as Draft(ES6 Proxy)
participant B as baseState(原始对象)
R->>P: produce(baseState, recipe)
P->>D: 用 Proxy 包装 baseState 创建 draft
P->>R: 将 draft 传给 recipe 函数
R->>D: recipe执行:state.items.push(...)
D->>D: Proxy 拦截写操作,记录变更路径
P->>P: recipe执行完毕,分析变更路径
P->>B: 未修改的节点:直接复用原引用(结构共享)
P->>P: 已修改的节点:创建新对象
P->>R: 返回新的不可变 state
【代码注释】Immer 的工作分四步:(1)用 ES6 Proxy 代理 baseState 创建 draft;(2)将 draft 传给 recipe 函数(即我们写的 reducer);(3)Proxy 拦截所有对 draft 的写操作,记录"哪条路径被修改了";(4)recipe 执行完毕后,Immer 基于记录的变更路径,只重建被修改的子树,未修改的子树直接复用原引用(结构共享)。市面应用:React 状态不可变性约束的痛点催生了 Immer,它现在也被 Zustand、MobX 等方案借鉴。
Proxy 拦截的关键代码(Immer 源码简化):
function createProxy(baseState) {
const modified = {} // 记录哪些路径被修改了
const proxies = {} // 缓存已创建的子 proxy
return new Proxy(baseState, {
get(target, prop) {
// 读取时:如果有修改值返回修改值,否则返回原值(可能递归创建子 proxy)
if (prop in modified) return modified[prop]
if (typeof target[prop] === 'object' && target[prop] !== null) {
// 递归代理嵌套对象,实现深层拦截
if (!proxies[prop]) proxies[prop] = createProxy(target[prop])
return proxies[prop]
}
return target[prop]
},
set(target, prop, value) {
// 写入时:记录到 modified,不修改原 target
modified[prop] = value
return true
}
})
}
【代码注释】这段代码揭示了 Immer Proxy 的关键机制:set 拦截器把写操作记录到 modified 对象而非真实修改 target,get 拦截器在读取嵌套对象时递归创建子 Proxy 实现深层拦截。原始 baseState 全程未被触碰。市面应用:Vue 3 的响应式系统(reactive())同样基于 ES6 Proxy,但目的是追踪读取(依赖收集)而非追踪写入,与 Immer 用途不同但机制相通。
4.4 结构共享算法
Immer 生成新对象时使用结构共享(Structural Sharing),而非深拷贝整个 state:
原始 state:
{
cart: { items: [A, B, C] }, ← 被修改(push D)
user: { name: '张三' }, ← 未修改
theme: 'light' ← 未修改
}
Immer 生成的新 state:
{
cart: { items: [A, B, C, D] }, ← 新对象(cart 和 items 数组都是新引用)
user: ─────────────────────────── 复用原引用 ✓(user 对象引用不变)
theme: ────────────────────────── 复用原引用 ✓(theme 字符串不变)
}
结构共享的性能意义:
state.user === newState.user→true(引用相等)useSelector(state => state.user)的组件不会重渲染- 只有
state.cart !== newState.cart的组件才重渲染
【实战要点】
- 经典应用场景:结构共享是 Redux + React 性能优化的理论基础——
useSelector默认用引用相等(===)判断是否需要重渲染,结构共享保证了未修改的数据引用不变,避免无谓重渲染。 - 常见坑:在 Immer reducer 中同时
mutatestate 又return新值——Immer 规定只能二选一。如果return了新值,Immer 会用return的值作为新 state,对 draft 的所有 mutate 操作被忽略。 - 最佳实践:替换整个数组时(如 filter 后),用
return语句:return state.filter(...);修改数组中的某个元素时,直接 mutate:state.items[0].quantity += 1。
【本章小结】
| 机制 | 技术实现 | 作用 |
|---|---|---|
| Action 自动生成 | 遍历 reducers key | 消除手写 action type 常量 |
| Reducer 包装 | Immer.produce | 允许直接 mutate,自动生成新对象 |
| Proxy 拦截 | ES6 Proxy set/get | 捕获所有写操作,不修改原对象 |
| 结构共享 | 基于变更路径重建 | 未修改的子树复用引用,优化性能 |
记忆口诀:生成类型,Proxy 拦截,共享结构——createSlice 自动生成类型,Immer 用 Proxy 拦截写操作,结构共享优化性能。
【面试考点】
Q:Immer 是如何实现"直接 mutate state"却不破坏不可变性的?
A:Immer 使用 ES6 Proxy 创建 baseState 的代理对象 draft。Proxy 的 set 拦截器将所有写操作记录到内部的变更日志,而不实际修改 baseState;get 拦截器对嵌套对象递归创建子 Proxy,实现深层拦截。当 recipe 函数(reducer)执行完毕,Immer 根据变更日志重建被修改的子树,未修改的子树直接复用原引用(结构共享)。整个过程 baseState 始终不变,最终返回一个新的不可变对象。
Q:createSlice 的 name 参数有什么作用?
A:name 是 action type 的命名空间前缀。createSlice 会将 name 和 reducers 对象的每个 key 拼接为 name/key 格式的 action type(如 cart/addItem)。这个设计避免了不同 slice 之间的 action type 命名冲突,同时在 Redux DevTools 中可以一目了然地看到 action 的归属模块。
五、createAsyncThunk 异步三态状态机
5.1 名词解释
- Thunk:一种特殊的函数,它延迟计算,是 Redux 异步中间件的核心概念。在 Redux 中,thunk 是一个返回函数的 action creator,被 dispatch 时由中间件拦截执行。
- createAsyncThunk:RTK 提供的异步 thunk 工厂函数,自动生成 pending/fulfilled/rejected 三个 action。
- pending:异步操作发起但尚未完成的状态(对应 Promise 的 pending 状态)。
- fulfilled:异步操作成功完成的状态(对应 Promise 的 resolved 状态)。
- rejected:异步操作失败的状态(对应 Promise 的 rejected 状态)。
- extraReducers:createSlice 中专门处理外部 action(如 createAsyncThunk 生成的 action)的配置项。
- rejectWithValue:createAsyncThunk 回调中提供的工具函数,将自定义错误信息携带在 rejected action 的 payload 中。
5.2 三态生命周期详解
stateDiagram-v2
[*] --> idle: 初始状态
idle --> pending: dispatch(fetchProducts())
pending --> fulfilled: Promise resolved
pending --> rejected: Promise rejected / rejectWithValue
fulfilled --> pending: 再次 dispatch
rejected --> pending: 重试
fulfilled --> idle: clearState
【代码注释】此状态机图展示了 createAsyncThunk 的完整生命周期。关键点:pending 是请求发出但未完成的中间态,此时应在 UI 显示 loading 状态;fulfilled 携带成功数据(action.payload);rejected 携带错误信息(action.payload 通过 rejectWithValue 设置,或 action.error 为未捕获的错误对象)。市面应用:所有依赖网络请求的列表页(商品列表、用户列表、订单列表)都需要管理这三种状态。
createAsyncThunk 的内部实现逻辑(简化):
function createAsyncThunk(typePrefix, payloadCreator) {
// 生成三个 action type
const pending = `${typePrefix}/pending`
const fulfilled = `${typePrefix}/fulfilled`
const rejected = `${typePrefix}/rejected`
// 返回一个 thunk action creator
function actionCreator(arg) {
// 这个函数会被 redux-thunk 中间件拦截并执行
return async function(dispatch, getState) {
dispatch({ type: pending })
try {
const result = await payloadCreator(arg, { dispatch, getState, rejectWithValue })
dispatch({ type: fulfilled, payload: result })
} catch (error) {
dispatch({ type: rejected, error: error.message })
}
}
}
actionCreator.pending = pending
actionCreator.fulfilled = fulfilled
actionCreator.rejected = rejected
return actionCreator
}
【代码注释】createAsyncThunk 返回的不是普通 action 对象,而是一个函数(thunk)。当被 dispatch 时,redux-thunk 中间件检测到这是一个函数,就执行它并传入 dispatch 和 getState。在异步操作前自动 dispatch pending,成功后 dispatch fulfilled,失败后 dispatch rejected,这样就把异步操作的状态管理完全标准化了。
5.3 完整异步 Slice 示例
// store/slices/productSlice.ts
import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'
export interface IProduct {
id: number
name: string
price: number
image: string
stock: number
}
interface ProductState {
items: IProduct[]
// 四态:初始/加载中/成功/失败
status: 'idle' | 'loading' | 'succeeded' | 'failed'
error: string | null
}
// 创建异步 thunk:第一个泛型是 fulfilled payload 类型,第二个是参数类型
export const fetchProducts = createAsyncThunk<IProduct[], void>(
'products/fetchProducts',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/products')
if (!response.ok) throw new Error('服务器错误')
return (await response.json()) as IProduct[]
} catch (error) {
// rejectWithValue 将自定义错误信息放入 action.payload(而非 action.error)
return rejectWithValue((error as Error).message)
}
}
)
const productSlice = createSlice({
name: 'products',
initialState: {
items: [],
status: 'idle',
error: null
} as ProductState,
reducers: {
// 同步 action:清空商品列表
clearProducts(state) {
state.items = []
state.status = 'idle'
}
},
// extraReducers 处理 createAsyncThunk 生成的 action
extraReducers: builder => {
builder
.addCase(fetchProducts.pending, state => {
state.status = 'loading'
state.error = null
})
.addCase(fetchProducts.fulfilled, (state, action) => {
state.status = 'succeeded'
state.items = action.payload // 直接赋值,Immer 处理不可变
})
.addCase(fetchProducts.rejected, (state, action) => {
state.status = 'failed'
// rejectWithValue 的值在 action.payload 中
state.error = (action.payload as string) ?? action.error.message ?? '未知错误'
})
}
})
export const { clearProducts } = productSlice.actions
export default productSlice.reducer
【代码注释】extraReducers 使用 builder 模式(链式调用 addCase)处理来自外部的 action,而不是 createSlice 内部 reducers 生成的 action。关键点:fetchProducts.pending、fetchProducts.fulfilled、fetchProducts.rejected 是 createAsyncThunk 自动挂载到 action creator 上的子 action creator,用于在 addCase 中精确匹配对应状态。市面应用:电商商品列表页、管理后台数据表格的数据加载均使用此模式。
在组件中使用异步 thunk:
// components/ProductList.tsx(概念示例)
import { useEffect } from 'react'
import { useAppDispatch, useAppSelector } from '../store/hooks'
import { fetchProducts } from '../store/slices/productSlice'
function ProductList() {
const dispatch = useAppDispatch()
const { items, status, error } = useAppSelector(state => state.products)
useEffect(() => {
// status 为 idle 时才发起请求,避免重复请求
if (status === 'idle') {
dispatch(fetchProducts())
}
}, [status, dispatch])
if (status === 'loading') return <div>加载中...</div>
if (status === 'failed') return <div>加载失败:{error}</div>
return (
<div className="product-list">
{items.map(product => (
<div key={product.id}>{product.name} - ¥{product.price}</div>
))}
</div>
)
}
【代码注释】使用 status === 'idle' 作为触发条件而非每次 mount 都请求,防止路由切换时重复发起请求。这是 RTK 官方推荐的异步数据加载模式。error 字段来自 rejectWithValue 的返回值,可以直接展示给用户。市面应用:这个模式在后台管理系统(列表页+详情页+编辑页)中极为普遍。
【实战要点】
- 经典应用场景:所有需要网络请求的数据加载(商品列表、用户信息、订单详情),以及需要乐观更新的场景(先更新 UI,请求失败再回滚)。
- 常见坑:在
createAsyncThunk的回调中直接throw error而不是return rejectWithValue(msg)——两者的区别在于:直接 throw 时错误信息在action.error.message(序列化受限),用rejectWithValue时自定义信息在action.payload(推荐,可携带任意序列化数据)。 - 最佳实践:用
'idle' | 'loading' | 'succeeded' | 'failed'四态而非简单的isLoading: boolean,这样可以区分"从未请求"和"请求中"两种状态,避免初始渲染时的加载闪烁。
【本章小结】
| 阶段 | Action Type | state.status | UI 展示 |
|---|---|---|---|
| dispatch 前 | - | 'idle' | 无 |
| 请求中 | xxx/pending | 'loading' | 加载动画 |
| 请求成功 | xxx/fulfilled | 'succeeded' | 数据列表 |
| 请求失败 | xxx/rejected | 'failed' | 错误提示 |
【面试考点】
Q:createAsyncThunk 中 rejectWithValue 和直接 throw error 有什么区别?
A:两者都会触发 rejected action,但错误信息的位置不同。直接 throw 时,Thunk 会把错误序列化后放入 action.error,受 Redux 可序列化约束,Error 对象无法完整保留;用 rejectWithValue(value) 时,value 被放入 action.payload,可以是任意可序列化的数据(字符串、对象),更灵活,在 extraReducers 中也更易访问。推荐始终使用 rejectWithValue 以获得完整的自定义错误信息。
Q:为什么需要 extraReducers,在 createSlice 的 reducers 中直接处理不行吗?
A:reducers 只能定义与当前 slice 的 name 挂钩的 action(即自动生成的 action)。createAsyncThunk 生成的 action type 格式是 typePrefix/pending 等,不属于任何 slice 自动管理的范围,必须用 extraReducers 引用外部 action。这也体现了 Redux 的可组合性——一个 slice 可以响应来自其他 slice 或 thunk 的 action,实现跨 slice 的联动。
六、createSelector 记忆化选择器
6.1 名词解释
- Selector(选择器):从 Redux state 中提取数据的函数,形如
state => state.cart.items。 - 派生数据(Derived Data):从原始 state 计算出的数据,如购物车总价(由 items 计算得出)。
- 记忆化(Memoization):缓存函数的计算结果,当输入不变时直接返回缓存,避免重复计算。
- Reselect:提供记忆化 selector 的库,RTK 已将其集成为
createSelector。 - 浅比较(Shallow Equality):只比较对象的引用(
===),不深入比较对象内部属性。
6.2 记忆化原理与性能收益
为什么需要记忆化?
useSelector 在每次 Redux state 变化时都会执行 selector 函数。即使 cart.items 没有变化,只要其他 slice(如 theme、user)的状态更新,所有组件的 selector 都会重新执行:
// 无记忆化的 selector:每次 state 变化都重新计算
const total = useAppSelector(state =>
state.cart.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
// 问题:切换主题色时,这个 reduce 也会执行,尽管 cart.items 没有变化
createSelector 的记忆化策略:只有当输入 selector 的返回值发生变化(引用不等)时,才重新执行计算函数:
import { createSelector } from '@reduxjs/toolkit'
import type { RootState } from '../store'
// 输入 selector(原始数据提取)
const selectCartItems = (state: RootState) => state.cart.items
// 记忆化 selector(派生数据计算)
export const selectCartTotal = createSelector(
selectCartItems, // 输入 selector
items => // 计算函数(只在 items 引用变化时执行)
items.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
export const selectCartCount = createSelector(
selectCartItems,
items => items.reduce((sum, item) => sum + item.quantity, 0)
)
// 工厂模式:参数化 selector
export const makeSelectIsInCart = (productId: number) =>
createSelector(
selectCartItems,
items => items.some(i => i.id === productId)
)
【代码注释】createSelector 接收 N 个输入 selector 和一个计算函数。内部用浅比较(===)缓存上一次的输入结果,只有当某个输入 selector 返回新引用时才重新计算。因为 Immer 的结构共享,未修改的子树引用不变,这两个机制配合完美。工厂模式(makeSelectIsInCart)解决了带参数的记忆化 selector 问题,在组件中:const isInCart = useAppSelector(makeSelectIsInCart(product.id))。市面应用:电商商品列表中每个商品卡片判断"是否已加入购物车",就是典型的参数化 selector 场景。
flowchart TD
A[state 发生任何变化] --> B{selectCartItems\n返回值是否变化?}
B -- 否(引用相同) --> C[返回缓存的 total\n不执行 reduce]
B -- 是(新数组引用) --> D[执行 items.reduce...\n计算新 total]
D --> E[缓存新结果]
C --> F[组件不重渲染]
E --> G{total 值是否变化?}
G -- 否 --> F
G -- 是 --> H[组件重渲染]
【代码注释】此图展示 createSelector 的两级缓存检查:第一级检查输入 selector 的返回值是否变化(引用比较),第二级检查计算结果是否变化(值比较)。只有通过两级检查才触发组件重渲染,大幅减少不必要的计算和渲染。
【实战要点】
- 经典应用场景:购物车总价(
price * quantity累加)、列表过滤结果(基于搜索词过滤商品)、权限计算(基于用户角色派生可见菜单)——凡是"从原始数据计算出的派生数据"都应使用 createSelector。 - 常见坑:在组件内部创建工厂 selector(
const selector = makeSelectIsInCart(id)),每次渲染都创建新的 selector 实例,导致记忆化失效。正确做法是在组件外部或用useMemo创建 selector 实例。 - 最佳实践:
createSelector默认只缓存最后一次计算结果(缓存大小为 1)。若同一个 selector 在多个组件中以不同参数使用,需要用createSelectorCreator自定义缓存策略,或为每个组件实例创建独立 selector。
【本章小结】
| 场景 | 普通 selector | createSelector |
|---|---|---|
| 简单数据提取 | ✅ 足够 | 过度设计 |
| 派生数据计算(reduce/filter) | ⚠️ 每次 state 变化都重算 | ✅ 输入不变不重算 |
| 带参数的 selector | ❌ 无法记忆化 | ✅ 工厂模式 |
| 多组件共享同一 selector | ✅ | ✅ 共享缓存 |
【面试考点】
Q:createSelector 的记忆化是如何实现的?它的缓存失效条件是什么?
A:createSelector 内部维护上一次的输入列表和计算结果。每次调用时,对每个输入 selector 的返回值进行浅比较(===)。若所有输入都与上一次相同(引用相等),直接返回缓存的计算结果,不执行计算函数;若有任何一个输入引用变化,重新执行计算函数并缓存新结果。缓存失效的条件是「任意输入 selector 返回的值引用变化」。默认缓存大小为 1(只记住最后一次),这在同一 selector 以不同参数被多个组件同时使用时会造成频繁缓存失效,需要用工厂模式解决。
七、configureStore 与 TypeScript 类型集成
7.1 名词解释
- configureStore:RTK 的 store 创建函数,自动集成 DevTools 和 redux-thunk 中间件。
- RootState:整个应用 state 树的 TypeScript 类型,由
ReturnType<typeof store.getState>自动推断。 - AppDispatch:store.dispatch 函数的 TypeScript 类型,支持 thunk 函数的类型推断。
- TypedUseSelectorHook:react-redux 提供的泛型类型,用于创建类型化的
useSelectorhook。 - 中间件(Middleware):Redux 的扩展机制,可以拦截 dispatch,用于异步处理、日志等。
7.2 Store 配置详解
// src/store/index.ts
import { configureStore } from '@reduxjs/toolkit'
import cartReducer from './slices/cartSlice'
import productReducer from './slices/productSlice'
import userReducer from './slices/userSlice'
export const store = configureStore({
reducer: {
cart: cartReducer,
products: productReducer,
user: userReducer
},
// middleware 配置(getDefaultMiddleware 已包含 redux-thunk)
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
// 状态或 action 中有不可序列化值(如 Date、函数)时需要关闭此检查
serializableCheck: {
// 忽略 redux-persist 的 action
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE']
}
})
// devTools 在生产环境自动禁用,开发环境自动启用(可手动配置)
// devTools: process.env.NODE_ENV !== 'production'
})
// 关键:从 store 推断类型,而非手动维护
export type RootState = ReturnType<typeof store.getState>
// AppDispatch 包含 thunk 类型支持
export type AppDispatch = typeof store.dispatch
【代码注释】configureStore 的 reducer 接受一个对象,每个键成为 state 树的一个命名空间(state.cart、state.products)。ReturnType<typeof store.getState> 是 TypeScript 的工具类型,从函数的返回值类型自动推断,这样每次新增 slice 后,RootState 会自动包含新的字段,无需手动更新类型定义。市面应用:中大型 React 项目(10+ slice)全部采用此模式自动维护 RootState 类型。
7.3 类型化 Hooks 封装
// src/store/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './index'
// 类型化 dispatch:支持 thunk action 的类型推断
export const useAppDispatch = () => useDispatch<AppDispatch>()
// 类型化 selector:自动获得 state 的完整类型提示
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
【代码注释】这两行代码是 RTK + TypeScript 项目的标配。封装后,业务组件中的 useAppDispatch 和 useAppSelector 不需要每次都手动传泛型,IDE 可以自动推断 state 的所有字段和 selector 的返回类型。TypedUseSelectorHook<RootState> 是 react-redux 提供的工具类型,专门用于创建类型化的 useSelector。市面应用:所有使用 RTK + TypeScript 的项目都会有这个文件,通常位于 src/store/hooks.ts。
在组件中使用的完整类型推断效果:
// 业务组件中(有完整 IDE 提示)
import { useAppDispatch, useAppSelector } from '../store/hooks'
import { addToCart, selectCartTotal } from '../store/slices/cartSlice'
function Component() {
const dispatch = useAppDispatch()
// total 自动推断为 number 类型
const total = useAppSelector(selectCartTotal)
// items 自动推断为 ICartItem[] 类型
const items = useAppSelector(state => state.cart.items)
// dispatch 知道 addToCart 需要 IProduct 类型参数
const handleAdd = (product: IProduct) => dispatch(addToCart(product))
}
【实战要点】
- 经典应用场景:TypeScript 项目的 store 配置是一次性工作,配置好后所有业务组件都享受完整类型推断,大幅减少运行时错误。
- 常见坑:在
configureStore的reducer参数里直接传入合并后的 rootReducer 对象,而不是让 RTK 自己合并——这会导致 TypeScript 无法正确推断各 slice 的命名空间类型。 - 最佳实践:将
store、RootState、AppDispatch的导出放在同一个文件(store/index.ts),将useAppDispatch和useAppSelector放在store/hooks.ts,业务组件只从hooks.ts导入,避免循环依赖。
【本章小结】
| 配置项 | 作用 | 关键点 |
|---|---|---|
reducer | 组合所有 slice reducer | 对象 key 成为 state 命名空间 |
middleware | 扩展 dispatch 能力 | 默认包含 redux-thunk 和序列化检查 |
RootState | 全局 state 类型 | ReturnType 自动推断,无需手动维护 |
AppDispatch | dispatch 函数类型 | 包含 thunk 支持 |
useAppDispatch | 类型化 dispatch hook | 省去每次手动传泛型 |
useAppSelector | 类型化 selector hook | 自动补全 state 字段 |
【面试考点】
Q:为什么要封装 useAppDispatch 和 useAppSelector,直接用 useDispatch 和 useSelector 不行吗?
A:可以用,但需要每次手动传泛型:useSelector<RootState, number>(state => state.cart.total) 和 useDispatch<AppDispatch>()。封装后的 hook 已内置了应用特定的 RootState 和 AppDispatch 类型,使用时无需重复传泛型,且如果 store 类型发生变化(新增 slice),所有使用封装 hook 的组件都自动获得更新的类型,不会有遗漏。这是 RTK 官方文档明确推荐的模式。
八、完整购物车实战
8.1 购物车 Slice 设计
// src/store/slices/cartSlice.ts
import { createSlice, createSelector, PayloadAction } from '@reduxjs/toolkit'
import type { RootState } from '../index'
// 商品接口
export interface IProduct {
id: number
name: string
price: number
image: string
stock: number
}
// 购物车商品(商品 + 数量)
export interface ICartItem extends IProduct {
quantity: number
}
// 购物车 state 结构
interface CartState {
items: ICartItem[]
}
const cartSlice = createSlice({
name: 'cart',
initialState: { items: [] } as CartState,
reducers: {
// 添加到购物车(已存在则增加数量,考虑库存上限)
addToCart(state, action: PayloadAction<IProduct>) {
const existing = state.items.find(i => i.id === action.payload.id)
if (existing) {
// 不超过库存上限
existing.quantity = Math.min(existing.quantity + 1, action.payload.stock)
} else {
state.items.push({ ...action.payload, quantity: 1 })
}
},
// 从购物车移除
removeFromCart(state, action: PayloadAction<number>) {
state.items = state.items.filter(i => i.id !== action.payload)
},
// 设置商品数量(数量 ≤ 0 时自动移除)
setQuantity(state, action: PayloadAction<{ id: number; quantity: number }>) {
const { id, quantity } = action.payload
const item = state.items.find(i => i.id === id)
if (!item) return
if (quantity <= 0) {
state.items = state.items.filter(i => i.id !== id)
} else {
item.quantity = Math.min(quantity, item.stock)
}
},
// 清空购物车
clearCart(state) {
state.items = []
}
}
})
// ── Selectors ──────────────────────────────────────────────────
// 基础 selector
export const selectCartItems = (state: RootState) => state.cart.items
// 记忆化:总价
export const selectCartTotal = createSelector(
selectCartItems,
items => items.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
// 记忆化:总件数
export const selectCartCount = createSelector(
selectCartItems,
items => items.reduce((sum, item) => sum + item.quantity, 0)
)
// 记忆化工厂:判断某商品是否在购物车中
export const makeSelectIsInCart = (productId: number) =>
createSelector(selectCartItems, items => items.some(i => i.id === productId))
// 记忆化工厂:获取购物车中某商品的数量
export const makeSelectItemQuantity = (productId: number) =>
createSelector(
selectCartItems,
items => items.find(i => i.id === productId)?.quantity ?? 0
)
export const { addToCart, removeFromCart, setQuantity, clearCart } = cartSlice.actions
export default cartSlice.reducer
【代码注释】这个 cartSlice 包含了购物车的完整业务逻辑:(1)addToCart 处理"已在购物车中增量"和"新增"两种情况,并用 Math.min 限制库存上限;(2)setQuantity 在数量降至 0 时自动移除,避免出现"0件商品"的异常状态;(3)工厂 selector(makeSelectIsInCart、makeSelectItemQuantity)为每个商品卡片提供独立的记忆化 selector。市面应用:这是标准的电商购物车 Redux 实现,京东、淘宝等平台的前端购物车逻辑与此高度相似。
8.2 ProductCard 组件
// src/components/ProductCard.tsx
import { memo } from 'react'
import { useAppDispatch, useAppSelector } from '../store/hooks'
import {
addToCart,
setQuantity,
makeSelectIsInCart,
makeSelectItemQuantity
} from '../store/slices/cartSlice'
import type { IProduct } from '../store/slices/cartSlice'
interface Props {
product: IProduct
}
// memo 避免父组件重渲染时不必要的子组件重渲染
const ProductCard = memo(function ProductCard({ product }: Props) {
const dispatch = useAppDispatch()
// 工厂 selector:每个 ProductCard 有独立的记忆化 selector 实例
const isInCart = useAppSelector(makeSelectIsInCart(product.id))
const quantity = useAppSelector(makeSelectItemQuantity(product.id))
const handleDecrease = () => {
dispatch(setQuantity({ id: product.id, quantity: quantity - 1 }))
}
const handleIncrease = () => {
dispatch(setQuantity({ id: product.id, quantity: quantity + 1 }))
}
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<div className="product-info">
<h3>{product.name}</h3>
<p className="price">¥{product.price.toFixed(2)}</p>
<p className="stock">库存:{product.stock} 件</p>
</div>
{isInCart ? (
<div className="quantity-control">
<button onClick={handleDecrease} disabled={quantity <= 1}>-</button>
<span>{quantity}</span>
<button onClick={handleIncrease} disabled={quantity >= product.stock}>+</button>
<button className="remove-btn" onClick={() =>
dispatch({ type: 'cart/removeFromCart', payload: product.id })
}>
移除
</button>
</div>
) : (
<button
className="add-btn"
onClick={() => dispatch(addToCart(product))}
disabled={product.stock === 0}
>
{product.stock === 0 ? '已售罄' : '加入购物车'}
</button>
)}
</div>
)
})
export default ProductCard
【代码注释】几个设计要点:(1)用 React.memo 包裹组件,配合 Redux 的结构共享,确保只有与该商品相关的 selector 输出变化时组件才重渲染;(2)makeSelectIsInCart(product.id) 在每次渲染时都会调用工厂函数,为避免每次创建新实例导致记忆化失效,实际项目中应将其用 useMemo 稳定:const isInCartSelector = useMemo(() => makeSelectIsInCart(product.id), [product.id]);(3)disabled={quantity >= product.stock} 防止超过库存的 UI 层防护(reducer 也有防护,双重保险)。
8.3 CartSidebar 组件
// src/components/CartSidebar.tsx
import { useAppDispatch, useAppSelector } from '../store/hooks'
import {
selectCartItems,
selectCartTotal,
selectCartCount,
removeFromCart,
clearCart
} from '../store/slices/cartSlice'
function CartSidebar() {
const dispatch = useAppDispatch()
const items = useAppSelector(selectCartItems)
const total = useAppSelector(selectCartTotal) // 记忆化,不重复计算
const count = useAppSelector(selectCartCount) // 记忆化,不重复计算
if (items.length === 0) {
return (
<aside className="cart-sidebar cart-empty">
<p>购物车空空如也,快去挑选商品吧~</p>
</aside>
)
}
return (
<aside className="cart-sidebar">
<header>
<h2>购物车</h2>
<span className="cart-count">共 {count} 件</span>
</header>
<ul className="cart-items">
{items.map(item => (
<li key={item.id} className="cart-item">
<img src={item.image} alt={item.name} width={60} />
<div className="item-detail">
<p className="item-name">{item.name}</p>
<p className="item-price">
¥{item.price} × {item.quantity}
= <strong>¥{(item.price * item.quantity).toFixed(2)}</strong>
</p>
</div>
<button
className="remove-btn"
onClick={() => dispatch(removeFromCart(item.id))}
aria-label={`移除 ${item.name}`}
>
✕
</button>
</li>
))}
</ul>
<footer className="cart-footer">
<p className="total">
合计:<strong>¥{total.toFixed(2)}</strong>
</p>
<div className="cart-actions">
<button className="clear-btn" onClick={() => dispatch(clearCart())}>
清空购物车
</button>
<button className="checkout-btn">
去结算({count} 件)
</button>
</div>
</footer>
</aside>
)
}
export default CartSidebar
【代码注释】selectCartTotal 和 selectCartCount 是记忆化 selector,只有 items 数组引用变化时才重新计算,避免了每次无关 state 更新时的重复 reduce 计算。aria-label 属性提升了无障碍访问体验(屏幕阅读器会读出"移除商品名")。市面应用:这是标准的电商购物车侧边栏实现,与 Amazon、京东购物车的 React 实现结构高度相似。
【实战要点】
- 经典应用场景:购物车页面、订单确认页、结算页——这类页面同时需要读取多种派生数据(总价、总件数、优惠后价格),是 createSelector 的经典用武之地。
- 常见坑:在 CartSidebar 中直接用
items.length显示数量而非selectCartCount——如果每件商品有 quantity 字段,items.length是商品种类数而非商品总件数,这是业务逻辑 bug。 - 最佳实践:购物车相关的所有派生计算(总价、折扣、运费、总件数)都应放在 selector 中,而不是在组件的 render 函数里计算,这样逻辑集中、可复用、可测试。
【本章小结】
| 组件 | 读取的 selector | dispatch 的 action |
|---|---|---|
| ProductCard | isInCart, quantity | addToCart, setQuantity |
| CartSidebar | items, total, count | removeFromCart, clearCart |
| Header/NavBar | selectCartCount | - |
【面试考点】
Q:如何用 TypeScript 为 createSlice 的 PayloadAction 提供精确类型?
A:在 reducers 的函数参数中用 PayloadAction<T> 泛型标注 action 的类型,T 是 payload 的类型。例如 addToCart(state, action: PayloadAction<IProduct>) 告诉 TypeScript action.payload 是 IProduct 类型,编译时就能检查 dispatch(addToCart(xxx)) 时 xxx 是否符合 IProduct 接口。RTK 会从这个类型推断出 action creator 的参数类型,实现端到端的类型安全,不需要在 action creator 调用处重复标注类型。
九、本地持久化与 Redux Persist
购物车数据在页面刷新后应当保留。redux-persist 将 Redux state 序列化存入 localStorage,并在应用启动时自动恢复。
// src/store/index.ts(集成 redux-persist)
import { configureStore, combineReducers } from '@reduxjs/toolkit'
import { persistStore, persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist'
import storage from 'redux-persist/lib/storage' // 默认使用 localStorage
import cartReducer from './slices/cartSlice'
import productReducer from './slices/productSlice'
// 购物车持久化配置
const cartPersistConfig = {
key: 'cart', // localStorage 中的 key
storage, // 使用 localStorage
whitelist: ['items'] // 只持久化 items,不持久化其他字段
}
const rootReducer = combineReducers({
cart: persistReducer(cartPersistConfig, cartReducer),
products: productReducer // 商品列表不需要持久化(每次从接口拉取)
})
export const store = configureStore({
reducer: rootReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: {
// 忽略 redux-persist 内部使用的非序列化 action
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
}
})
})
export const persistor = persistStore(store)
export type RootState = ReturnType<typeof store.getState>
export type AppDispatch = typeof store.dispatch
【代码注释】whitelist 控制只持久化 items 字段——如果 state 中还有 status('loading'/'succeeded')等字段,不应该持久化,否则刷新后 status 仍是 'loading' 会导致 UI 异常。ignoredActions 必须包含 redux-persist 的内部 action,否则 RTK 的序列化检查中间件会报警告。市面应用:电商平台购物车、表单草稿自动保存、用户偏好设置均使用此方案。
// src/App.tsx(PersistGate 包裹)
import { Provider } from 'react-redux'
import { PersistGate } from 'redux-persist/integration/react'
import { store, persistor } from './store'
function App() {
return (
<Provider store={store}>
{/* PersistGate 等待 localStorage 恢复完成后再渲染子组件 */}
<PersistGate loading={<div>状态恢复中...</div>} persistor={persistor}>
<RouterProvider router={router} />
</PersistGate>
</Provider>
)
}
【代码注释】PersistGate 是一个等待门,在 redux-persist 从 localStorage 读取并恢复 state 之前显示 loading 内容,恢复完成后才渲染子组件。这避免了"state 还没从 localStorage 恢复时购物车显示为空"的闪烁问题。市面应用:所有需要用户刷新后保留状态的 Web 应用(购物车、编辑器草稿、用户设置)都需要类似的"恢复门"机制。
【实战要点】
- 经典应用场景:购物车商品列表、用户偏好(主题/语言)、表单中途保存的草稿——凡是"用户操作后刷新不应丢失"的数据都应持久化。
- 常见坑:持久化了
status: 'loading'字段,导致刷新后应用一直显示加载状态。解决:用whitelist只持久化需要保留的字段,或在REHYDRATEaction 的 case 中将 status 重置为'idle'。 - 最佳实践:不要持久化服务器数据缓存(如商品列表、用户详情),这些数据应在每次启动时重新请求,以确保数据新鲜度;只持久化用户主动产生的数据(购物车选择、偏好设置)。
【本章小结】
| 配置项 | 作用 | 注意事项 |
|---|---|---|
key | localStorage 的存储 key | 不同 slice 用不同 key |
storage | 存储引擎 | 默认 localStorage,可换 sessionStorage |
whitelist | 只持久化指定字段 | 排除 status、error 等动态字段 |
PersistGate | 恢复完成前的等待门 | loading prop 显示恢复中的 UI |
ignoredActions | 忽略内部 action 的序列化检查 | 必须配置,否则控制台报警告 |
十、交互式购物车 HTML 演示
以下是一个完整可运行的购物车演示,用纯 HTML + JavaScript 模拟 Redux 的状态管理逻辑,可以直接保存为 .html 文件在浏览器中打开:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redux 购物车原理演示</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f5f5f5; color: #333; }
.app { display: grid; grid-template-columns: 1fr 380px; gap: 0; min-height: 100vh; }
/* 左侧商品区 */
.main { padding: 24px; }
.main h1 { font-size: 22px; margin-bottom: 20px; color: #1a1a1a; }
.product-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 16px; }
.product-card {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
transition: transform .2s, box-shadow .2s;
}
.product-card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0,0,0,.12); }
.product-img {
width: 100%; height: 160px; object-fit: cover;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; align-items: center; justify-content: center;
font-size: 48px;
}
.product-body { padding: 16px; }
.product-name { font-size: 15px; font-weight: 600; margin-bottom: 6px; }
.product-price { color: #e53e3e; font-size: 18px; font-weight: 700; margin-bottom: 4px; }
.product-stock { color: #999; font-size: 12px; margin-bottom: 12px; }
.btn { border: none; border-radius: 8px; cursor: pointer; font-size: 14px; padding: 8px 16px; transition: all .2s; }
.btn-add { background: #4f6ef7; color: #fff; width: 100%; }
.btn-add:hover { background: #3b5bdb; }
.btn-add:disabled { background: #ccc; cursor: not-allowed; }
.btn-add.in-cart { background: #38a169; }
.qty-control { display: flex; align-items: center; gap: 8px; }
.qty-btn { width: 32px; height: 32px; border-radius: 8px; background: #f0f0f0; border: none; cursor: pointer; font-size: 18px; font-weight: 700; }
.qty-btn:hover:not(:disabled) { background: #ddd; }
.qty-btn:disabled { opacity: .4; cursor: not-allowed; }
.qty-num { flex: 1; text-align: center; font-size: 16px; font-weight: 600; }
/* 右侧购物车 */
.cart-sidebar {
background: #fff;
border-left: 1px solid #eee;
display: flex; flex-direction: column;
height: 100vh; position: sticky; top: 0;
}
.cart-header {
padding: 20px 20px 16px;
border-bottom: 1px solid #f0f0f0;
display: flex; justify-content: space-between; align-items: center;
}
.cart-header h2 { font-size: 18px; }
.cart-badge {
background: #e53e3e; color: #fff;
border-radius: 12px; padding: 2px 8px; font-size: 12px;
}
.cart-items { flex: 1; overflow-y: auto; padding: 12px 20px; }
.cart-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #999; gap: 8px; }
.cart-empty .empty-icon { font-size: 48px; }
.cart-item {
display: flex; align-items: center; gap: 12px;
padding: 12px 0; border-bottom: 1px solid #f5f5f5;
}
.cart-item-icon { font-size: 32px; }
.cart-item-info { flex: 1; }
.cart-item-name { font-size: 14px; font-weight: 500; margin-bottom: 4px; }
.cart-item-price { font-size: 12px; color: #999; }
.cart-item-subtotal { font-size: 14px; font-weight: 600; color: #e53e3e; white-space: nowrap; }
.btn-remove { background: none; border: none; cursor: pointer; color: #bbb; font-size: 16px; padding: 4px; }
.btn-remove:hover { color: #e53e3e; }
.cart-footer {
padding: 16px 20px 24px;
border-top: 1px solid #f0f0f0;
}
.cart-total { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.total-label { font-size: 14px; color: #666; }
.total-amount { font-size: 22px; font-weight: 700; color: #e53e3e; }
.btn-checkout { width: 100%; background: #e53e3e; color: #fff; padding: 12px; border-radius: 10px; font-size: 15px; font-weight: 600; }
.btn-checkout:hover { background: #c53030; }
.btn-clear { width: 100%; background: #f5f5f5; color: #666; padding: 10px; border-radius: 10px; font-size: 14px; margin-bottom: 8px; }
.btn-clear:hover { background: #eee; }
/* 状态面板 */
.state-panel {
margin-top: 32px; background: #1a1a2e; border-radius: 12px;
padding: 16px; color: #a8ff78;
}
.state-panel h3 { color: #fff; font-size: 13px; margin-bottom: 10px; font-family: monospace; }
.state-code { font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.6; white-space: pre-wrap; max-height: 200px; overflow-y: auto; }
/* 操作日志 */
.action-log {
margin-top: 16px; background: #1a1a2e; border-radius: 12px;
padding: 16px;
}
.action-log h3 { color: #fff; font-size: 13px; margin-bottom: 10px; font-family: monospace; }
.log-list { max-height: 160px; overflow-y: auto; }
.log-item { font-family: monospace; font-size: 11px; padding: 3px 0; border-bottom: 1px solid #2a2a4a; }
.log-item .log-type { color: #ffd700; }
.log-item .log-payload { color: #87ceeb; }
.log-item .log-time { color: #666; float: right; }
@media (max-width: 768px) {
.app { grid-template-columns: 1fr; }
.cart-sidebar { height: auto; position: static; }
}
</style>
</head>
<body>
<div class="app">
<!-- 左侧:商品列表 -->
<div class="main">
<h1>商品列表</h1>
<div class="product-grid" id="productGrid"></div>
<!-- Redux State 可视化面板 -->
<div class="state-panel">
<h3>// Redux State 实时快照(模拟 DevTools)</h3>
<div class="state-code" id="stateDisplay"></div>
</div>
<!-- Action 日志 -->
<div class="action-log">
<h3>// Action 日志(模拟 Redux DevTools)</h3>
<div class="log-list" id="actionLog"></div>
</div>
</div>
<!-- 右侧:购物车 -->
<div class="cart-sidebar">
<div class="cart-header">
<h2>购物车</h2>
<span class="cart-badge" id="cartBadge">0</span>
</div>
<div id="cartContent" class="cart-items">
<div class="cart-empty">
<div class="empty-icon">🛒</div>
<p>购物车空空如也</p>
</div>
</div>
<div class="cart-footer" id="cartFooter" style="display:none">
<div class="cart-total">
<span class="total-label">合计</span>
<span class="total-amount" id="cartTotal">¥0.00</span>
</div>
<button class="btn btn-clear" onclick="store.dispatch({ type: 'cart/clearCart' })">清空购物车</button>
<button class="btn btn-checkout" id="checkoutBtn">去结算</button>
</div>
</div>
</div>
<script>
// ══════════════════════════════════════════════════
// 模拟 Redux Store 实现
// ══════════════════════════════════════════════════
// 商品数据(模拟数据库)
const PRODUCTS = [
{ id: 1, name: '无线蓝牙耳机', price: 299, stock: 5, icon: '🎧' },
{ id: 2, name: '机械键盘', price: 599, stock: 3, icon: '⌨️' },
{ id: 3, name: '便携充电宝', price: 149, stock: 10, icon: '🔋' },
{ id: 4, name: '智能手环', price: 199, stock: 8, icon: '⌚' },
{ id: 5, name: '降噪鼠标', price: 399, stock: 2, icon: '🖱️' },
{ id: 6, name: '4K 摄像头', price: 699, stock: 4, icon: '📷' },
]
// ── 纯函数 Reducer(模拟 cartSlice 的 reducer) ──
function cartReducer(state = { items: [] }, action) {
// 注意:这里我们"手动实现"不可变更新(生产中由 Immer 处理)
switch (action.type) {
case 'cart/addToCart': {
const product = action.payload
const existingIdx = state.items.findIndex(i => i.id === product.id)
if (existingIdx >= 0) {
const items = state.items.map((item, idx) =>
idx === existingIdx
? { ...item, quantity: Math.min(item.quantity + 1, product.stock) }
: item
)
return { ...state, items }
}
return { ...state, items: [...state.items, { ...product, quantity: 1 }] }
}
case 'cart/removeFromCart': {
return { ...state, items: state.items.filter(i => i.id !== action.payload) }
}
case 'cart/setQuantity': {
const { id, quantity } = action.payload
if (quantity <= 0) {
return { ...state, items: state.items.filter(i => i.id !== id) }
}
const product = PRODUCTS.find(p => p.id === id)
const maxQty = product ? product.stock : 999
return {
...state,
items: state.items.map(item =>
item.id === id
? { ...item, quantity: Math.min(quantity, maxQty) }
: item
)
}
}
case 'cart/clearCart':
return { ...state, items: [] }
default:
return state
}
}
// ── 简易 Store 实现(模拟 Redux createStore 逻辑) ──
function createStore(reducer) {
let state = reducer(undefined, { type: '@@INIT' })
const listeners = []
const actionHistory = []
return {
getState: () => state,
dispatch(action) {
// 1. 调用 reducer 生成新 state(纯函数)
const prevState = state
state = reducer(state, action)
// 记录 action 历史(模拟 Redux DevTools)
actionHistory.unshift({
type: action.type,
payload: action.payload,
time: new Date().toLocaleTimeString()
})
if (actionHistory.length > 20) actionHistory.pop()
// 2. 通知所有订阅者(类似 React 的 setState 触发重渲染)
listeners.forEach(fn => fn(state, prevState, action))
return action
},
subscribe(listener) {
listeners.push(listener)
return () => {
const idx = listeners.indexOf(listener)
if (idx > -1) listeners.splice(idx, 1)
}
},
getActionHistory: () => actionHistory
}
}
// ── Selectors(记忆化简化版) ──
const selectCartItems = state => state.items
const selectCartTotal = state =>
state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
const selectCartCount = state =>
state.items.reduce((sum, item) => sum + item.quantity, 0)
const selectIsInCart = (state, id) => state.items.some(i => i.id === id)
const selectItemQuantity = (state, id) =>
state.items.find(i => i.id === id)?.quantity ?? 0
// ── 创建 store ──
const store = createStore(cartReducer)
// ══════════════════════════════════════════════════
// UI 渲染层(模拟 React 组件)
// ══════════════════════════════════════════════════
// 渲染商品列表
function renderProducts() {
const cartState = store.getState()
const grid = document.getElementById('productGrid')
grid.innerHTML = PRODUCTS.map(product => {
const inCart = selectIsInCart(cartState, product.id)
const qty = selectItemQuantity(cartState, product.id)
const soldOut = product.stock === 0
return `
<div class="product-card" data-id="${product.id}">
<div class="product-img">${product.icon}</div>
<div class="product-body">
<div class="product-name">${product.name}</div>
<div class="product-price">¥${product.price.toFixed(2)}</div>
<div class="product-stock">库存:${product.stock} 件</div>
${inCart ? `
<div class="qty-control">
<button class="qty-btn" onclick="dispatch('cart/setQuantity', {id:${product.id}, quantity:${qty - 1}})"
${qty <= 1 ? 'disabled' : ''}>−</button>
<span class="qty-num">${qty}</span>
<button class="qty-btn" onclick="dispatch('cart/setQuantity', {id:${product.id}, quantity:${qty + 1}})"
${qty >= product.stock ? 'disabled' : ''}>+</button>
</div>
` : `
<button class="btn btn-add ${inCart ? 'in-cart' : ''}"
onclick="dispatch('cart/addToCart', ${JSON.stringify(product)})"
${soldOut ? 'disabled' : ''}>
${soldOut ? '已售罄' : '加入购物车'}
</button>
`}
</div>
</div>
`
}).join('')
}
// 渲染购物车
function renderCart() {
const cartState = store.getState()
const items = selectCartItems(cartState)
const total = selectCartTotal(cartState)
const count = selectCartCount(cartState)
// 更新角标
document.getElementById('cartBadge').textContent = count
document.getElementById('cartBadge').style.display = count > 0 ? '' : 'none'
const cartContent = document.getElementById('cartContent')
const cartFooter = document.getElementById('cartFooter')
if (items.length === 0) {
cartContent.innerHTML = `
<div class="cart-empty">
<div class="empty-icon">🛒</div>
<p>购物车空空如也</p>
<p style="font-size:12px;color:#ccc;margin-top:4px">快去挑选商品吧</p>
</div>`
cartFooter.style.display = 'none'
return
}
cartContent.innerHTML = items.map(item => `
<div class="cart-item">
<div class="cart-item-icon">${item.icon}</div>
<div class="cart-item-info">
<div class="cart-item-name">${item.name}</div>
<div class="cart-item-price">¥${item.price} × ${item.quantity}</div>
</div>
<div class="cart-item-subtotal">¥${(item.price * item.quantity).toFixed(2)}</div>
<button class="btn-remove" onclick="dispatch('cart/removeFromCart', ${item.id})" title="移除">✕</button>
</div>
`).join('')
document.getElementById('cartTotal').textContent = `¥${total.toFixed(2)}`
cartFooter.style.display = 'block'
// 结算按钮
document.getElementById('checkoutBtn').onclick = () => {
alert(`结算成功!\n共 ${count} 件商品\n合计:¥${total.toFixed(2)}`)
store.dispatch({ type: 'cart/clearCart' })
}
}
// 渲染 State 快照(模拟 Redux DevTools)
function renderStatePanel() {
const state = store.getState()
document.getElementById('stateDisplay').textContent =
JSON.stringify({ cart: state }, null, 2)
}
// 渲染 Action 日志
function renderActionLog() {
const history = store.getActionHistory()
const logEl = document.getElementById('actionLog')
logEl.innerHTML = history.map(entry => `
<div class="log-item">
<span class="log-type">${entry.type}</span>
${entry.payload !== undefined
? `<span class="log-payload"> → ${JSON.stringify(entry.payload)}</span>`
: ''}
<span class="log-time">${entry.time}</span>
</div>
`).join('')
}
// 统一 dispatch 函数(供 HTML onclick 调用)
function dispatch(type, payload) {
store.dispatch({ type, payload })
}
// ── 订阅 store,state 变化时重新渲染 ──
store.subscribe(() => {
renderProducts()
renderCart()
renderStatePanel()
renderActionLog()
})
// ── 初始渲染 ──
renderProducts()
renderCart()
renderStatePanel()
</script>
</body>
</html>
【代码注释】这个演示从零实现了 Redux 的核心机制:(1)createStore 函数实现了 getState、dispatch、subscribe 三个核心 API,展示 Redux store 的本质;(2)cartReducer 手写不可变更新,让你看清 Immer 在生产代码中帮你省去的工作;(3)store.subscribe 订阅 state 变化并重新渲染 UI,这正是 react-redux 的 useSelector 的底层原理;(4)State 快照面板实时展示 Redux state,模拟 Redux DevTools 的核心功能;(5)Action 日志记录每次 dispatch 的操作历史,展示 Redux 可追踪性的实际意义。将此文件在浏览器中打开,即可体验完整的购物车操作并观察 Redux state 的实时变化。
总结
知识点思维导图
mindmap
root((Redux Toolkit))
核心原则
单一数据源
State只读
纯函数Reducer
核心API
createSlice
自动生成ActionType
自动生成ActionCreator
内置Immer
configureStore
自动集成DevTools
内置redux-thunk
createAsyncThunk
pending状态
fulfilled状态
rejected状态
createSelector
记忆化
结构共享配合
工厂模式
底层原理
Immer
ES6 Proxy拦截
Draft草稿对象
结构共享算法
TypeScript集成
RootState推断
AppDispatch推断
类型化Hooks
工程实践
Slice文件组织
持久化redux-persist
DevTools调试
性能优化
【代码注释】此思维导图展示了 Redux Toolkit 的完整知识体系,从核心原则到底层原理再到工程实践,形成完整的学习路径。建议按"核心原则 → 核心 API → 底层原理 → 工程实践"的顺序掌握,每个节点都有对应的代码示例和面试考点。
高频面试题速查
| # | 问题 | 核心答案要点 |
|---|---|---|
| 1 | Redux 三大原则是什么? | 单一数据源、State 只读、纯函数 Reducer;解释每条原则的工程价值 |
| 2 | Immer 如何实现不可变更新? | ES6 Proxy 代理 baseState 创建 draft,拦截写操作记录变更,recipe 完成后结构共享生成新对象 |
| 3 | createSlice 的 name 有什么作用? | action type 的命名空间前缀,格式为 name/reducerKey,自动生成避免手写常量 |
| 4 | createAsyncThunk 的三个状态是什么? | pending(请求中)、fulfilled(成功)、rejected(失败);在 extraReducers 中处理 |
| 5 | createSelector 的记忆化原理? | 缓存输入 selector 的上一次返回值,浅比较输入变化,输入不变直接返回缓存结果 |
| 6 | 为什么封装 useAppDispatch 和 useAppSelector? | 内置 RootState 和 AppDispatch 类型,省去每次手动传泛型,IDE 自动补全更完整 |
| 7 | rejectWithValue 和 throw 有什么区别? | rejectWithValue 的值在 action.payload,throw 的错误在 action.error,前者更灵活 |
| 8 | RTK 相比传统 Redux 解决了什么问题? | 样板代码、手写不可变更新、异步处理配置、DevTools 集成四大痛点 |
学习建议
掌握顺序:
- 先理解 Redux 三大原则(理论基础)
- 手写一次传统 Redux(体验痛点,才能理解 RTK 的价值)
- 学习 createSlice 的使用,理解 Immer 的作用
- 学习 createAsyncThunk 处理网络请求
- 学习 createSelector 优化性能
- TypeScript 集成(RootState、AppDispatch、类型化 Hooks)
- 完整项目实战(购物车或用户管理)
调试工具:安装 Redux DevTools 浏览器扩展(Chrome/Firefox),配合 configureStore 自动集成,可以实时查看 state 快照、action 日志,并进行时间旅行调试。
进阶方向:
- RTK Query:RTK 官方的数据获取和缓存方案,可替代 createAsyncThunk + useEffect 的组合
- Entity Adapter:RTK 内置的实体标准化工具,优化列表数据的 CRUD 操作
- Immer 独立使用:Immer 可以脱离 RTK 单独使用,在任何需要不可变数据操作的场景中发挥价值
参考资料
- Redux 官方文档 — Redux 三大原则、数据流的权威定义
- Redux Toolkit 官方文档 — createSlice、createAsyncThunk、createSelector 的完整 API 文档
- Immer 官方文档 — Proxy 拦截原理、produce API、结构共享算法的详细说明
- Reselect 文档 — 记忆化 selector 的原理与高级用法
- React Redux 官方文档 — useSelector、useDispatch、TypedUseSelectorHook 的 TypeScript 集成指南
- redux-persist 文档 — 状态持久化配置与最佳实践
- MDN — ES6 Proxy — Proxy 的 handler 拦截原理与完整 API
- Redux Style Guide — Redux 官方推荐的编码规范与最佳实践