Skip to content

05|跨 store 协作 ​

第五章概览 / 05。目标:每个 store 只拥有自己的领域状态;协作发生在动作边界,不靠两个 store 互相写字段。

先画依赖方向 ​

text
checkoutStore ──读取──> authStore
      │
      └──调用──> cartStore.acknowledgePurchased()

authStore 拥有会话,cartStore 拥有购物车,checkoutStore 编排一次结算。下层 store 不需要反过来导入结算 store。依赖图保持单向,初始化和测试就更容易。

在 action 中按需取得其他 store ​

下面复用第02篇购物车的acknowledgePurchased();auth使用第08篇的user/clearSession接口,认证实现属于明确后续前置,先可传入{ user:{ id:'demo' } }观察编排。postOrder是调用方提供的异步订单接口,本例只讨论它成功或抛错后本地如何变化;不要把没有服务端的片段当成可付款功能。

ts
// 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、会话 SA×2,S尚未扣减
等待时再加 A×1快照仍为 A×2A×3,S按收据扣 2,留下 1
等待时清空再加入 A收据仍引用旧行新行 A×1,S找不到旧行,不扣新行
等待时切换账号请求仍属于 S新购物车,会话 T会话身份不同,不提交购物车动作

取消客户端等待也不等于撤销订单。请求已到服务器后,Abort 不能证明服务端停止处理;遇到“请求超时但可能已成功”,应凭幂等键查询订单结果,再决定补偿或重试。

对跨 store 动作写清三个合同:

合同结算示例
前置条件已登录、购物车非空
成功提交服务端创建订单后扣除本次提交的数量,保留新增内容
失败收敛保留购物车,status=error,允许重试

什么时候用 composable 协调 ​

如果协作只服务某个页面生命周期,可以把编排放到 composable,store 只暴露领域动作:

ts
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 安装后:

ts
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 互相控制更可追踪。

用心记录每一步成长