在做电商项目的时候,有一个看起来很小的问题:用户在商品页加了一件东西进购物车,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 存在哪 | SessionStorage | URL 参数 | 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 的判断结果算谁的状态?它跨越了应用边界,现有的通信机制能承载吗?
这些是我接下来想探索的问题,如果你有想法,欢迎交流。
参考资料
- Medusa Next.js Starter Storefront — 本文解法三的代码来源
- Next.js Data Fetching and Caching — Next.js 官方缓存机制文档
- Next.js revalidateTag — revalidateTag API 文档
- What we've learned from the transition to Next.js 14 with Server Components — Medusa 官方博客,迁移到 Server Components 的思考