Skip to content
Gains Summary
Main Navigation 首页 / Home
C++ 编程 / C++ Programming
系统与高性能 / Systems & Performance
Web 开发 / Web Development
人工智能 / Artificial Intelligence
工业软件 / Industrial Software
其他内容 / Other Topics
C++ 编程 / C++系统与性能 / SystemsWeb 开发 / Web人工智能 / AI工业软件 / Industrial

外观

Sidebar Navigation

← Web 开发 / Web Development

Vue 生态 / Vue Ecosystem

1. Vue 生态知识体系 / Vue Ecosystem Knowledge System

2. Vue 组件、模板与编译原理 / Vue Components, Templates, and Compilation

3. Vue 3 响应式系统与 Composition API / Vue 3 Reactivity System and Composition API

4. Vue Router、表单与组件架构 / Vue Router, Forms, and Component Architecture

5. Pinia 状态管理与持久化 / Pinia State Management and Persistence

6. Nuxt 路由、渲染与项目结构 / Nuxt Routing, Rendering, and Project Structure

7. Nuxt 数据获取、Server API 与缓存 / Nuxt Data Fetching, Server APIs, and Caching

8. Vue 测试、性能与生产工程 / Vue Testing, Performance, and Production Engineering

本页目录

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
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

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 式的深层访问路径。

ts
// 每个 Store 独立定义,无需声明属于哪个 module
export const useCartStore = defineStore('cart', () => { /* ... */ })
export const useUserStore = defineStore('user', () => { /* ... */ })
export const useOrderStore = defineStore('order', () => { /* ... */ })
1
2
3
4

这种扁平化设计的代价是:当 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 │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

2.2 Option Store 详解 ​

Option Store 的结构与 Vue 组件 Options API 类似,适合从 Vuex 迁移的团队:

ts
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
    },
  },
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110

2.3 Setup Store 详解 ​

Setup Store 使用 Composition API 风格,灵活性更高:

ts
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,
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104

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                │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

使用示例:

vue
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

3.2 $patch:批量更新 ​

当需要同时修改多个 State 字段时,$patch 比逐个赋值更高效(只触发一次 DevTools 记录):

ts
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()
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

$patch 的主要优势:

  • 原子性:多个变更合并为一次 DevTools 条目,方便时间旅行调试
  • 批量操作:函数式 $patch 适合复杂的数组/集合批量修改
  • 插件通知:插件只收到一次 state 变更通知

3.3 $reset:重置状态 ​

将整个 Store 重置为初始状态:

ts
// 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 }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Option Store 的 $reset 是内置的,因为 state() 函数为 Pinia 提供了初始状态快照。Setup Store 中 ref() 的初始值在运行时对 Pinia 不透明,需要显式定义重置逻辑。

3.4 $subscribe:订阅变化 ​

$subscribe 在 State 发生变化后触发,适合持久化、同步到外部系统等场景:

ts
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()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌─────────────────────────────────────────────────────────────┐
│          $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                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

3.5 $onAction:拦截 Action ​

$onAction 在 Action 执行前后触发,适合日志、错误追踪和性能监控:

ts
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()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

3.6 $state:访问原始状态 ​

ts
// 读取整个 State 的普通对象(非响应式)
const plainState = cart.$state

// 替换整个 State(会触发所有订阅者)
cart.$state = {
  items: [],
  discountCode: null,
  lastUpdated: Date.now(),
}
1
2
3
4
5
6
7
8
9

第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 回调在变更时触发         │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

4.2 自定义插件示例 ​

日志插件 ​

ts
// 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 : '',
    )
  })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

错误追踪插件 ​

ts
// 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),
          },
        )
      })
    })
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

增强类型声明 ​

当插件向 Store 注入属性时,需要扩展 Pinia 类型:

ts
// types/pinia.d.ts
import 'pinia'

declare module 'pinia' {
  export interface PiniaCustomProperties {
    // 插件注入的属性
    $router: Router
  }
}
1
2
3
4
5
6
7
8
9

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 │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38

持久化的 Schema 版本管理:

ts
// 不要直接持久化整个 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
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

