购物车数字怎么更新?一个前端问题的三种架构答案

4 阅读8分钟

在做电商项目的时候,有一个看起来很小的问题:用户在商品页加了一件东西进购物车,Header 右上角的数字要 +1。

这个需求本身不复杂,但我在三个不同的项目里,见到了三种完全不同的解法。每一种解法背后,都是一套不同的架构决策——状态到底归谁管?

这篇文章是我在读 Medusa(一个开源电商 SaaS)源码时引发的思考,整理了我对这个问题的理解过程。


先定义清楚这个问题

"Header 购物车数字怎么更新",本质上是一个跨组件状态同步问题:

商品页的"加入购物车"按钮(触发方)
         ↓
    购物车数量变了
         ↓
Header 的数字组件(响应方)

这两个组件在页面上没有直接的父子关系,但需要共享同一份数据。

不同架构对这个问题的回答,决定了整个前端的数据流长什么样。


解法一:客户端拥有状态(Monorepo 单体前端)

方案描述

在一个 Monorepo 单体前端项目里,一种常见的做法是用 useReducer + SessionStorage 来管理跨页面的状态:

// 环境:React + Next.js(Pages Router)
// 场景:用 useReducer 管理 cart 状态,并持久化到 SessionStorage

type CartState = {
  items: CartItem[]
  totalCount: number
}

type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: string }
  | { type: 'INIT'; payload: CartState }

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return {
        ...state,
        items: [...state.items, action.payload],
        totalCount: state.totalCount + action.payload.quantity,
      }
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload),
        totalCount: state.totalCount - 1,
      }
    case 'INIT':
      return action.payload
    default:
      return state
  }
}

// 每次 dispatch 同步写入 SessionStorage
function useCartWithSession() {
  const [state, dispatch] = useReducer(cartReducer, { items: [], totalCount: 0 })

  const persistedDispatch = (action: CartAction) => {
    dispatch(action)
    const nextState = cartReducer(state, action)
    sessionStorage.setItem('cart', JSON.stringify(nextState))
  }

  // 页面初始化时从 SessionStorage 读取
  useEffect(() => {
    const saved = sessionStorage.getItem('cart')
    if (saved) {
      persistedDispatch({ type: 'INIT', payload: JSON.parse(saved) })
    }
  }, [])

  return { state, dispatch: persistedDispatch }
}

数据流:

用户点击"加入购物车"
       ↓
dispatch(ADD_ITEM)
       ↓
reducer 更新内存中的 state
       ↓
同步写入 SessionStorage
       ↓
所有订阅了这个 context 的组件重渲染
       ↓
Header 数字更新

这个方案在做什么

状态存在客户端的内存和 SessionStorage 里。内存保证当前页面的响应速度,SessionStorage 保证页面跳转后状态不丢失。

组件间的同步靠 React Context——谁订阅了这个 context,谁就能感知到 dispatch 触发的变化。

权衡

优势:

  • 直觉清晰,数据流可追踪
  • 不依赖网络,操作响应快
  • 状态变化立即反映在 UI

代价:

  • 需要手动管理"内存状态"和"持久化状态"的同步
  • 如果有多个 tab 打开,状态会不一致
  • 客户端状态和服务端实际数据可能出现偏差(比如库存已售罄但客户端不知道)

解法二:消灭状态(微前端架构)

方案描述

在另一个电商项目里,前端是微前端架构——PDP(商品详情页)、Cart、Checkout 各自是独立部署的应用,挂载在一个 CMS(内容管理系统)的页面上。

这种架构下,"跨组件状态同步"这个问题根本不存在——因为根本没有一个"全局前端"。

CMS Shell(Header 在这里)
  ├── /products/[id]  →  PDP 微前端(只管商品详情)
  ├── /cart           →  Cart 微前端(只管购物车)
  └── /checkout       →  Checkout 微前端(只管结账流程)

跨应用的状态传递靠 URL 参数

// 环境:微前端,PDP 应用
// 场景:加购后跳转到 Cart,通过 URL 传递 cart_id

async function handleAddToCart(variantId: string) {
  // 调用 cart-service 创建或更新 cart
  const response = await fetch('/api/cart', {
    method: 'POST',
    body: JSON.stringify({ variantId, quantity: 1 }),
  })
  const { cartId } = await response.json()

  // 跳转到 Cart 微前端,通过 URL 传递 cart_id
  window.location.href = `/cart?cart_id=${cartId}`
}

// Cart 微前端初始化时从 URL 读取
function CartApp() {
  const cartId = new URLSearchParams(window.location.search).get('cart_id')

  useEffect(() => {
    if (cartId) {
      // 用 cart_id 查询购物车数据,初始化页面
      fetchCartData(cartId)
    }
  }, [cartId])
}

数据流:

用户在 PDP 点击"加入购物车"
       ↓
调用 cart-service API,拿到 cart_id
       ↓
跳转到 /cart?cart_id=xxx
       ↓
Cart 微前端用 cart_id 初始化,fetch 购物车数据

这个方案在做什么

这里没有"全局状态管理",而是用物理边界把问题消灭了。

每个微前端只管自己的数据,应用之间通过 URL 传递"通行证"(cart_id),谁拿到 cart_id 谁去查数据,不需要任何前端间的状态共享。

至于 Header 的购物车数字——那是 CMS 的职责,不在这个微前端的边界内。CMS 自己有机制处理。

权衡

优势:

  • 边界极其清晰,每个应用只关心自己的事
  • 应用间没有状态污染,独立部署,独立维护
  • 技术栈可以不统一

