React useContext、useReducer 实践:React 实现购物车项目
下面我们通过讲解一下使用 React 实现购物车项目,进一步讲解 React Context、React Reducer 的一些知识。
最终效果如下:
1. Reducer 设计
我们在 reducer/CartReducer.jsx 中封装 Cart 的一些逻辑,具体实现和 useState 差不多:
export const initialState = { items: {} };
export const actionTypes = {
ADD_ITEM: "ADD_ITEM",
SET_QTY: "SET_QTY",
REMOVE_ITEM: "REMOVE_ITEM",
CLEAR_CART: "CLEAR_CART",
};
function cartReducer(state, action) {
switch (action.type) {
case actionTypes.ADD_ITEM: {
const { product, qty } = action.payload;
const id = product.id;
const existing = state.items[id];
const baseQty = existing ? existing.qty : 0;
const added = Number(qty ?? 1);
const newQty = baseQty + (Number.isNaN(added) ? 0 : added);
return {
...state,
items: {
...state.items,
[id]: { product, qty: newQty },
},
};
}
case actionTypes.SET_QTY: {
const { id, qty } = action.payload;
const q = Number(qty);
const newItems = { ...state.items };
if (q <= 0) {
delete newItems[id];
} else {
newItems[id] = { ...newItems[id], qty: q };
}
return {
...state,
items: newItems,
};
}
case actionTypes.REMOVE_ITEM: {
const { id } = action.payload;
const newItems = { ...state.items };
delete newItems[id];
return {
...state,
items: newItems,
};
}
case actionTypes.CLEAR_CART: {
return { items: {} };
}
default:
return state;
}
}
export { cartReducer };
这里定义一个 actionType 是一个小技巧,这可以防止我们拼错字符串。
2. Context 设计
然后我们开始设计 Context。首先先完成 Context 模块实现,然后让其他组件调用 Context。
模块实现
根据在 React Context 笔记中讲解的设计模式,我们把 state 的 Context 和 actions 的 Context 拆分开来。
Provider 的实现非常简单:我们把参数传给 reducer 对应的 action 即可:
import { createContext, useMemo, useReducer } from "react";
import {
initialState,
actionTypes,
cartReducer,
} from "../reducers/CartReducer";
const CartContext = createContext(null);
const CartDispatchContext = createContext(null);
export function CartProvider({ children }) {
const [state, dispatch] = useReducer(cartReducer, initialState);
const actions = useMemo(
() => ({
addItem: (product, qty = 1) =>
dispatch({ type: actionTypes.ADD_ITEM, payload: { product, qty } }),
setQty: (id, qty) =>
dispatch({ type: actionTypes.SET_QTY, payload: { id, qty } }),
removeItem: (id) =>
dispatch({ type: actionTypes.REMOVE_ITEM, payload: { id } }),
clearCart: () => dispatch({ type: actionTypes.CLEAR_CART }),
}),
[dispatch],
);
const totalItems = Object.values(state.items).reduce(
(s, it) => s + it.qty,
0,
);
const totalPrice = Object.values(state.items).reduce(
(s, it) => s + it.qty * it.product.price,
0,
);
return (
<CartContext.Provider value={{ state, totalItems, totalPrice }}>
<CartDispatchContext.Provider value={actions}>
{children}
</CartDispatchContext.Provider>
</CartContext.Provider>
);
}
export { CartContext, CartDispatchContext };
其实
totalItems和totalPrice也可以写在 reducer 里面,但是当时忘掉了…
使用模块
然后我们就可以直接调用 useContext 来使用我们的上下文了。不过我们可以给它加一层简单的错误处理逻辑、把它放到 hooks 中:
import { useContext } from "react";
import { CartContext, CartDispatchContext } from "../contexts/CartContext";
export function useCart() {
const ctx = useContext(CartContext);
if (!ctx) throw new Error("useCart must be used inside CartProvider");
return ctx;
}
export function useCartActions() {
const ctx = useContext(CartDispatchContext);
if (!ctx) throw new Error("useCartActions must be used inside CartProvider");
return ctx;
}
然后就可以在组件中调用这些 Context 来实现数据获取与状态更新了。比如 CartItem.jsx:
import { useCartActions } from "../hooks/useCart";
export default function CartItem({ product, qty }) {
const { setQty, removeItem } = useCartActions();
return (
<div className="cart-item">
<img src={product.image} alt={product.title} />
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600 }}>{product.title}</div>
<div>${product.price.toFixed(2)}</div>
</div>
<QuantityControl
value={qty}
onChange={(v) => setQty(product.id, v)}
min={0}
/>
<div style={{ width: 120, textAlign: "right" }}>
${(product.price * qty).toFixed(2)}
</div>
<button
onClick={() => removeItem(product.id)}
aria-label={`Remove ${product.title}`}
>
Remove
</button>
</div>
);
}