从 First Slice 到异步数据:Redux Toolkit 的实用思维模型
通过计数器、购物车和产品列表,了解 Redux Toolkit 的 store、slices、Immer 驱动的 reducer、selector 以及 createAsyncThunk 是如何相互配合的。
来源: Redux Toolkit: A Complete Guide from Basics to Production
随着 React 应用规模的扩大,诸如用户信息、购物车、偏好设置、筛选条件以及 API 数据等共享状态会分散在多个页面中,通过 props 传递这些数据会变得十分脆弱。Redux 通过中央存储来解决这一问题,而 Redux Toolkit(RTK)则是目前编写 Redux 代码的推荐方式。本指南将从计数器功能逐步讲解到包含计算总价的购物车以及基于 API 的列表功能,帮助你自信地阅读和编写 RTK 代码。
首先确认你是否真的需要使用存储:仅凭 prop drilling 并不足以成为安装 Redux 的理由。
Redux 的核心理念
Redux 将所有共享状态集中存储在一个仓库中,并且仅通过 reducer 处理的动作来修改这些状态,因此每次更新都是可预测的。如果没有 Redux,数据只能逐层通过 props 传递。
Component
↓
Props
↓
Component
↓
Props
↓
Component
通过商店,组件可以读取所需数据并发送动作以请求状态变更。
Redux Store
↑
│
┌─────────┴─────────┐
│ │
Component A Component B
│ │
└──── dispatch ─────┘
Redux Toolkit 的优势
RTK 汇集了用于常见 Redux 任务的实用工具。
Redux Toolkit
│
├── configureStore()
├── createSlice()
├── createAsyncThunk()
├── createSelector()
└── RTK Query
传统 Redux 需要将某个功能分散在多个文件中实现。
Action Types
Action Creators
Reducers
Constants
Store
Middleware
createSlice() 允许将大部分功能集中在一处实现。
数据在 RTK 应用中的流动方式
组件使用 useSelector() 读取数据,使用 useDispatch() 发送动作;还原器则用于生成新的状态。
React Application
│
↓
useSelector()
│
↓
Redux Store
↑
│
useDispatch()
↑
│
Actions
│
↓
Reducer
│
↓
New State
核心流程如下:
UI
↓
dispatch(action)
↓
Reducer
↓
State update
↓
Subscribed components
↓
UI re-render
安装与创建商店
需要安装两个包:
npm install @reduxjs/toolkit react-redux
@reduxjs/toolkit 包含 Redux 的 API;react-redux 则负责将其与 React 相连接。
useSelector()
useDispatch()
Provider
示例计数器应用是按功能模块来组织的。
src/
│
├── app/
│ └── store.js
│
├── features/
│ └── counter/
│ └── counterSlice.js
│
├── App.jsx
└── main.jsx
configureStore() 接收一个 slice reducer 的映射表;每个键对应一个顶层状态分支。
import { configureStore } from "@reduxjs/toolkit";
import counterReducer from "../features/counter/counterSlice";
export const store = configureStore({
reducer: {
counter: counterReducer,
},
});
最终得到的状态为:
{
counter: {
value: 0
}
}
Slices:一个功能,一个文件
每个 slice 负责状态中的一个逻辑部分,而应用通常会包含多个这样的 slice。
authSlice
cartSlice
productSlice
userSlice
notificationSlice
计数器 slice 会指定名称、初始状态以及每种操作对应的 reducer,随后导出生成的 action 创建函数和 reducer。
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
value: 0,
};
const counterSlice = createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
decrement(state) {
state.value -= 1;
},
incrementByAmount(state, action) {
state.value += action.payload;
},
},
});
export const {
increment,
decrement,
incrementByAmount,
} = counterSlice.actions;
export default counterSlice.reducer;
只需一次调用即可替代原本需要手写的三部分代码。
Action Types
+
Action Creators
+
Reducer
createSlice 会生成什么
以一个最简单的 slice 为例:
createSlice({
name: "counter",
initialState,
reducers: {
increment(state) {
state.value += 1;
},
},
});
RTK 会将 slice 名称与 reducer 键组合成一个 action 类型
"counter/increment"
并据此创建一个 action 创建函数。
increment()
因此 dispatch(increment()) 等同于发送如下动作:
dispatch({
type: "counter/increment",
});
将状态存储与 React 相连接
在 main.jsx 中,Provider 使得其下的所有组件都能使用该状态存储。
import ReactDOM from "react-dom/client";
import { Provider } from "react-redux";
import App from "./App";
import { store } from "./app/store";
ReactDOM.createRoot(
document.getElementById("root")
).render(
<Provider store={store}>
<App />
</Provider>
);
使用 useSelector 读取数据
useSelector() 可以精确获取组件所需的那一部分状态。
import { useSelector } from "react-redux";
function Counter() {
const count = useSelector(
state => state.counter.value
);
return <h1>{count}</h1>;
}
只有当该值发生变化时,组件才会重新渲染;详情可参阅 React-Redux 如何决定重新渲染。
使用 useDispatch 写入数据
useDispatch() 会返回状态存储的 dispatch 方法。
import { useDispatch } from "react-redux";
import { increment } from "./counterSlice";
function Counter() {
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(increment())}>
Increment
</button>
);
}
点击操作的执行流程如下:
Button Click
↓
dispatch(increment())
↓
Action
↓
Reducer
↓
State changes
↓
useSelector detects relevant change
↓
Component re-renders
通过 payload 传递数据
动作会在 payload 中携带数据,这些数据来自还原器的第二个参数:
incrementByAmount(state, action) {
state.value += action.payload;
}
调用方会将该值传递进去:
dispatch(incrementByAmount(10));
从而生成这样的动作:
{
type: "counter/incrementByAmount",
payload: 10
}
每个操作都有一个type,可选地还有一个payload。
action
│
├── type
└── payload
为何此处允许修改状态
这是一个经典的面试问题:这个 reducer 似乎在修改状态,而 Redux 是禁止这样做的。
increment(state) {
state.value += 1;
}
RTK 通过 Immer 来执行 reducer,Immer 会传入一个草稿代理对象,记录变更并生成一个新的不可变对象。
Reducer
↓
Immer Proxy
↓
Track changes
↓
Create new immutable state
这样既能写出易于理解的代码,又能保持不可变性。要么修改草稿对象,要么返回新值,绝不能同时进行两者操作;并且在 RTK reducer 之外也不要以这种方式修改状态。
购物车切片
电商购物车初始时是空的。
const initialState = {
items: [],
};
该切片用于添加、删除和清空商品;removeItem 会赋值一个新的数组,Immer 也能接受这个新数组。
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
items: [],
};
const cartSlice = createSlice({
name: "cart",
initialState,
reducers: {
addItem(state, action) {
state.items.push(action.payload);
},
removeItem(state, action) {
state.items = state.items.filter(
item => item.id !== action.payload
);
},
clearCart(state) {
state.items = [];
},
},
});
export const {
addItem,
removeItem,
clearCart,
} = cartSlice.actions;
export default cartSlice.reducer;
添加商品
给定一个产品对象:
const product = {
id: 101,
name: "Laptop",
price: 60000,
};
点击处理程序会将其派发:
const dispatch = useDispatch();
const handleAddToCart = () => {
dispatch(addItem(product));
};
整个产品数据便成为负载内容。
{
type: "cart/addItem",
payload: {
id: 101,
name: "Laptop",
price: 60000
}
}
随后 reducer 会将其推入:
state.items
移除某个项目
按 id 过滤以移除项目:
removeItem(state, action) {
state.items = state.items.filter(
item => item.id !== action.payload
);
}
组件仅传递 id 值:
dispatch(removeItem(101));
因此在 reducer 内部:
action.payload === 101
计算数值而非直接存储
假设购物车中包含这些条目:
[
{
price: 100,
quantity: 2,
},
{
price: 50,
quantity: 3,
}
]
总金额可直接得出:
100 × 2 + 50 × 3
= 200 + 150
= 350
将总金额与各个项目放在一起存储很诱人:
{
items,
total
}
但这样一来每个 reducer 都必须保持两者同步。不如直接计算:
const selectCartTotal = state =>
state.cart.items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
);
然后再读取该数值:
const total = useSelector(selectCartTotal);
存储真实数据源,所有可计算的内容都从中推导得出。
选择器作为单一访问点
选择器是一种用于读取状态的函数。
const selectCartItems = state =>
state.cart.items;
组件使用它,而无需了解状态的具体结构。
const items = useSelector(selectCartItems);
否则这条路径会在各处重复出现:
state.cart.items
通过选择器,重构只需修改一个函数即可。
使用 createSelector 进行缓存
createSelector() 会缓存派生结果,直到其输入发生变化。
import { createSelector } from "@reduxjs/toolkit";
const selectCartItems = state =>
state.cart.items;
export const selectCartTotal = createSelector(
[selectCartItems],
items =>
items.reduce(
(total, item) =>
total + item.price * item.quantity,
0
)
);
逻辑很简单:
Input unchanged
↓
Return cached result
Input changed
↓
Recalculate
这有助于处理耗时的计算,以及那些返回新数组或对象的选择器,否则每次存储更新时都会重新渲染。
使用 createAsyncThunk 加载数据
应用会调用诸如以下的 API:
GET /api/products
请求状态会从“空闲”变为“加载中”,最终变为“成功”
idle
↓
loading
↓
success
或者变为“错误”。
idle
↓
loading
↓
error
createAsyncThunk() 可以对这一过程进行建模:为其指定类型前缀和一个异步函数;抛出异常则表示请求失败。
import {
createAsyncThunk,
createSlice,
} from "@reduxjs/toolkit";
export const fetchProducts = createAsyncThunk(
"products/fetchProducts",
async () => {
const response = await fetch("/api/products");
if (!response.ok) {
throw new Error("Failed to fetch products");
}
return response.json();
}
);
它会生成三种生命周期操作:
fetchProducts.pending
fetchProducts.fulfilled
fetchProducts.rejected
在 slice 中处理生命周期
products slice 用于跟踪项目、状态及错误信息,并在 extraReducers 中对这些信息进行更新。
const productsSlice = createSlice({
name: "products",
initialState: {
items: [],
status: "idle",
error: null,
},
reducers: {},
extraReducers: builder => {
builder
.addCase(
fetchProducts.pending,
state => {
state.status = "loading";
}
)
.addCase(
fetchProducts.fulfilled,
(state, action) => {
state.status = "success";
state.items = action.payload;
}
)
.addCase(
fetchProducts.rejected,
(state, action) => {
state.status = "failed";
state.error =
action.error.message;
}
);
},
});
reducers 与 extraReducers 的区别
reducers 定义了 slice 自身的操作及其对应的创建函数。
reducers: {
addItem() {}
}
extraReducers 用于处理其他地方定义的操作,比如来自 createAsyncThunk() 的操作:
pending
fulfilled
rejected
该 slice 仅负责监听这些操作。
从组件中派发 thunk
在组件挂载时启动请求:
const dispatch = useDispatch();
useEffect(() => {
dispatch(fetchProducts());
}, [dispatch]);
严格模式会在开发环境中重复执行相关操作,因此会看到两次请求。之后选择对应状态
const {
items,
status,
error,
} = useSelector(
state => state.products
);
并根据状态进行渲染:
if (status === "loading") {
return <p>Loading...</p>;
}
if (status === "failed") {
return <p>{error}</p>;
}
return (
<div>
{items.map(product => (
<div key={product.id}>
{product.name}
</div>
))}
</div>
);
对于更复杂的数据获取场景,RTK Query 可以替代这种模式。
中间件与 configureStore
中间件位于 dispatch() 与 reducer 之间。
dispatch(action)
↓
Middleware
↓
Reducer
↓
New State
常见用途:
- 异步逻辑
- 日志记录
- 数据分析
- 身份验证流程
- 副作用处理
- 拦截动作
configureStore() 可替代手动配置,它会自动添加默认中间件(包括 thunks)以及 Redux DevTools。
const store = configureStore({
reducer: {
auth: authReducer,
cart: cartReducer,
products: productsReducer,
},
});
核心要点
- slice 用于为某个功能整合状态、reducer 以及对应的动作创建函数。
- Immer 能让基于变异的 reducer 更安全,但仅限于 RTK reducers 内部。
- 只存储最少必要的状态;其余状态可通过选择器获取,若计算成本较高则进行缓存。
createAsyncThunk()结合extraReducers可用于处理加载、成功和失败状态。configureStore()的默认配置已能满足大多数应用的需求。