持久化关键原则:

  • 价格、库存等服务端事实不应信任本地缓存,结算时从服务端重新读取
  • 用户切换或退出时必须清理持久化数据
  • 多标签页场景需考虑冲突策略(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 闪烁             │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

5.2 Nuxt 中的 Pinia 初始化 ​

Nuxt 通过 @pinia/nuxt 模块提供开箱即用的 Pinia 集成:

ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@pinia/nuxt'],
  pinia: {
    // 可选:自定义 Pinia 配置
    storesDirs: ['./stores/**'], // Store 文件自动导入
  },
})
1
2
3
4
5
6
7
8

Nuxt 自动处理了 SSR 的关键细节:

ts
// plugins/pinia-init.ts (Nuxt 插件,可选)
export default defineNuxtPlugin((nuxtApp) => {
  // nuxtApp.$pinia 是当前请求的 Pinia 实例
  // 每个 SSR 请求自动创建独立实例,无需手动管理

  // 安装 Pinia 插件
  nuxtApp.$pinia.use(myPlugin)
})
1
2
3
4
5
6
7
8

5.3 Store 定义(Nuxt 风格) ​

ts
// 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 }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

5.4 SSR 状态传递与 Hydration ​

ts
// 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

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 管理的状态应是"客户端拥有的真实状态"          │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

第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 直接解构               │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

6.2 迁移示例 ​

Vuex Module(迁移前):

ts
// 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)
    },
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

Pinia Store(迁移后):

ts
// 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,
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

6.3 迁移策略 ​

渐进迁移: Vuex 和 Pinia 可以在同一项目中并存。app.use(pinia) 不会干扰已有的 Vuex Store。可以逐个模块迁移:

  1. 新建 Pinia Store,复制 Vuex Module 的逻辑
  2. 逐个组件替换 useStore() 为新的 Pinia Store
  3. 确认无回归后移除旧的 Vuex Module
  4. 全部迁移完成后移除 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 适合管理的客户端状态:                                │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  ✓ 购物车内容(用户交互产生的状态)                  │   │
│  │  ✓ 草稿编辑器内容(未提交的表单数据)                │   │
│  │  ✓ 用户偏好/主题(跨组件共享的客户端设置)           │   │
│  │  ✓ 多步向导中间状态(跨页面的流程状态)              │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

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) ​

ts
// 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 }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

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 之间         │   │
│  │  }                                                  │   │
│  │                                                     │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

7.5 其他常见反模式 ​

反模式一:滥用 Store 替代组件 Props

ts
// ✗ 不必要:将组件展开状态放入 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 足够,除非需要跨组件协调
1
2
3
4
5
6
7
8
9

反模式二:Action 演化成手写 Query Cache

如果 Action 中出现了缓存过期、后台重取、乐观更新、请求去重等逻辑,停下来 —— 这不是 Pinia 的职责。使用 TanStack Query (Vue Query) 或 Nuxt 的 useAsyncData。

反模式三:持久化敏感数据

ts
// ✗ 危险:Token、密码等敏感信息绝不应持久化到 localStorage
persist: {
  paths: ['token', 'refreshToken', 'creditCard'], // 不要这样做
}

// ✓ 使用 httpOnly cookie 存储敏感数据,仅持久化非敏感偏好
persist: {
  paths: ['theme', 'language', 'sidebarCollapsed'],
}
1
2
3
4
5
6
7
8
9

核心总结 ​

┌─────────────────────────────────────────────────────────────┐
│              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                    │   │
│  └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

章节测试 ​

