Dark Dwarf Blog background

React useContext、useReducer 实践:React 实现购物车项目

React useContext、useReducer 实践:React 实现购物车项目

下面我们通过讲解一下使用 React 实现购物车项目,进一步讲解 React Context、React Reducer 的一些知识。

最终效果如下:

alt text

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。

a.a. 模块实现

根据在 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 };

其实 totalItemstotalPrice 也可以写在 reducer 里面,但是当时忘掉了…

b.b. 使用模块

然后我们就可以直接调用 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>
  );
}