05|跨 store 协作
第五章概览 / 05。目标:每个 store 只拥有自己的领域状态;协作发生在动作边界,不靠两个 store 互相写字段。
先画依赖方向
checkoutStore ──读取──> authStore
│
└──调用──> cartStore.acknowledgePurchased()authStore 拥有会话,cartStore 拥有购物车,checkoutStore 编排一次结算。下层 store 不需要反过来导入结算 store。依赖图保持单向,初始化和测试就更容易。
在 action 中按需取得其他 store
下面复用第02篇购物车的acknowledgePurchased();auth使用第08篇的user/clearSession接口,认证实现属于明确后续前置,先可传入{ user:{ id:'demo' } }观察编排。postOrder是调用方提供的异步订单接口,本例只讨论它成功或抛错后本地如何变化;不要把没有服务端的片段当成可付款功能。
// src/stores/checkout.ts
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
import { useCartStore } from './cart'
export const useCheckoutStore = defineStore('checkout', () => {
const status = ref<'idle' | 'submitting' | 'error'>('idle')
const error = ref('')
async function submit() {
if (status.value === 'submitting') return false
const auth = useAuthStore()
const cart = useCartStore()
if (!auth.user) {
error.value = '请先登录'
return false
}
if (cart.lines.length === 0) {
error.value = '购物车为空'
return false
}
status.value = 'submitting'
error.value = ''
const submittedUser = auth.user
const receipt = cart.lines.map(line => ({ line, quantity: line.quantity }))
const snapshot = cart.lines.map(line => ({ ...line }))
try {
await postOrder({ userId: submittedUser.id, lines: snapshot })
if (auth.user !== submittedUser) {
status.value = 'idle'
return false
}
cart.acknowledgePurchased(receipt)
status.value = 'idle'
error.value = ''
return true
} catch (cause) {
if (auth.user !== submittedUser) {
status.value = 'idle'
return false
}
status.value = 'error'
error.value = cause instanceof Error ? cause.message : '结算失败'
return false
}
}
return { status, error, submit }
})依赖在 submit() 执行时解析,而不是两个 store 的 setup 顶层互读。Pinia 允许 store 使用 store,但有一条关键规则:若两个 Setup Store 相互依赖,不能都在 setup 顶层立即读取对方状态,否则会形成初始化环。把读取放进 computed 或 action 只能推迟问题;若业务上仍双向控制,应该重新划分 owner 或引入上层协调者。
失败不应污染其他 owner
示例先复制购物车快照,再发请求;只有服务端确认成功才调用 cart.acknowledgePurchased(receipt),按原行身份扣除已提交数量,保留等待期间新加的商品与数量。请求失败时购物车保持原样,用户可以重试。这里没有“跨 store 事务”:如果服务端已成功而本地清理失败,下一次恢复应以服务端订单和幂等键为准,不能只靠撤销内存字段。
结算中第二次submit()立即返回false,避免发送第二张相同订单。收据保存原行身份,接口收到的则是值快照:A买2件后又加1件,成功留下A×1;清空再加A产生新行,成功不扣新行。变化实验再加入B,确认请求只有A而购物车保留B。服务端幂等键仍是跨刷新、断网重试时避免重复订单的必要条件。
这里还固定了提交时的会话对象。等待订单响应时,用户可能登出并切换账号;新会话必须得到新的 auth.user 对象。旧请求成功仅证明旧用户的订单已经创建,不能据此扣除新用户购物车或显示旧用户错误。成功和失败两条路径都比较会话身份,失效后只结束这次编排。此例禁止并发 submit,因此旧任务结束不会覆盖另一条 submit;若允许并发,还需要动作轮次。
按下面顺序手动推演,比只看最后的购物车更容易发现遗漏:
| 时刻 | 请求保存的内容 | 当前购物车与会话 | 成功后的动作 |
|---|---|---|---|
| 提交 A×2 | 旧行对象、数量 2、会话 S | A×2,S | 尚未扣减 |
| 等待时再加 A×1 | 快照仍为 A×2 | A×3,S | 按收据扣 2,留下 1 |
| 等待时清空再加入 A | 收据仍引用旧行 | 新行 A×1,S | 找不到旧行,不扣新行 |
| 等待时切换账号 | 请求仍属于 S | 新购物车,会话 T | 会话身份不同,不提交购物车动作 |
取消客户端等待也不等于撤销订单。请求已到服务器后,Abort 不能证明服务端停止处理;遇到“请求超时但可能已成功”,应凭幂等键查询订单结果,再决定补偿或重试。
对跨 store 动作写清三个合同:
| 合同 | 结算示例 |
|---|---|
| 前置条件 | 已登录、购物车非空 |
| 成功提交 | 服务端创建订单后扣除本次提交的数量,保留新增内容 |
| 失败收敛 | 保留购物车,status=error,允许重试 |
什么时候用 composable 协调
如果协作只服务某个页面生命周期,可以把编排放到 composable,store 只暴露领域动作:
export function useLogoutFlow() {
const auth = useAuthStore()
const cart = useCartStore()
const drafts = useDraftStore()
async function logout() {
await fetch('/api/logout', { method: 'POST', credentials: 'include' }).then(response => {
if (!response.ok) throw new Error(`HTTP ${response.status}`)
}).catch(() => undefined)
cart.reset()
drafts.clearPrivateDrafts()
auth.clearSession()
}
return { logout }
}这让“退出登录需要清哪些领域”集中可见,又不迫使 authStore 导入所有业务 store。若流程要在路由守卫、组件和服务层复用,再考虑专门的应用服务。
组件外与 SSR
在 router guard 或普通模块中,确保 store 调用发生在 Pinia 安装后:
router.beforeEach(to => {
const auth = useAuthStore(pinia)
if (to.meta.requiresAuth && !auth.user) return { name: 'login' }
})显式传 pinia 能指出 owner。SSR 必须传当前请求的 Pinia 实例;全局单例会跨用户共享状态。测试同样每例创建新实例,避免上一例残留。
常见失败路径
- A 顶层读 B,B 顶层又读 A:得到不完整值或无限初始化。
- 一个“万能 appStore”拥有所有字段:消除了 import 环,却失去领域边界。
- 用
watch(auth.userId)在多个组件各自清缓存:同一登录变化触发重复副作用。由一个应用级 owner 统一处理。 - action 直接修改另一个 store 的字段:技术上可能成功,但绕开对方不变量;调用公开 action。
- 并行初始化多个 store 后只报告最后一个错误:保留每个领域的失败状态,明确可用的降级功能。
练习:退出登录协调器
实现 useLogoutFlow(),协调 auth.clearSession()、cart.reset() 和 drafts.clearPrivateDrafts()。
契约:无论服务端登出请求成功还是失败,本地敏感状态都必须清空;服务端失败时返回 { ok: false, needsServerRetry: true },但不能恢复本地 session;连续调用两次结果应幂等。画出依赖方向,并写出三个 store 的最小假实现进行验证。
参考答案:第 05 题。
本页结论
跨 store 协作的核心不是“能否 import”,而是 owner、依赖方向和失败收敛。让上层流程调用下层领域动作,比 store 互相控制更可追踪。