02|Pinia 的核心模型
第五章概览 / 02。目标:跑通
createPinia → defineStore → useStore → storeToRefs,并知道每个 API 在数据流中的责任。
最小配置
下面假设项目已经安装 pinia。Pinia 4 是 ESM-only;如果维护 CommonJS 工具链,应先处理构建兼容,而不是改 store 业务代码。
// 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对应另一个购物车。
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 表达一个领域
// 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()。
组件怎样读取而不丢响应性
<!-- 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 是另一种等价入口
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) 之前。把调用放入函数/守卫回调,或显式传实例:
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 组成。