Skip to content

02|Pinia 的核心模型 ​

第五章概览 / 02。目标:跑通 createPinia → defineStore → useStore → storeToRefs,并知道每个 API 在数据流中的责任。

最小配置 ​

下面假设项目已经安装 pinia。Pinia 4 是 ESM-only;如果维护 CommonJS 工具链,应先处理构建兼容,而不是改 store 业务代码。

ts
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.mount('#app')

createPinia() 创建当前 Vue 应用的 store 容器。组件在 setup 中调用 useXxxStore() 时,Pinia 会从当前应用上下文找到它;测试和 SSR 必须为每个测试/请求创建自己的实例,不能共享模块级容器。

容器怎样决定“同一份状态” ​

defineStore('cart', setup) 返回的是取得store的函数,不是在模块导入时立即创建购物车。第一次对容器P调用useCartStore(P),Pinia按id创建并登记实例;下一次仍传P,就返回已登记的同一个实例。另一个容器Q有自己的登记表,因此相同id对应另一个购物车。

ts
const firstPinia = createPinia()
const secondPinia = createPinia()
const first = useCartStore(firstPinia)
console.log(first === useCartStore(firstPinia)) // true
console.log(first === useCartStore(secondPinia)) // false

这段在下方定义useCartStore后执行。组件跨页面共享来自“同一容器、同一id”;SSR隔离来自“不同请求、不同容器”。把模块级ref换成defineStore却继续共享一个服务端Pinia,仍然没有隔离。

用 Setup Store 表达一个领域 ​

ts
// src/stores/cart.ts
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export interface CartLine {
  productId: string
  unitPrice: number
  quantity: number
}

export const useCartStore = defineStore('cart', () => {
  const lines = ref<CartLine[]>([])
  const error = ref('')

  const itemCount = computed(() =>
    lines.value.reduce((sum, line) => sum + line.quantity, 0),
  )
  const total = computed(() =>
    lines.value.reduce((sum, line) => sum + line.unitPrice * line.quantity, 0),
  )

  function add(productId: string, unitPrice: number) {
    if (!productId || !Number.isFinite(unitPrice) || unitPrice < 0) {
      error.value = '商品参数无效'
      return false
    }

    const existing = lines.value.find(line => line.productId === productId)
    if (existing) existing.quantity += 1
    else lines.value.push({ productId, unitPrice, quantity: 1 })
    error.value = ''
    return true
  }

  function reset() {
    lines.value = []
    error.value = ''
  }

  // receipt 在发请求前保存行对象身份与当时数量;请求体另用普通快照。
  function acknowledgePurchased(receipt: { line: CartLine; quantity: number }[]) {
    for (const item of receipt) {
      const current = lines.value.find(line => line === item.line)
      if (current) current.quantity = Math.max(0, current.quantity - item.quantity)
    }
    lines.value = lines.value.filter(line => line.quantity > 0)
  }

  return { lines, error, itemCount, total, add, reset, acknowledgePurchased }
})

acknowledgePurchased()供第05篇结算调用:同一行从2本加到3本,已购买2本后留下1本;若用户清空再重新加入同商品,新行身份不同,不能扣它。这里收据只在本次内存操作内使用,不能序列化后恢复对象身份。

Setup Store 中:

  • ref 相当于 state,computed 相当于 getter,函数相当于 action。
  • 所有要被 Pinia 管理、SSR 序列化或开发工具检查的 state 都必须返回;“藏起来的 ref”会破坏这些机制。
  • total 是派生值,不另存一份。add 集中维护“同商品合并数量”的不变量。
  • Setup Store 没有自动 $reset(),因此显式提供 reset();Option Store 才能依据 state() 自动 $reset()。

组件怎样读取而不丢响应性 ​

vue
<!-- CartSummary.vue -->
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'

const cart = useCartStore()
const { lines, itemCount, total, error } = storeToRefs(cart)
const { add, reset } = cart
</script>

<template>
  <p>{{ itemCount }} 件,合计 ¥{{ total.toFixed(2) }}</p>
  <button @click="add('book-1', 59)">加入购物车</button>
  <button :disabled="lines.length === 0" @click="reset">清空</button>
  <p v-if="error" role="alert">{{ error }}</p>
</template>

直接写 const { total } = cart 只拿到当时的普通值,后续不会更新。storeToRefs() 只把 state、getter 和插件添加的响应式属性转成 ref;action 已经绑定到 store,可以直接解构。

如果模板只用少量属性,也可以保留 cart.total,完全不解构。

Option Store 是另一种等价入口 ​

ts
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  getters: {
    doubled: state => state.count * 2,
  },
  actions: {
    increment() {
      this.count += 1
    },
  },
})

Option Store 的 state 必须是返回新对象的函数。它的优势是结构固定、自动 $reset();Setup Store 更适合组合 composable、watcher 和复杂类型。团队应按领域一致选用,不需要把一种风格包装成另一种。

在组件外使用 store ​

在客户端文件顶层过早调用 useCartStore(),可能发生在 app.use(pinia) 之前。把调用放入函数/守卫回调,或显式传实例:

ts
const pinia = createPinia()
const cart = useCartStore(pinia)

SSR 中这不只是初始化顺序问题:必须使用当前请求的 pinia,否则用户 A 的状态可能泄露到用户 B。服务端渲染后的状态还要安全序列化,并在客户端创建 store 前完成 hydration。

失败路径与边界 ​

  • store id(如 'cart')在应用内必须唯一,它也是开发工具中的身份。
  • action 可以同步或异步并返回任意值;失败契约由 action 自己定义。对于业务拒绝,返回明确结果;对于网络异常,保留原始异常或转换成统一错误对象。
  • Pinia 允许直接修改 state,但跨字段不变量、网络操作和需要复用的变更应放进 action。
  • 只读需求不能靠返回 readonly(lines) 形成绝对权限边界;Pinia 的公开 store 仍可通过 $patch 改状态。真正的权限在服务端。

练习:库存 store ​

实现 useInventoryStore:state 为 stock: Record<string, number> 和 error: string;getter totalUnits;action reserve(productId, quantity)。

契约:quantity 必须是正整数;商品不存在或库存不足时返回 false、写入错误且不改变库存;成功时扣减并返回 true。组件使用 storeToRefs 展示总库存,并能调用自定义 reset()。

变化题:写一个测试初始化片段,证明两个新 createPinia() 实例不会共享库存。

参考答案:第 02 题。

本页结论 ​

Pinia 不是新的响应式系统,而是给共享状态增加领域身份、约定、开发工具、插件与 SSR 支持。store 的公开面仍应由最小 state、派生 getter 和有意图的 action 组成。

用心记录每一步成长