代价:

  • 全局体验难以协调(Header 的状态由谁来维护?)
  • 跨应用通信变复杂,URL 能传递的信息有限
  • 每次跨应用跳转都是完整的页面刷新,体验有损

解法三:服务端拥有状态(单体前端 + Server Cache)

方案描述

读 Medusa 的源码时,我在找 Cart 相关的 Context——搜索 createContext,整个仓库只有两个结果:一个是 modal,一个是 Stripe 支付。Cart 根本没有用 Context。

然后我搜 revalidateTag,在 cart.ts 里找到了答案:

// 环境:Next.js App Router,Server Action
// 来源:Medusa nextjs-starter-medusa/src/lib/data/cart.ts
// 场景:加购操作

"use server"

export async function addToCart({
  variantId,
  quantity,
  countryCode,
}: {
  variantId: string
  quantity: number
  countryCode: string
}) {
  // 确保 cart 存在,没有就创建,并把 cart_id 写入 cookie
  const cart = await getOrSetCart(countryCode)

  if (!cart) {
    throw new Error("Error retrieving or creating cart")
  }

  // 调用 Medusa API 写入数据
  await sdk.store.cart
    .createLineItem(
      cart.id,
      { variant_id: variantId, quantity },
      {},
      headers
    )
    .then(async () => {
      // 操作成功后,让相关缓存失效
      const cartCacheTag = await getCacheTag("carts")
      revalidateTag(cartCacheTag)               // Cart 数据缓存失效

      const fulfillmentCacheTag = await getCacheTag("fulfillment")
      revalidateTag(fulfillmentCacheTag)        // 履约数据缓存失效
    })
  // 注意:没有返回值
}

没有返回值。函数只负责两件事:调 API 写数据,然后让缓存失效。

数据流:

用户点击"加入购物车"
       ↓
Client Component 调用 addToCart()(Server Action)
       ↓
【在服务端执行】
getOrSetCart() — 确保 cart 存在,cart_id 存入 cookie
       ↓
sdk.store.cart.createLineItem() — 调 Medusa API
       ↓
revalidateTag("carts") — 让 cart 相关缓存失效
       ↓
Next.js 自动重新 fetch 所有标记了 "carts" tag 的数据
       ↓
Header 数字、Cart 页面列表,同时自动更新

cart_id 的持久化也值得注意——它存在 cookie 里,而不是 URL 参数:

// 环境:Next.js Server Action
// 场景:创建 cart 后持久化 cart_id

async function getOrSetCart(countryCode: string) {
  // 尝试读取已有的 cart
  let cart = await retrieveCart(undefined, "id,region_id")

  if (!cart) {
    // 创建新 cart
    const cartResp = await sdk.store.cart.create(
      { region_id: region.id },
      {},
      headers
    )
    cart = cartResp.cart

    // cart_id 写入 cookie(不是 URL)
    await setCartId(cart.id)

    // 同时让缓存失效,触发 UI 更新
    const cartCacheTag = await getCacheTag("carts")
    revalidateTag(cartCacheTag)
  }

  return cart
}

这个方案在做什么

状态的真正归属地在服务端。前端不持有 cart 数据,只持有一个 cart_id(存在 cookie 里)。

每次需要数据,就去 fetch——但 Next.js 会自动缓存这个 fetch 的结果,打上 tag。当数据变化时,revalidateTag 让这个 tag 失效,所有依赖这份数据的组件在下次渲染时自动重新 fetch。

组件之间不需要任何显式的"通知"机制,因为它们都从同一个源头取数据,源头失效了大家一起重取。

权衡

优势:

  • 无需手写状态同步逻辑,Next.js 自动处理
  • 服务端数据是真正的 single source of truth
  • 跨组件共享"零成本"——读同一个 cache tag 就够了

代价:

  • 需要理解和信任 Next.js 的缓存机制
  • 实时性强的数据(库存、限时价格)需要绕过缓存直接请求
  • 出了缓存问题比较难调试

三种解法的本质对比

解法一(Reducer + SessionStorage)解法二(微前端 + URL)解法三(Server Cache)
状态归属客户端内存不存在全局状态服务端缓存
cart_id 存在哪SessionStorageURL 参数Cookie
跨组件同步React Context + dispatch物理隔离,无需同步revalidateTag 自动触发
Header 谁更新订阅 context 自动更新CMS 负责,不在前端边界内和 cart 用同一份 cache
手写同步逻辑需要不需要(问题不存在)不需要(框架处理)
状态一致性风险客户端 vs 服务端可能偏差每次跳转重新 fetch,一致服务端是唯一来源,一致

怎么选?

我的理解是,这三种方案解决的不是同一个层次的问题:

解法二(微前端)适合大型组织,团队边界清晰,每个前端 app 由不同团队维护,宁愿牺牲一些全局体验,换取团队间的独立性。

解法一(Reducer + SessionStorage)适合中小型单体前端,需要快速响应、离线支持,或者还没有引入 Next.js App Router 等新范式的项目。

解法三(Server Cache)适合以 Next.js App Router 为核心的单体前端,服务端数据是可信来源,且对全局状态一致性要求高的场景。

没有绝对的优劣,选择背后是对应用边界、团队结构、实时性要求的权衡。


还没想清楚的地方

这三种方案里,AI 介入之后会发生什么?

addToCart 成功后触发 revalidateTag——如果这个时机要插入一个导购 Agent 的推荐逻辑,它应该在哪里?是 .then() 里同步执行,还是作为一个独立的事件监听,还是需要一个完全独立的"AI 介入层"?

在微前端架构里,AI 的判断结果算谁的状态?它跨越了应用边界,现有的通信机制能承载吗?

这些是我接下来想探索的问题,如果你有想法,欢迎交流。


参考资料