前端开发模式探索 - (2)引入统一数据源

前端开发模式探索 - (2)引入统一数据源

编程
2025年10月13日
1708 字约 9 分钟

背景

在上一篇文章中,我们学会了如何在单个组件内做关注点分离。但随着项目复杂度增加,你会发现新的问题:多个组件需要共享数据

比如一个电商网站,购物车数据需要在顶部导航栏、商品列表、购物车抽屉、结算栏等多个地方使用。如果每个组件都自己管理状态,就会出现数据不一致的问题。

问题:数据散落各处

在没有状态管理的情况下,我们只能通过 props 层层传递数据(暂不考虑 useContext,因为手写缓存与选择器有较大的心智负担)。

让我们看看一个购物车页面的例子:

// services/cart-service.ts
export type SkuId = string;
export type AddPayload = { skuId: SkuId; qty: number };
export type UpdateQtyPayload = { skuId: SkuId; qty: number };
export type RemovePayload = { skuId: SkuId };

export type CartItem = { skuId: SkuId; title: string; price: number; qty: number };
export type CartSnapshot = { items: CartItem[] };

export async function fetchCart(): Promise<CartSnapshot>;
export async function addCartItem(payload: AddPayload): Promise<void>;
export async function updateCartItemQty(payload: UpdateQtyPayload): Promise<void>;
export async function removeCartItem(payload: RemovePayload): Promise<void>;
// hooks/use-cart.ts
import {
  type CartItem,
  type SkuId,
  type AddPayload,
  type UpdateQtyPayload,
  type RemovePayload,
} from '../services/cart-service';

type PendingMap = Record<SkuId, boolean>;

export function useCart(): {
  items: CartItem[];
  loading: boolean;
  error: string | null;
  pendingBySku: PendingMap;
  subtotal: number;
  cartCount: number;
  canCheckout: boolean;
  add: (payload: AddPayload) => Promise<void> | void;
  updateQty: (payload: UpdateQtyPayload) => Promise<void> | void;
  remove: (payload: RemovePayload) => Promise<void> | void;
};
// pages/shop-page.tsx
import { useCart } from '../hooks/use-cart';

export function ShopPage() {
  const {
    items,
    loading,
    error,
    pendingBySku,
    subtotal,
    cartCount,
    canCheckout,
    add,
    updateQty,
    remove,
  } = useCart();

  return (
    <>
      <TopBar cartCount={cartCount} />
      <ProductList
        products={[] /* fetched products */}
        cartItems={Object.fromEntries(items.map((i) => [i.skuId, i]))}
        addToCart={add}
        pendingBySku={pendingBySku}
      />
      <CartDrawer items={items} onUpdateQty={updateQty} onRemove={remove} subtotal={subtotal} />
      <CheckoutBar totalPrice={subtotal} canCheckout={canCheckout} />
      {loading && <div>Loading...</div>}
      {error && <div className="error">{error}</div>}
    </>
  );
}

这种写法虽然能工作,但存在明显的问题:

  • Props 传递地狱:购物车数据要一层层往下传,中间组件被迫接收它们不需要的 props
  • 顶层组件臃肿:页面组件被迫管理所有子组件需要的状态,职责不清
  • 难以维护:新增一个需要购物车数据的组件,就要修改整个传递链

解决方案:引入统一数据源

我们需要一个「统一的数据源(Store)」来集中管理状态与动作,让组件按需订阅。这里以 zustand 为例,它是一个轻量级的状态管理库。

第一步:创建 Store

首先,我们把购物车的状态和动作都集中到一个 Store 里:

// stores/cart-store.ts
import { type CartItem, type SkuId, type AddPayload, type UpdateQtyPayload, type RemovePayload } from '../services/cart-service';

export type CartStore = {
  // 状态
  items: CartItem[];
  pendingBySku: Record<SkuId, boolean>;
  // 派生
  subtotal: number;
  cartCount: number;
  canCheckout: boolean;
  // 动作
  initBySkuId: (id: SkuId) => Promise<void> | void;
  fetchCart: () => Promise<void> | void;
  add: (payload: AddPayload) => Promise<void> | void;
  updateQty: (payload: UpdateQtyPayload) => Promise<void> | void;
  remove: (payload: RemovePayload) => Promise<void> | void;
};

// 仅保留 Hook 签名,供组件选择订阅
export const useCartStore: {
  <T>(selector: (s: CartStore) => T, equalityFn?: (a: T, b: T) => boolean): T;
  (): CartStore;
};

Store 集中管理了:

  • 状态:购物车商品、加载状态等
  • 派生状态:总价、商品数量、是否可结算等计算属性
  • 动作:添加、删除、更新商品等操作