一、选择题(每题 4 分,共 20 分) ​

  1. 关于 Pinia 的设计哲学,以下哪项描述是正确的? A. Pinia 保留了 Vuex 的 mutations 概念,确保状态变更可追踪 B. Pinia 通过嵌套 modules 来组织状态,与 Vuex 保持一致 C. Pinia 取消了 mutations,将状态变更统一为 action D. Pinia 要求所有 action 必须是异步的

  2. 在组件中解构 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

  3. 关于 Setup Store 的 $reset,以下说法正确的是? A. Setup Store 的 $reset 是内置的,无需额外配置 B. Setup Store 不能使用 $reset C. Setup Store 可以定义自己的 $reset 函数来重置状态 D. 只有 Option Store 才有 Setup Store 的 $reset 功能

  4. Nuxt 中使用 Pinia 时,以下哪个做法是错误的? A. 将购物车状态存入 Pinia 并在客户端持久化 B. 用 Pinia 存储 useAsyncData 返回的服务端数据以便跨组件访问 C. 在 Nuxt 插件中安装 Pinia 自定义插件 D. 在 Pinia Store 中使用 import.meta.client 区分 SSR 和客户端

  5. Store A 和 Store B 互相调用对方的 Action,可能导致什么问题? A. 性能下降,因为每次 Action 调用都触发两次 DevTools 记录 B. 初始化时循环依赖导致某个 Store 为 undefined C. TypeScript 类型推断失败 D. 不会产生任何问题,这是 Pinia 推荐的设计模式

二、简答题(每题 10 分,共 30 分) ​

  1. 简述 $subscribe 和 $onAction 的区别,以及各自的典型使用场景。

  2. 解释为什么在 SSR 中每个请求必须拥有独立的 Pinia 实例,以及 Nuxt 是如何自动处理这一点的。

  3. 列出至少三种不适合使用 Pinia 的场景,并说明每种场景的推荐替代方案。

三、实操题(每题 25 分,共 50 分) ​

  1. 设计一个带持久化的用户偏好 Store(usePreferencesStore),要求:

    • 包含 theme('light' | 'dark' | 'auto')、language(string)、sidebarCollapsed(boolean)
    • 持久化到 localStorage,排除版本号等元数据字段
    • 包含 Schema 版本管理和从旧格式迁移的逻辑(V1 没有 sidebarCollapsed 字段)
    • 写一个 Nuxt 插件在用户切换时清理旧的偏好数据
  2. 将以下 Vuex Module 迁移为 Pinia Setup Store(useNotificationsStore):

ts
// 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))
    },
  },
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

参考答案 ​

一、选择题 ​

  1. C — Pinia 取消了 mutations,State 的变更统一通过 action(或直接赋值/$patch)完成。

  2. B — State 和 Getter 通过 storeToRefs 解构保持响应式;Action 是普通函数,直接从 Store 解构。

  3. C — Setup Store 需要手动实现 $reset 逻辑,因为 Pinia 无法自动推断 ref 的初始状态。Option Store 的 $reset 是内置的。

  4. B — useAsyncData 的数据由 Nuxt 的 Payload 机制管理,不应复制到 Pinia。这将导致两个真相源(Nuxt 缓存 + Pinia State),引发一致性问题。

  5. B — 循环依赖可能导致初始化时某个 Store 尚未创建就被访问,得到 undefined。应通过抽取共享 Store、在 Action 内部延迟调用或使用 Composable 协调来避免。

二、简答题 ​

  1. 区别与场景:

    • $subscribe:State 变更后触发,接收 mutation 对象和新状态。典型场景:持久化 State 到 localStorage、同步状态到 IndexedDB、发送状态变更到 Web Worker。
    • $onAction:Action 调用时触发(可注册 after/onError 回调)。典型场景:Action 性能监控(计时)、错误上报、调用前权限检查日志。
  2. SSR 实例隔离:

    • 多个并发请求共享同一个 Pinia 实例会导致请求 A 的 State 泄漏到请求 B 的响应中。
    • Nuxt 通过 @pinia/nuxt 模块自动为每个请求创建独立的 Pinia 实例。
    • 服务端渲染完成后,pinia.state.value 被序列化嵌入 HTML(Nuxt Payload)。
    • 客户端 hydration 时,从 Payload 恢复状态到客户端 Pinia 实例。
  3. 不适合 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 的高级配置和序列化定制

学习状态:🟡 开始学习

最后更新于:

Pager
上一篇4. Vue Router、表单与组件架构 / Vue Router, Forms, and Component Architecture
下一篇6. Nuxt 路由、渲染与项目结构 / Nuxt Routing, Rendering, and Project Structure

持续记录,持续成长

Copyright © Tidenflow