Pinia 状态管理与持久化 / Pinia State Management and Persistence
📅 创建时间:2026-07-28 🏷️ 标签:#Pinia #Vue #StateManagement #Store #Persist 📚 前置知识:[[02-reactivity-and-composition-api]]
📋 本章目标
- 理解 Pinia "简单优于抽象"的设计哲学,以及它为何替代 Vuex 成为 Vue 官方状态管理方案
- 掌握 Option Store 与 Setup Store 两种写法的完整语法、响应式原理和适用场景
- 熟练使用
defineStore、storeToRefs、$patch、$reset、$subscribe、$onAction等核心 API - 理解 Pinia 插件系统的工作机制,能编写自定义插件并正确集成持久化方案
- 掌握 Nuxt 中 Pinia 的 SSR 安全初始化、状态传递与 hydration 流程
- 能够从 Vuex 平滑迁移到 Pinia,理解概念映射和常见陷阱
- 识别并避免常见反模式:Store 粒度不当、服务端数据滥用、循环依赖等
第1部分:Pinia 设计哲学
1.1 "简单优于抽象"
Pinia 的设计出发点是:状态管理库应该是透明的,而不是魔术。与 Vuex 的 mutations/actions 严格分离不同,Pinia 将状态变更统一为 action,消除了"我该用 mutation 还是 action"的决策负担。
┌─────────────────────────────────────────────────────────────┐
│ Pinia 设计哲学:简单优于抽象 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 核心理念 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ State = 事实(数据本身) │ │
│ │ Getter = 派生事实(从 State 计算得出) │ │
│ │ Action = 改变事实的操作(同步/异步均可) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 设计取舍 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✗ 没有 mutations(Vuex 的概念包袱) │ │
│ │ ✗ 没有嵌套 modules(每个 Store 独立注册) │ │
│ │ ✗ 没有命名空间字符串(Store 即模块) │ │
│ │ ✓ 完整的 TypeScript 类型推断(无需额外类型声明) │ │
│ │ ✓ DevTools 时间旅行、HMR、插件系统 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键洞察:Pinia 不替你决定架构,它只提供最小的约束 │
│ 让你用 Vue 的 Composition API 组织状态,而非学习新概念 │
│ │
└─────────────────────────────────────────────────────────────┘1.2 为什么 Pinia 替代 Vuex
Vuex 4 虽然支持 Vue 3,但其设计仍受 Flux 模式的约束:必须通过 mutation 同步修改 state,action 只负责提交 mutation。这种分离在简单场景中增加了不必要的样板代码。Pinia 取消了这层约束:
- Vuex:
component → dispatch(action) → commit(mutation) → state - Pinia:
component → action → state
类型推断方面,Vuex 需要手动编写类型声明或使用复杂的包装。Pinia 的 defineStore 返回的 Store 实例自带完整类型推断,包括 state、getter 和 action 的参数与返回值。
1.3 模块化设计
Pinia 不区分"全局 store"和"模块 store"——每个 store 都是独立注册的顶层实体。不存在嵌套命名空间,避免了 Vuex 中 rootState.profile.user.name 式的深层访问路径。
// 每个 Store 独立定义,无需声明属于哪个 module
export const useCartStore = defineStore('cart', () => { /* ... */ })
export const useUserStore = defineStore('user', () => { /* ... */ })
export const useOrderStore = defineStore('order', () => { /* ... */ })这种扁平化设计的代价是:当 Store 数量增多时,需要团队自行约定命名和组织规范。收益是:每个 Store 的依赖关系一目了然,不需要通过命名空间字符串跳转。
1.4 与 Vue DevTools 的无缝集成
Pinia 从设计之初就考虑了 DevTools 体验:
- 每个 Store 在 DevTools 的时间线中独立显示
- Action 调用自动记录,支持时间旅行调试
- State 快照可直接在 DevTools 中编辑
- 支持 HMR(热模块替换),修改 Store 定义后状态保留
第2部分:Option Store vs Setup Store
2.1 两种写法概览
Pinia 提供两种定义 Store 的方式,它们共享相同的运行时能力,差异仅在语法风格。
┌─────────────────────────────────────────────────────────────┐
│ Option Store vs Setup Store │
├─────────────────────────────────────────────────────────────┤
│ │
│ Option Store Setup Store │
│ ┌─────────────────────────┐ ┌─────────────────────┐ │
│ │ defineStore('x', { │ │ defineStore('x', () │ │
│ │ state: () => ({...}) │ │ const s = ref(...) │ │
│ │ getters: {...} │ │ const g = computed │ │
│ │ actions: {...} │ │ function a() {...} │ │
│ │ }) │ │ return {s,g,a} │ │
│ │ │ │ }) │ │
│ └─────────────────────────┘ └─────────────────────┘ │
│ │
│ 相似点 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 同一个 Pinia 实例,同一套 API($patch,$reset 等) │ │
│ │ 都在 DevTools 中可见,都支持插件和 SSR │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键差异 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Option → 结构固定,Vuex 用户熟悉,迁移成本低 │ │
│ │ Setup → 自由组合,可直接使用 Composable 和 inject │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘2.2 Option Store 详解
Option Store 的结构与 Vue 组件 Options API 类似,适合从 Vuex 迁移的团队:
import { defineStore } from 'pinia'
interface Product {
id: string
name: string
price: number
quantity: number
}
export const useCartStore = defineStore('cart', {
// State:必须是一个返回初始状态的函数(避免 SSR 跨请求污染)
state: (): {
items: Product[]
discountCode: string | null
lastUpdated: number | null
} => ({
items: [],
discountCode: null,
lastUpdated: null,
}),
// Getter:接收 state 作为第一参数,可访问其他 getter 通过 this
getters: {
itemCount(state): number {
return state.items.reduce((sum, item) => sum + item.quantity, 0)
},
subtotal(state): number {
return state.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0,
)
},
// 使用 this 访问其他 getter(必须显式标注返回类型)
discountAmount(): number {
if (!this.discountCode) return 0
return this.subtotal * 0.1 // 假设统一 10% 折扣
},
total(): number {
return this.subtotal - this.discountAmount
},
// 返回函数的 getter:适合带参数的查询
findById(state) {
return (id: string): Product | undefined =>
state.items.find(item => item.id === id)
},
},
// Action:同步或异步均可,通过 this 访问 state 和 getter
actions: {
addItem(product: Omit<Product, 'quantity'>) {
const existing = this.items.find(item => item.id === product.id)
if (existing) {
existing.quantity += 1
} else {
this.items.push({ ...product, quantity: 1 })
}
this.lastUpdated = Date.now()
},
removeItem(productId: string) {
const index = this.items.findIndex(item => item.id === productId)
if (index !== -1) {
this.items.splice(index, 1)
this.lastUpdated = Date.now()
}
},
updateQuantity(productId: string, quantity: number) {
const item = this.items.find(item => item.id === productId)
if (!item) return
if (quantity <= 0) {
this.removeItem(productId)
return
}
item.quantity = quantity
this.lastUpdated = Date.now()
},
applyDiscount(code: string) {
// 实际项目中应调用后端验证折扣码
const validCodes = ['SAVE10', 'WELCOME']
if (validCodes.includes(code.toUpperCase())) {
this.discountCode = code.toUpperCase()
}
},
async checkout(): Promise<string> {
// 异步 action:调用 API,处理错误
const orderData = {
items: this.items.map(({ id, quantity }) => ({ id, quantity })),
discountCode: this.discountCode,
}
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orderData),
})
if (!response.ok) {
throw new Error(`Checkout failed: ${response.statusText}`)
}
const { orderId } = await response.json()
this.$reset() // 清空购物车
return orderId
},
},
})2.3 Setup Store 详解
Setup Store 使用 Composition API 风格,灵活性更高:
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
interface Product {
id: string
name: string
price: number
quantity: number
}
export const useCartStore = defineStore('cart', () => {
// ── State(ref / reactive) ──────────────────────────
const items = ref<Product[]>([])
const discountCode = ref<string | null>(null)
const lastUpdated = ref<number | null>(null)
// ── Getter(computed) ───────────────────────────────
const itemCount = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0),
)
const subtotal = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0),
)
const discountAmount = computed(() => {
if (!discountCode.value) return 0
return subtotal.value * 0.1
})
const total = computed(() => subtotal.value - discountAmount.value)
const findById = computed(() => {
return (id: string) => items.value.find(item => item.id === id)
})
// ── Action(普通函数) ───────────────────────────────
function addItem(product: Omit<Product, 'quantity'>) {
const existing = items.value.find(item => item.id === product.id)
if (existing) {
existing.quantity += 1
} else {
items.value.push({ ...product, quantity: 1 })
}
lastUpdated.value = Date.now()
}
function removeItem(productId: string) {
const index = items.value.findIndex(item => item.id === productId)
if (index !== -1) {
items.value.splice(index, 1)
lastUpdated.value = Date.now()
}
}
function updateQuantity(productId: string, quantity: number) {
const item = items.value.find(item => item.id === productId)
if (!item) return
if (quantity <= 0) {
removeItem(productId)
return
}
item.quantity = quantity
lastUpdated.value = Date.now()
}
async function checkout(): Promise<string> {
const orderData = {
items: items.value.map(({ id, quantity }) => ({ id, quantity })),
discountCode: discountCode.value,
}
const response = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orderData),
})
if (!response.ok) {
throw new Error(`Checkout failed: ${response.statusText}`)
}
const { orderId } = await response.json()
items.value = []
discountCode.value = null
return orderId
}
// ── 返回对外暴露的内容 ───────────────────────────────
return {
// State
items,
discountCode,
lastUpdated,
// Getter
itemCount,
subtotal,
discountAmount,
total,
findById,
// Action
addItem,
removeItem,
updateQuantity,
checkout,
}
})2.4 何时选择哪种风格
优先选择 Setup Store:
- 项目已全面使用 Composition API
- 需要在 Store 中使用
inject、生命周期钩子或 Composable - 需要灵活组合多个独立的响应式逻辑块
- 团队成员对
ref/computed更熟悉
Option Store 仍有价值:
- 从 Vuex 迁移,希望最小化差异
- 团队部分成员来自 Options API 背景
- Store 结构简单,state/getters/actions 三段的清晰分界本身就有文档价值
团队规范:选定一种风格后,所有 Store 应保持一致。混用两种风格会增加认知负担。
第3部分:Store 核心 API
3.1 storeToRefs:响应式解构
组件中直接解构 Store 会丢失响应式连接。Setup Store 的 ref 和 computed 在 Store 实例上不再是独立的 Ref 对象——它们被 Pinia 包装过。
┌─────────────────────────────────────────────────────────────┐
│ storeToRefs 解构规则 │
├─────────────────────────────────────────────────────────────┤
│ │
│ const cart = useCartStore() │
│ │
│ ✗ 直接解构 → 失去响应式 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ const { items, total } = cart │ │
│ │ // items 和 total 现在是静态快照,不再响应式更新 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ✓ storeToRefs → 保持响应式 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ const { items, total } = storeToRefs(cart) │ │
│ │ // items 和 total 保持 Ref 连接 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ✓ Action 直接解构(不是 Ref,不需要 storeToRefs) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ const { addItem, checkout } = cart │ │
│ │ // Action 是普通函数,解构后正常调用 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 最佳实践 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ const { items, total } = storeToRefs(cart) │ │
│ │ const { addItem, checkout } = cart │ │
│ │ // 一行拆 State/Getter,一行拆 Action │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘使用示例:
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCartStore } from '@/stores/cart'
const cart = useCartStore()
// State 和 Getter 用 storeToRefs 保持响应式
const { items, itemCount, total, discountCode } = storeToRefs(cart)
// Action 直接从 Store 解构
const { addItem, removeItem, updateQuantity, checkout } = cart
</script>
<template>
<div>
<p>共 {{ itemCount }} 件商品,合计 ¥{{ total }}</p>
<div v-for="item in items" :key="item.id">
<span>{{ item.name }}</span>
<button @click="removeItem(item.id)">删除</button>
</div>
</div>
</template>3.2 $patch:批量更新
当需要同时修改多个 State 字段时,$patch 比逐个赋值更高效(只触发一次 DevTools 记录):
const cart = useCartStore()
// 方式一:传入部分 State 对象
cart.$patch({
discountCode: 'SAVE10',
lastUpdated: Date.now(),
})
// 方式二:传入函数(适合复杂批量更新)
cart.$patch(state => {
// 批量更新数组中的多个项
for (const item of state.items) {
if (item.price > 100) {
item.quantity = Math.min(item.quantity, 1)
}
}
state.lastUpdated = Date.now()
})$patch 的主要优势:
- 原子性:多个变更合并为一次 DevTools 条目,方便时间旅行调试
- 批量操作:函数式
$patch适合复杂的数组/集合批量修改 - 插件通知:插件只收到一次 state 变更通知
3.3 $reset:重置状态
将整个 Store 重置为初始状态:
// Option Store:$reset 直接可用
const cart = useCartStore()
cart.$reset() // state 回到 defineStore 中 state() 的初始返回值
// Setup Store:需要自定义 $reset 实现
export const useCartStore = defineStore('cart', () => {
const items = ref<Product[]>([])
const discountCode = ref<string | null>(null)
// Setup Store 中 Pinia 无法自动推断"初始状态"
// 需要手动提供 $reset 逻辑
function $reset() {
items.value = []
discountCode.value = null
}
return { items, discountCode, $reset }
})Option Store 的 $reset 是内置的,因为 state() 函数为 Pinia 提供了初始状态快照。Setup Store 中 ref() 的初始值在运行时对 Pinia 不透明,需要显式定义重置逻辑。
3.4 $subscribe:订阅变化
$subscribe 在 State 发生变化后触发,适合持久化、同步到外部系统等场景:
const cart = useCartStore()
// 订阅整个 Store 的 State 变化
const unsubscribe = cart.$subscribe(
(mutation, state) => {
// mutation.type: 'direct' | 'patch object' | 'patch function'
console.log(`[cart] ${mutation.type} 变更:`)
console.log(' storeId:', mutation.storeId)
console.log(' 新状态:', state)
// 持久化到 localStorage(示例,实际应使用插件)
localStorage.setItem('cart', JSON.stringify(state.items))
},
{
detached: false, // 默认 false:组件卸载时自动取消订阅
// detached: true → 需要手动调用 unsubscribe()
},
)
// 取消订阅(仅在 detached: true 时需要)
// unsubscribe()┌─────────────────────────────────────────────────────────────┐
│ $subscribe 的 mutation 对象 │
├─────────────────────────────────────────────────────────────┤
│ │
│ { │
│ storeId: 'cart', // 发生变更的 Store ID │
│ type: 'direct', // 变更类型 │
│ // 'direct' → 直接赋值 items.value = [...] │
│ // 'patch object' → $patch({ discountCode: ... }) │
│ // 'patch function'→ $patch(state => { ... }) │
│ events: DebuggerEvent[], // 底层响应式事件(调试用) │
│ payload: any, // $patch 传入的数据 │
│ } │
│ │
│ 注意:$subscribe 在变更后触发,不能阻止变更 │
│ 如需在变更前拦截,使用 $onAction │
│ │
└─────────────────────────────────────────────────────────────┘3.5 $onAction:拦截 Action
$onAction 在 Action 执行前后触发,适合日志、错误追踪和性能监控:
const cart = useCartStore()
const unsubscribe = cart.$onAction(
({ name, store, args, after, onError }) => {
const startTime = performance.now()
console.log(`[action] ${name} 开始`, { args })
// after:Action 成功完成后回调
after(result => {
const duration = performance.now() - startTime
console.log(`[action] ${name} 完成 (${duration.toFixed(1)}ms)`, {
result,
})
})
// onError:Action 抛出错误时回调
onError(error => {
const duration = performance.now() - startTime
console.error(`[action] ${name} 失败 (${duration.toFixed(1)}ms)`, {
error,
})
// 可在此上报错误到监控系统
})
},
// 第二个参数:仅拦截特定 action(可选)
// { name: 'checkout' } 只拦截名为 checkout 的 action
)
// 取消拦截
// unsubscribe()3.6 $state:访问原始状态
// 读取整个 State 的普通对象(非响应式)
const plainState = cart.$state
// 替换整个 State(会触发所有订阅者)
cart.$state = {
items: [],
discountCode: null,
lastUpdated: Date.now(),
}第4部分:插件机制
4.1 Pinia 插件系统架构
Pinia 的插件系统在 Store 创建时介入,可以对每个 Store 进行统一增强。
┌─────────────────────────────────────────────────────────────┐
│ Pinia 插件生命周期 │
├─────────────────────────────────────────────────────────────┤
│ │
│ createPinia() │
│ │ │
│ ▼ │
│ pinia.use(plugin) ← 注册插件(可链式调用多次) │
│ │ │
│ ▼ │
│ app.use(pinia) ← 安装到 Vue 应用 │
│ │ │
│ ▼ │
│ 首次调用 useXxxStore() ← 创建 Store 实例 │
│ │ │
│ ▼ │
│ plugin({ app, options, pinia, store }) ← 插件回调触发 │
│ │ │
│ ├── store.$subscribe() ← 订阅 State 变化 │
│ ├── store.$onAction() ← 拦截 Action │
│ └── 注入属性到 store ← store.myProp = ... │
│ │
│ 之后的每次 $subscribe / $onAction 回调在变更时触发 │
│ │
└─────────────────────────────────────────────────────────────┘4.2 自定义插件示例
日志插件
// plugins/pinia-logger.ts
import type { PiniaPlugin } from 'pinia'
export const piniaLoggerPlugin: PiniaPlugin = ({ store }) => {
// 订阅 State 变化
store.$subscribe((mutation, state) => {
console.group(`[${store.$id}] ${mutation.type}`)
console.log('payload:', mutation.payload)
console.log('newState:', JSON.parse(JSON.stringify(state)))
console.groupEnd()
})
// 拦截 Action
store.$onAction(({ name, args }) => {
console.log(
`%c[${store.$id}] action:${name}`,
'color: #4fc08d; font-weight: bold',
args.length > 0 ? args : '',
)
})
}错误追踪插件
// plugins/pinia-error-tracker.ts
import type { PiniaPlugin } from 'pinia'
interface ErrorReporter {
captureException: (error: Error, context?: Record<string, unknown>) => void
}
export function createPiniaErrorTracker(reporter: ErrorReporter): PiniaPlugin {
return ({ store }) => {
// 为每个 Store 注入错误上报方法
store.$onAction(({ name, store, args, onError }) => {
onError((error: unknown) => {
reporter.captureException(
error instanceof Error ? error : new Error(String(error)),
{
storeId: store.$id,
actionName: name,
actionArgs: JSON.stringify(args),
storeState: JSON.stringify(store.$state),
},
)
})
})
}
}增强类型声明
当插件向 Store 注入属性时,需要扩展 Pinia 类型:
// types/pinia.d.ts
import 'pinia'
declare module 'pinia' {
export interface PiniaCustomProperties {
// 插件注入的属性
$router: Router
}
}4.3 持久化插件 (pinia-plugin-persistedstate)
┌─────────────────────────────────────────────────────────────┐
│ pinia-plugin-persistedstate 架构 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 安装 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ import { createPersistedState } from '...' │ │
│ │ pinia.use(createPersistedState({ │ │
│ │ storage: localStorage, // 默认 │ │
│ │ key: id => `pinia-${id}`, │ │
│ │ })) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Store 级别配置 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ defineStore('cart', () => {...}, { │ │
│ │ persist: { │ │
│ │ key: 'my-cart', │ │
│ │ storage: sessionStorage, │ │
│ │ paths: ['items'], // 仅持久化指定字段 │ │
│ │ beforeRestore: (ctx) => {...}, │ │
│ │ afterRestore: (ctx) => {...}, │ │
│ │ serializer: { │ │
│ │ serialize: JSON.stringify, │ │
│ │ deserialize: JSON.parse, │ │
│ │ }, │ │
│ │ }, │ │
│ │ }) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 工作流程 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Store 创建 → 从 storage 读取 → deserialize │ │
│ │ → 合并到 State → beforeRestore → afterRestore │ │
│ │ State 变更 → $subscribe → serialize → 写入 storage │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘持久化的 Schema 版本管理:
// 不要直接持久化整个 Store,建立显式 Schema
interface PersistedCartV3 {
version: 3
items: Array<{ id: string; quantity: number }>
savedAt: number
}
// 迁移函数:处理旧版本数据
function migrateCart(stored: unknown): PersistedCartV3 {
const raw = stored as Record<string, unknown>
// V1 → V2:price 字段不再持久化(从服务端获取)
if (!raw || !raw.version || raw.version < 2) {
const oldItems = (raw?.items as any[]) ?? []
return {
version: 3,
items: oldItems.map((i: any) => ({ id: i.id, quantity: i.quantity })),
savedAt: Date.now(),
}
}
// V2 → V3:增加 savedAt 字段
if (raw.version === 2) {
return {
version: 3,
items: raw.items as any[],
savedAt: Date.now(),
}
}
return raw as PersistedCartV3
}持久化关键原则:
- 价格、库存等服务端事实不应信任本地缓存,结算时从服务端重新读取
- 用户切换或退出时必须清理持久化数据
- 多标签页场景需考虑冲突策略(BroadcastChannel 同步或 "最后写入胜出")
- 存储配额超限和 JSON 解析失败必须有降级方案
- 敏感字段(token、个人信息)应排除在持久化之外
第5部分:SSR 与 Nuxt 集成
5.1 SSR 安全核心原则
┌─────────────────────────────────────────────────────────────┐
│ Pinia SSR 状态传递流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 服务端(每个请求独立) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 创建请求级 Pinia 实例(不能是模块级单例) │ │
│ │ 2. 组件在 SSR 中使用 useXxxStore() 读写 State │ │
│ │ 3. 渲染完成后,pinia.state.value 包含所有 Store 快照│ │
│ │ 4. 序列化为 JSON,嵌入 HTML(注意 XSS 防护) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ HTTP Response (HTML + 初始状态) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ 客户端(浏览器) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 5. 从 HTML 中提取序列化的 State │ │
│ │ 6. 创建客户端 Pinia 实例 │ │
│ │ 7. 调用 pinia.state.value = window.__PINIA_STATE__ │ │
│ │ 8. Vue hydration:客户端 Store 状态 = 服务端快照 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 关键约束:服务端和客户端 Store State 必须一致 │
│ 不一致会导致 hydration mismatch 警告或 UI 闪烁 │
│ │
└─────────────────────────────────────────────────────────────┘5.2 Nuxt 中的 Pinia 初始化
Nuxt 通过 @pinia/nuxt 模块提供开箱即用的 Pinia 集成:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@pinia/nuxt'],
pinia: {
// 可选:自定义 Pinia 配置
storesDirs: ['./stores/**'], // Store 文件自动导入
},
})Nuxt 自动处理了 SSR 的关键细节:
// plugins/pinia-init.ts (Nuxt 插件,可选)
export default defineNuxtPlugin((nuxtApp) => {
// nuxtApp.$pinia 是当前请求的 Pinia 实例
// 每个 SSR 请求自动创建独立实例,无需手动管理
// 安装 Pinia 插件
nuxtApp.$pinia.use(myPlugin)
})5.3 Store 定义(Nuxt 风格)
// stores/cart.ts
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
// 仅在客户端可用的 API(localStorage 等在 SSR 中不存在)
const isClient = import.meta.client
if (isClient) {
// 从 localStorage 恢复(SSR 时跳过)
const stored = localStorage.getItem('cart')
if (stored) {
try {
items.value = JSON.parse(stored)
} catch {
localStorage.removeItem('cart')
}
}
}
function addItem(item: CartItem) {
items.value.push(item)
if (isClient) {
localStorage.setItem('cart', JSON.stringify(items.value))
}
}
return { items, addItem }
})5.4 SSR 状态传递与 Hydration
// Nuxt 自动处理 Payload extraction
// 在 app.vue 或页面组件中:
<script setup lang="ts">
const cart = useCartStore()
// 在服务端获取数据并填入 Store
if (import.meta.server) {
// 仅在服务端执行:从数据库获取用户购物车
const savedCart = await fetchUserCartFromDB(event)
if (savedCart) {
cart.$patch({ items: savedCart.items })
}
}
// 客户端 hydration 时,Store 已包含服务端写入的数据
// 无需额外代码 —— Nuxt 的 Payload 机制自动传递
</script>Hydration 注意事项:
- 不要在
onMounted中无条件重置 Store 数据,这会覆盖 SSR 传递的状态 - 如果客户端必须重新获取数据,先比较服务端数据是否仍然有效
- 使用
useAsyncData/useFetch获取服务端数据时,不要同时复制到 Pinia(除非该数据确实需要跨组件共享且需要客户端更新)
5.5 服务端数据 vs 客户端状态
┌─────────────────────────────────────────────────────────────┐
│ Nuxt 数据层与 Pinia 的职责边界 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 数据来源 归属 推荐方案 │
│ ────────────────────────────────────────────────────────── │
│ 服务端页面数据 服务端 useAsyncData / useFetch │
│ (文章内容、 (SSR) 数据由 Nuxt Payload 自动传递 │
│ 商品列表等) 无需手动存入 Pinia │
│ │
│ 共享的客户端状态 客户端 Pinia │
│ (购物车、草稿、 (Browser) 不经过 SSR Payload, │
│ 用户偏好等) 由客户端直接管理 │
│ │
│ 会话信息 混合 Pinia + SSR Payload │
│ (当前用户信息、 服务端验证 服务端注入初始值, │
│ 权限列表等) 客户端更新 客户端可修改部分字段 │
│ │
│ 关键规则 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 不要用 Pinia 替代服务端数据缓存 │ │
│ │ useAsyncData 的数据不要复制进 Pinia │ │
│ │ Pinia 管理的状态应是"客户端拥有的真实状态" │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘第6部分:Pinia vs Vuex 迁移
6.1 概念对照
┌─────────────────────────────────────────────────────────────┐
│ Vuex → Pinia 概念映射 │
├─────────────────────────────────────────────────────────────┤
│ │
│ Vuex 4 Pinia │
│ ───────────────────────── ────────────────────────── │
│ store (单一 Store 树) 多个独立 Store │
│ state state (ref / state()) │
│ getters getters (computed / getters) │
│ mutations ✗ 不存在,合并到 actions │
│ actions (只能异步触发 actions (同步/异步均可) │
│ mutation) │
│ modules (嵌套) ✗ 不存在,每个 Store 独立 │
│ namespaced: true ✗ Store ID 即命名空间 │
│ dispatch('module/action') store.action() 直接调用 │
│ commit('module/mutation') store.$patch() 或直接赋值 │
│ context (复杂上下文对象) this / 直接访问 ref │
│ mapState / mapGetters storeToRefs │
│ mapActions store 直接解构 │
│ │
└─────────────────────────────────────────────────────────────┘6.2 迁移示例
Vuex Module(迁移前):
// store/modules/products.ts
export const productsModule = {
namespaced: true,
state: () => ({
items: [] as Product[],
loading: false,
error: null as string | null,
}),
getters: {
availableProducts(state) {
return state.items.filter(p => p.stock > 0)
},
totalValue(state) {
return state.items.reduce((sum, p) => sum + p.price * p.stock, 0)
},
},
mutations: {
SET_ITEMS(state, items: Product[]) {
state.items = items
},
SET_LOADING(state, loading: boolean) {
state.loading = loading
},
SET_ERROR(state, error: string | null) {
state.error = error
},
ADD_ITEM(state, product: Product) {
state.items.push(product)
},
},
actions: {
async fetchProducts({ commit }) {
commit('SET_LOADING', true)
commit('SET_ERROR', null)
try {
const items = await productsApi.getAll()
commit('SET_ITEMS', items)
} catch (e) {
commit('SET_ERROR', (e as Error).message)
} finally {
commit('SET_LOADING', false)
}
},
async addProduct({ commit }, product: Omit<Product, 'id'>) {
const created = await productsApi.create(product)
commit('ADD_ITEM', created)
},
},
}Pinia Store(迁移后):
// stores/products.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { productsApi } from '@/api/products'
import type { Product } from '@/types'
export const useProductsStore = defineStore('products', () => {
const items = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const availableProducts = computed(() =>
items.value.filter(p => p.stock > 0),
)
const totalValue = computed(() =>
items.value.reduce((sum, p) => sum + p.price * p.stock, 0),
)
async function fetchProducts() {
loading.value = true
error.value = null
try {
items.value = await productsApi.getAll()
} catch (e) {
error.value = (e as Error).message
} finally {
loading.value = false
}
}
async function addProduct(product: Omit<Product, 'id'>) {
const created = await productsApi.create(product)
items.value.push(created)
}
return {
items,
loading,
error,
availableProducts,
totalValue,
fetchProducts,
addProduct,
}
})6.3 迁移策略
渐进迁移: Vuex 和 Pinia 可以在同一项目中并存。app.use(pinia) 不会干扰已有的 Vuex Store。可以逐个模块迁移:
- 新建 Pinia Store,复制 Vuex Module 的逻辑
- 逐个组件替换
useStore()为新的 Pinia Store - 确认无回归后移除旧的 Vuex Module
- 全部迁移完成后移除 Vuex 依赖
迁移中的常见问题:
dispatch的返回值在 Vuex 中是 Promise(如果 action 返回 Promise),在 Pinia 中直接是 action 的返回值- Vuex 的
watch通过store.watch(),Pinia 用store.$subscribe() - Vuex 的
subscribeAction对应 Pinia 的store.$onAction()
第7部分:常见模式与反模式
7.1 不要用 Pinia 缓存服务端数据
┌─────────────────────────────────────────────────────────────┐
│ 反模式:Pinia 作为服务端数据缓存 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✗ 错误做法(Nuxt 中) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ // stores/articles.ts │ │
│ │ const articles = ref<Article[]>([]) │ │
│ │ async function load() { │ │
│ │ articles.value = await $fetch('/api/articles') │ │
│ │ } │ │
│ │ // 问题:缓存失效、去重、后台刷新都要手写 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ✓ 正确做法 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ // pages/articles.vue │ │
│ │ const { data, refresh } = await useAsyncData( │ │
│ │ 'articles', │ │
│ │ () => $fetch('/api/articles'), │ │
│ │ ) │ │
│ │ // Nuxt 管理缓存键、SSR 传递、自动去重 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Pinia 适合管理的客户端状态: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✓ 购物车内容(用户交互产生的状态) │ │
│ │ ✓ 草稿编辑器内容(未提交的表单数据) │ │
│ │ ✓ 用户偏好/主题(跨组件共享的客户端设置) │ │
│ │ ✓ 多步向导中间状态(跨页面的流程状态) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘7.2 Store 拆分粒度
拆分原则:
- 一个 Store 对应一个领域聚合(购物车、用户、订单),而非一个页面
- 如果两个 State 字段总是一起变化,它们应属于同一个 Store
- 如果两个 State 字段的生命周期不同(如用户信息 vs 临时搜索词),它们应分开
拆分过细的信号:
- Store 之间频繁通过 Action 互相调用,形成调用链
- 一个组件需要引入 5 个以上的 Store
- 跨 Store 的一致性需要手动维护(A 的变更后必须更新 B)
合并过大的信号:
- Store 的
$subscribe中写满各种无关逻辑 - State 中有大量字段在 80% 的场景中不使用
- 修改一个字段需要理解整个 Store 的几百行代码
7.3 组合 Store(Store 使用 Store)
// stores/checkout.ts
import { defineStore } from 'pinia'
import { useCartStore } from './cart'
import { useUserStore } from './user'
export const useCheckoutStore = defineStore('checkout', () => {
// ✓ 在 Setup 函数体内调用其他 Store
const cart = useCartStore()
const user = useUserStore()
const submitting = ref(false)
// ✗ 不要在顶层直接调用 useXxxStore() —— 会导致初始化顺序问题
// const cart = useCartStore() ← 放在 defineStore 回调内部
// Getter 可以组合其他 Store 的 Getter
const canCheckout = computed(() => {
return cart.itemCount > 0
&& user.isLoggedIn
&& !submitting.value
})
async function submitOrder() {
if (!canCheckout.value) return
submitting.value = true
try {
const order = {
items: cart.items,
userId: user.profile.id,
addressId: user.defaultAddressId,
}
const result = await orderApi.create(order)
cart.$reset()
return result
} finally {
submitting.value = false
}
}
return { submitting, canCheckout, submitOrder }
})7.4 避免循环依赖
┌─────────────────────────────────────────────────────────────┐
│ Store 循环依赖问题 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✗ 循环依赖(危险) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ userStore ──────需要──────→ permissionStore │ │
│ │ ↑ │ │ │
│ │ └──────────需要─────────────┘ │ │
│ │ │ │
│ │ 问题:初始化时互相引用,可能导致 undefined │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 解决方案 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ 方案一:抽取共享到第三个 Store │ │
│ │ ┌──────────┐ │ │
│ │ │ authStore │ (token, role) │ │
│ │ └────┬─────┘ │ │
│ │ │ 依赖 │ │
│ │ ┌────┴─────┐ ┌──────────────┐ │ │
│ │ │userStore │ │permissionStore│ │ │
│ │ └──────────┘ └──────────────┘ │ │
│ │ │ │
│ │ 方案二:在 Action 内部才读取对方 Store │ │
│ │ function checkPermission(resource: string) { │ │
│ │ const user = useUserStore() // 延迟调用 │ │
│ │ return user.role === 'admin' │ │
│ │ } │ │
│ │ │ │
│ │ 方案三:将协调逻辑提升到 Composable │ │
│ │ export function useAuthGuard() { │ │
│ │ const user = useUserStore() │ │
│ │ const perm = usePermissionStore() │ │
│ │ // 在 Composable 中协调,而非 Store 之间 │ │
│ │ } │ │
│ │ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘7.5 其他常见反模式
反模式一:滥用 Store 替代组件 Props
// ✗ 不必要:将组件展开状态放入 Store,即使只有一个组件用
export const useModalStore = defineStore('modal', () => {
const isOpen = ref(false)
function open() { isOpen.value = true }
function close() { isOpen.value = false }
return { isOpen, open, close }
})
// ✓ 组件内部 ref 足够,除非需要跨组件协调反模式二:Action 演化成手写 Query Cache
如果 Action 中出现了缓存过期、后台重取、乐观更新、请求去重等逻辑,停下来 —— 这不是 Pinia 的职责。使用 TanStack Query (Vue Query) 或 Nuxt 的 useAsyncData。
反模式三:持久化敏感数据
// ✗ 危险:Token、密码等敏感信息绝不应持久化到 localStorage
persist: {
paths: ['token', 'refreshToken', 'creditCard'], // 不要这样做
}
// ✓ 使用 httpOnly cookie 存储敏感数据,仅持久化非敏感偏好
persist: {
paths: ['theme', 'language', 'sidebarCollapsed'],
}核心总结
┌─────────────────────────────────────────────────────────────┐
│ Pinia 知识体系总结 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 设计哲学 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 简单优于抽象:State = 事实,Getter = 派生, │ │
│ │ Action = 改变事实(同步/异步统一处理) │ │
│ │ 无 mutations、无嵌套 modules、完整 TS 类型推断 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 两种 Store 写法 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Option Store:state/getters/actions 三段式, │ │
│ │ 适合 Vuex 迁移和结构简单的场景 │ │
│ │ Setup Store:Composition API 风格, │ │
│ │ 更灵活,支持 inject、Composable、生命周期 │ │
│ │ 团队应统一一种风格 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 核心 API │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ storeToRefs → 响应式解构 State/Getter │ │
│ │ $patch → 批量更新(原子 DevTools 记录) │ │
│ │ $reset → 重置为初始状态 │ │
│ │ $subscribe → State 变更后回调(持久化、同步) │ │
│ │ $onAction → Action 拦截(日志、错误追踪) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 插件与持久化 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 插件在 Store 创建时注入,可添加属性、订阅变更 │ │
│ │ 持久化需要 Schema 版本管理、迁移、降级策略 │ │
│ │ 敏感字段排除、多标签冲突处理、SSR 安全 │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ SSR / Nuxt │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 每个请求独立 Pinia 实例(不能是模块级单例) │ │
│ │ Nuxt Payload 自动传递 Store 状态到客户端 │ │
│ │ 服务端数据用 useAsyncData,客户端状态用 Pinia │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 常见反模式 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ✗ 用 Pinia 代替服务端数据缓存 │ │
│ │ ✗ 循环依赖 Store │ │
│ │ ✗ 持久化整个 Store(包括敏感/服务端字段) │ │
│ │ ✗ Action 演变成手写 Query Cache │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘章节测试
一、选择题(每题 4 分,共 20 分)
关于 Pinia 的设计哲学,以下哪项描述是正确的? A. Pinia 保留了 Vuex 的 mutations 概念,确保状态变更可追踪 B. Pinia 通过嵌套 modules 来组织状态,与 Vuex 保持一致 C. Pinia 取消了 mutations,将状态变更统一为 action D. Pinia 要求所有 action 必须是异步的
在组件中解构 Pinia Store 时,以下哪个做法是正确的? A.
const { items, total } = useCartStore()——直接解构即可 B.const { items, total } = storeToRefs(useCartStore())——State/Getter 用 storeToRefs C.const { addItem } = storeToRefs(useCartStore())——Action 也需要 storeToRefs D.const cart = useCartStore(); const items = ref(cart.items)——手动包装 ref关于 Setup Store 的
$reset,以下说法正确的是? A. Setup Store 的$reset是内置的,无需额外配置 B. Setup Store 不能使用$resetC. Setup Store 可以定义自己的$reset函数来重置状态 D. 只有 Option Store 才有 Setup Store 的$reset功能Nuxt 中使用 Pinia 时,以下哪个做法是错误的? A. 将购物车状态存入 Pinia 并在客户端持久化 B. 用 Pinia 存储
useAsyncData返回的服务端数据以便跨组件访问 C. 在 Nuxt 插件中安装 Pinia 自定义插件 D. 在 Pinia Store 中使用import.meta.client区分 SSR 和客户端Store A 和 Store B 互相调用对方的 Action,可能导致什么问题? A. 性能下降,因为每次 Action 调用都触发两次 DevTools 记录 B. 初始化时循环依赖导致某个 Store 为 undefined C. TypeScript 类型推断失败 D. 不会产生任何问题,这是 Pinia 推荐的设计模式
二、简答题(每题 10 分,共 30 分)
简述
$subscribe和$onAction的区别,以及各自的典型使用场景。解释为什么在 SSR 中每个请求必须拥有独立的 Pinia 实例,以及 Nuxt 是如何自动处理这一点的。
列出至少三种不适合使用 Pinia 的场景,并说明每种场景的推荐替代方案。
三、实操题(每题 25 分,共 50 分)
设计一个带持久化的用户偏好 Store(
usePreferencesStore),要求:- 包含
theme('light' | 'dark' | 'auto')、language(string)、sidebarCollapsed(boolean) - 持久化到 localStorage,排除版本号等元数据字段
- 包含 Schema 版本管理和从旧格式迁移的逻辑(V1 没有
sidebarCollapsed字段) - 写一个 Nuxt 插件在用户切换时清理旧的偏好数据
- 包含
将以下 Vuex Module 迁移为 Pinia Setup Store(
useNotificationsStore):
// Vuex Module
const notificationsModule = {
namespaced: true,
state: () => ({
items: [] as Notification[],
unreadCount: 0,
}),
getters: {
unreadNotifications: (state) => state.items.filter(n => !n.read),
},
mutations: {
ADD_NOTIFICATION(state, notification: Notification) {
state.items.unshift(notification)
state.unreadCount += 1
},
MARK_READ(state, id: string) {
const n = state.items.find(item => item.id === id)
if (n && !n.read) {
n.read = true
state.unreadCount -= 1
}
},
MARK_ALL_READ(state) {
state.items.forEach(n => { n.read = true })
state.unreadCount = 0
},
CLEAR(state) {
state.items = []
state.unreadCount = 0
},
},
actions: {
addNotification({ commit }, notification: Notification) {
commit('ADD_NOTIFICATION', notification)
if (notification.persistent) {
// 假设有持久化逻辑
}
},
async fetchNotifications({ commit }) {
const items = await notificationApi.getAll()
commit('CLEAR')
items.forEach((item: Notification) => commit('ADD_NOTIFICATION', item))
},
},
}参考答案
一、选择题
C — Pinia 取消了 mutations,State 的变更统一通过 action(或直接赋值/$patch)完成。
B — State 和 Getter 通过
storeToRefs解构保持响应式;Action 是普通函数,直接从 Store 解构。C — Setup Store 需要手动实现
$reset逻辑,因为 Pinia 无法自动推断 ref 的初始状态。Option Store 的$reset是内置的。B —
useAsyncData的数据由 Nuxt 的 Payload 机制管理,不应复制到 Pinia。这将导致两个真相源(Nuxt 缓存 + Pinia State),引发一致性问题。B — 循环依赖可能导致初始化时某个 Store 尚未创建就被访问,得到 undefined。应通过抽取共享 Store、在 Action 内部延迟调用或使用 Composable 协调来避免。
二、简答题
区别与场景:
$subscribe:State 变更后触发,接收 mutation 对象和新状态。典型场景:持久化 State 到 localStorage、同步状态到 IndexedDB、发送状态变更到 Web Worker。$onAction:Action 调用时触发(可注册 after/onError 回调)。典型场景:Action 性能监控(计时)、错误上报、调用前权限检查日志。
SSR 实例隔离:
- 多个并发请求共享同一个 Pinia 实例会导致请求 A 的 State 泄漏到请求 B 的响应中。
- Nuxt 通过
@pinia/nuxt模块自动为每个请求创建独立的 Pinia 实例。 - 服务端渲染完成后,
pinia.state.value被序列化嵌入 HTML(Nuxt Payload)。 - 客户端 hydration 时,从 Payload 恢复状态到客户端 Pinia 实例。
不适合 Pinia 的场景:
- 服务端数据列表:应用
useAsyncData/useFetch,Nuxt 管理缓存和 SSR 传递。 - 单组件 UI 状态(如展开/折叠):组件内的
ref即可,放入 Store 反而增加复杂度。 - URL 驱动的筛选/分页:写入
route.query,支持浏览器前进后退和分享,无需在 Pinia 中维护副本。
- 服务端数据列表:应用
三、实操题(要点)
略 — 详情见本文档各部分示例代码,重点考核 Store 定义、持久化 Schema 设计和 Vuex 迁移能力。
相关笔记
- [[02-reactivity-and-composition-api]] — ref、computed、watch 等响应式基础
- [[03-router-forms-and-component-architecture]] — URL 状态管理和组件职责划分
- [[05-nuxt-routing-and-rendering]] — Nuxt SSR 渲染模式与路由约定
- [[06-nuxt-data-server-cache]] — useAsyncData、useFetch 和缓存策略
下一步学习
- 深入 [[05-nuxt-routing-and-rendering]] 理解 Nuxt 的 SSR/SSG/CSR 混合渲染
- 学习 [[06-nuxt-data-server-cache]] 掌握服务端数据获取与缓存
- 阅读 Pinia 官方文档 - Plugins 了解更多插件模式
- 探索 pinia-plugin-persistedstate 的高级配置和序列化定制
学习状态:🟡 开始学习