第二步:创建友好的 Hook 接口

虽然 Store 提供了完整的功能,但直接使用可能不够友好。我们创建一些 Hook 来提供更简洁的 API:

// hooks/use-cart-facade.ts
import { type CartItem, type SkuId, type AddPayload, type UpdateQtyPayload, type RemovePayload } from '../services/cart-service';
import { useCartStore } from '../stores/cart-store.ts';

// 获取购物车状态的 Hook
export function useCartState(): {
  items: CartItem[];
  pendingBySku: Record<SkuId, boolean>;
  subtotal: number;
  cartCount: number;
  canCheckout: boolean;
} => {
  // store 状态(示意,这里应该只需要引入 hooks 确实需要的状态)
  const items = useCartStore(state => state.items);
  const pendingBySku = useCartStore(state => state.pendingBySku);
  const subtotal = useCartStore(state => state.subtotal);
  const cartCount = useCartStore(state => state.cartCount);
  const canCheckout = useCartStore(state => state.canCheckout);

  // 也可在 Hook 内组合局部 UI 状态(示意)
  // const loading = useRef(...)

  return {
    items,
    pendingBySku,
    subtotal,
    cartCount,
    canCheckout,
  };
};

// 获取购物车操作的 Hook
export function useCartActions(): {
  fetchCart: () => Promise<void> | void;
  add: (payload: AddPayload) => Promise<void> | void;
  updateQty: (payload: UpdateQtyPayload) => Promise<void> | void;
  remove: (payload: RemovePayload) => Promise<void> | void;
  addOrUpdateFlow: (payload: AddPayload | UpdateQtyPayload) => Promise<void> | void;
} => {
  const add = useCartStore(state => state.add);
  const updateQty = useCartStore(state => state.updateQty);

  // 组合 store api 形成新的 api(仅示意)
  const addOrUpdateFlow = (payload: AddPayload | UpdateQtyPayload) => {
    if ((payload as any).id) {
      return updateQty(payload as UpdateQtyPayload);
    }
    return add(payload as AddPayload);
  };

  return {
    add,
    updateQty,
    addOrUpdateFlow,
    // fetchCart: useCartStore(state => state.fetchCart),
    // remove: useCartStore(state => state.remove),
  };
};

// 获取单个商品信息的 Hook(细粒度订阅)
export function useCartItem(skuId: SkuId): {
  qty: number;
  isPending: boolean;
} => {
  // 订阅细粒度切片(示意)
  const qty = useCartStore(state => state.items.find(i => i.skuId === skuId)?.qty ?? 0);
  const isPending = useCartStore(state => !!state.pendingBySku[skuId]);

  return {
    qty,
    isPending,
  };
};

这些 Hook 提供了:

  • 状态 Hook:只订阅需要的状态,避免不必要的重渲染
  • 操作 Hook:提供简洁的操作接口,可以组合多个 Store 方法
  • 细粒度 Hook:针对特定商品的状态订阅,性能更优

第三步:简化组件代码

// pages/shop-page.tsx
import { TopBar } from '../components/top-bar';
import { ProductList } from '../components/product-list';
import { CartDrawer } from '../components/cart-drawer';
import { CheckoutBar } from '../components/checkout-bar';

export function ShopPage(): JSX.Element {
  return (
    <>
      <TopBar />
      <ProductList />
      <CartDrawer />
      <CheckoutBar />
    </>
  );
}
// components/product-list.tsx
import { useCartActions, useCartState } from '../hooks/use-cart-facade';

export function ProductList(): JSX.Element {
  const { add } = useCartActions();
  const { pendingBySku, items } = useCartState();
  return null;
}

看,现在每个组件都可以直接使用需要的状态和操作,不再需要层层传递 props!

额外收益:统一初始化入口

状态都集中到 store 后,还有一个额外的好处:可以在应用入口统一初始化数据,避免各个组件「各拉各的」:

// ./entry.ts
export function initStore() {
  const uri = new URL(location.href);

  const cartId = uri.searchParams.get('cart_id');
  if (cartId) {
    // 用异步加载,避免入口文件过大
    import('./stores/cart-store.ts').then(({ useCartStore }) => {
      useCartStore.getState().initBySkuId(cartId as any);
    });
  }

  // 其他 store 的初始化逻辑
}

这样做的好处:

  • 统一管理:所有数据初始化都在一个地方,便于调试和维护
  • 性能优化:避免多个组件重复请求相同数据
  • 代码简洁:组件只需要关心展示,不需要关心数据初始化

最终结构示意

FYI