Vue 基础体系 · 第 45/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Pinia 生产模式:Store 拆分、订阅、持久化、重置和测试

Pinia 是 Vue 3 应用中管理共享状态的官方状态库。它解决的不是“如何把数据放进一个全局对象”,而是如何在多个组件、路由、异步请求和测试环境之间,建立可追踪、可复用、可恢复的状态数据流。

生产环境中的 Pinia 通常需要同时处理以下问题:

  • 一个 Store 应该保存哪些状态;
  • 多个 Store 如何拆分和协作;
  • 如何观察状态变化和 Action 执行;
  • 哪些状态应该持久化,如何恢复;
  • 用户退出、切换账号或测试结束后,如何可靠重置;
  • 如何在单元测试中隔离 Store,避免测试之间相互污染。

下面的示例基于 Vue 3、Composition API、TypeScript、Vite 和现代 Pinia API。


一、先建立 Pinia 的运行模型

1.1 Pinia、Pinia 实例、Store 和组件的关系

Pinia 的运行结构可以抽象为:

Vue App
  └── Pinia 实例
        ├── auth Store
        ├── cart Store
        └── settings Store
              └── 被组件、路由守卫、测试代码使用

创建应用时,通常先创建一个 Pinia 实例,再把它安装到 Vue 应用:

// 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() 返回的是应用级容器。Store 定义本身只是一个工厂,只有通过 useXxxStore() 获取时,才会在当前 Pinia 实例中创建或复用对应 Store。

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
  }),
})

这里的 'counter' 是 Store 的唯一 ID。Pinia 使用它区分不同 Store,也使用它作为 DevTools、持久化和测试初始化时的标识。

如果代码运行在组件 setup() 中,Pinia 通常可以自动找到当前实例:

const counter = useCounterStore()

但在组件外部,例如路由守卫、普通模块或测试中,必须确保 Pinia 已经激活,或者显式传入实例:

const pinia = createPinia()
const counter = useCounterStore(pinia)

这一区别很重要。以下代码在模块加载阶段调用 Store,可能早于 app.use(pinia)

// 不推荐:模块导入时立即执行
const auth = useAuthStore()

更安全的方式是在函数执行时再获取:

export function canAccessAdmin() {
  const auth = useAuthStore()
  return auth.isAuthenticated && auth.user?.role === 'admin'
}

对于路由守卫,也应在守卫函数内部获取 Store:

// src/router/guards.ts
import { useAuthStore } from '@/stores/auth'

router.beforeEach((to) => {
  const auth = useAuthStore()

  if (to.meta.requiresAuth && !auth.isAuthenticated) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
})

此时路由通常已经在应用安装 Pinia 后使用,因此可以找到正确的活动实例。


1.2 两种 Store 定义方式

Pinia 支持两种主要写法:

  1. Option Store:接近 Vuex 或传统 Options API;
  2. Setup Store:使用 refcomputed 和普通函数。

Option Store

import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
  }),

  getters: {
    doubled: (state) => state.count * 2,
  },

  actions: {
    increment() {
      this.count++
    },
  },
})

Option Store 的结构明确:

  • state 返回初始状态;
  • getters 声明派生数据;
  • actions 修改状态或执行业务逻辑;
  • 内置支持 $reset()

Setup Store

import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubled = computed(() => count.value * 2)

  function increment() {
    count.value++
  }

  return {
    count,
    doubled,
    increment,
  }
})

Setup Store 更接近 Composition API,可以使用:

  • ref
  • computed
  • watch
  • Vue 生命周期;
  • 其他 composable;
  • 复杂的异步控制逻辑。

但它有一个关键差异:Setup Store 没有自动生成的 $reset()。如果需要重置,必须自己实现。


二、Store 拆分:按状态所有权划分,而不是按组件划分

2.1 什么是合理的 Store 边界

Store 边界的核心问题是:

哪个业务对象负责维护这份状态,以及哪些动作可以改变它?

例如一个电商应用可以拆分为:

auth Store
  ├── 当前用户
  ├── 登录状态
  └── 登录、退出

cart Store
  ├── 购物车商品
  ├── 商品数量
  └── 加入、删除、清空

catalog Store
  ├── 商品列表
  ├── 搜索条件
  └── 加载商品

settings Store
  ├── 主题
  └── 语言

不应简单地为每个页面建立一个 Store:

HomePageStore
ProductPageStore
CheckoutPageStore

页面是视图组织单位,业务状态的生命周期通常跨越多个页面。例如购物车状态会从商品详情页持续到结算页,因此它更适合属于 cart Store,而不是某个页面 Store。

一个状态适合进入 Store,通常至少满足以下条件之一:

  • 多个不直接具有父子关系的组件需要访问;
  • 状态生命周期跨越路由切换;
  • 状态变化需要被订阅、持久化或测试;
  • 状态包含明确的业务动作,而不只是单个组件的临时输入。

反过来,以下状态通常不应放入全局 Store:

  • 某个弹窗是否打开;
  • 一个输入框当前的临时字符串;
  • 仅由单个组件使用的动画状态;
  • 可以由 Props 和 Emits 清晰传递的局部状态。

把所有内容放进一个 Store 会产生隐式耦合:

// 不推荐:一个 Store 同时管理用户、购物车、弹窗和商品搜索
export const useAppStore = defineStore('app', {
  state: () => ({
    user: null,
    cart: [],
    isLoginDialogOpen: false,
    keyword: '',
    products: [],
  }),
})

这种结构的问题不是代码一定不能运行,而是每次修改 Store 都可能影响无关功能,订阅、持久化和测试也无法按业务边界进行控制。


2.2 一个可运行的 Store 拆分示例

先定义认证 Store:

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

export interface User {
  id: string
  name: string
  role: 'user' | 'admin'
}

export const useAuthStore = defineStore('auth', () => {
  const token = ref<string | null>(null)
  const user = ref<User | null>(null)

  const isAuthenticated = computed(() => token.value !== null)

  async function login(username: string, password: string) {
    // 实际项目中应调用 API
    if (username !== 'admin' || password !== 'password') {
      throw new Error('用户名或密码错误')
    }

    token.value = 'token-from-server'
    user.value = {
      id: 'u-1',
      name: 'Admin',
      role: 'admin',
    }
  }

  function logout() {
    token.value = null
    user.value = null
  }

  return {
    token,
    user,
    isAuthenticated,
    login,
    logout,
  }
})

再定义购物车 Store:

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

export interface CartItem {
  productId: string
  name: string
  price: number
  quantity: number
}

export const useCartStore = defineStore('cart', () => {
  const items = ref<CartItem[]>([])

  const totalQuantity = computed(() =>
    items.value.reduce((sum, item) => sum + item.quantity, 0),
  )

  const totalPrice = computed(() =>
    items.value.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0,
    ),
  )

  function addItem(product: Omit<CartItem, 'quantity'>) {
    const existing = items.value.find(
      (item) => item.productId === product.productId,
    )

    if (existing) {
      existing.quantity += 1
      return
    }

    items.value.push({
      ...product,
      quantity: 1,
    })
  }

  function removeItem(productId: string) {
    items.value = items.value.filter(
      (item) => item.productId !== productId,
    )
  }

  function clear() {
    items.value = []
  }

  return {
    items,
    totalQuantity,
    totalPrice,
    addItem,
    removeItem,
    clear,
  }
})

组件中使用 Store 时,如果要解构响应式字段,必须注意响应式丢失问题:

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

const cart = useCartStore()
const { items, totalPrice } = storeToRefs(cart)

const { addItem, removeItem } = cart
</script>

直接解构状态:

const { items } = cart

会失去对 Store 状态的响应式连接。storeToRefs() 只提取状态和 getters,并保留响应式;Action 可以直接从 Store 解构,因为函数本身不依赖 Vue 的 ref 解构机制。


2.3 Store 之间的协作与循环依赖

Store 可以读取其他 Store:

// src/stores/checkout.ts
import { defineStore } from 'pinia'
import { useAuthStore } from './auth'
import { useCartStore } from './cart'

export const useCheckoutStore = defineStore('checkout', () => {
  async function submitOrder() {
    const auth = useAuthStore()
    const cart = useCartStore()

    if (!auth.isAuthenticated) {
      throw new Error('请先登录')
    }

    if (cart.items.length === 0) {
      throw new Error('购物车为空')
    }

    // 调用订单 API
  }

  return {
    submitOrder,
  }
})

这里的依赖方向是:

checkout
  ├── auth
  └── cart

如果 auth 又在模块初始化时直接获取 checkout,就可能产生循环依赖或 Store 尚未完全初始化的问题:

// 不推荐
const checkout = useCheckoutStore()

export const useAuthStore = defineStore('auth', {
  actions: {
    logout() {
      checkout.reset()
    },
  },
})

更安全的方式是把 useCheckoutStore() 放入 Action 执行时:

actions: {
  logout() {
    const checkout = useCheckoutStore()
    checkout.reset()
    this.token = null
  },
}

更根本的做法是减少双向依赖。通常让一个协调型 Store 调用多个领域 Store,比让两个基础 Store 互相调用更容易维护。


三、状态变更:直接修改、$patch 与 Action

Pinia 允许三类常见状态变化方式。

3.1 直接修改状态

cart.items.push({
  productId: 'p-1',
  name: 'Keyboard',
  price: 99,
  quantity: 1,
})

在组件或业务代码中直接修改是被 Pinia 支持的,Vue 的响应式系统会追踪它。

但是,直接修改多字段状态时,业务语义可能分散在多个调用点。例如:

user.name = 'New Name'
user.email = 'new@example.com'

调用者必须知道哪些字段需要一起修改,容易形成不完整状态。


3.2 使用 $patch

对象形式适合批量更新:

auth.$patch({
  user: {
    id: 'u-1',
    name: 'Alice',
    role: 'user',
  },
  token: 'token-from-server',
})

函数形式适合数组或嵌套对象:

cart.$patch((state) => {
  state.items.push({
    productId: 'p-2',
    name: 'Mouse',
    price: 49,
    quantity: 2,
  })
})

$patch 的重要行为是:多个同步修改可以作为一个订阅事件被观察。对于需要记录“这次业务变更”的日志、持久化或 DevTools 调试,函数形式通常比连续多个直接赋值更容易形成清晰边界。


3.3 使用 Action 封装业务不变量

不变量是业务上必须始终成立的条件。例如购物车中同一个商品只能有一条记录,数量必须大于零。

如果外部代码可以任意修改 items,就可能绕过这些规则。因此应把业务变更封装进 Action:

function setQuantity(productId: string, quantity: number) {
  if (!Number.isInteger(quantity) || quantity < 1) {
    throw new Error('商品数量必须是正整数')
  }

  const item = items.value.find(
    (candidate) => candidate.productId === productId,
  )

  if (!item) {
    throw new Error('商品不存在')
  }

  item.quantity = quantity
}

Action 不是强制所有状态修改都必须经过的语法层,而是业务边界。状态越复杂,越应避免让组件直接拼装内部数据结构。


四、订阅:观察状态变化和 Action 执行

Pinia 中“订阅”有两个不同对象:

  • $subscribe():订阅 Store 状态变化;
  • $onAction():订阅 Action 的调用、成功和失败。

不能用其中一个完全替代另一个。


4.1 $subscribe():观察状态变化

const cart = useCartStore()

const unsubscribe = cart.$subscribe((mutation, state) => {
  console.log('Store ID:', mutation.storeId)
  console.log('Mutation type:', mutation.type)
  console.log('Current state:', state)
})

mutation.type 常见值包括:

  • 'direct':直接修改;
  • 'patch object':使用对象形式 $patch
  • 'patch function':使用函数形式 $patch

示例:

cart.items.push(item)
// mutation.type === 'direct'

cart.$patch({ items: [] })
// mutation.type === 'patch object'

cart.$patch((state) => {
  state.items = []
})
// mutation.type === 'patch function'

订阅返回取消函数:

const stop = cart.$subscribe(() => {
  // 处理状态变化
})

stop()

在 Vue 组件的 setup() 中建立订阅时,Pinia 默认会将订阅绑定到当前组件生命周期。组件卸载后,订阅会自动停止。若订阅属于应用级服务而不是组件,使用 detached: true

const stop = cart.$subscribe(
  (mutation, state) => {
    console.log(mutation, state)
  },
  { detached: true },
)

flush 选项控制响应式观察的调度时机:

cart.$subscribe(
  () => {
    console.log('状态已变化')
  },
  { flush: 'sync' },
)

flush: 'sync' 会让回调更早执行,但同步执行可能增加连续修改的开销。持久化通常不需要强制使用 sync;只有在明确需要同步观察时才应选择它。


4.2 $onAction():观察 Action 生命周期

const auth = useAuthStore()

const stop = auth.$onAction(
  ({
    name,
    args,
    after,
    onError,
  }) => {
    const startedAt = performance.now()

    console.log('Action started:', name, args)

    after((result) => {
      console.log('Action succeeded:', {
        name,
        duration: performance.now() - startedAt,
        result,
      })
    })

    onError((error) => {
      console.error('Action failed:', {
        name,
        duration: performance.now() - startedAt,
        error,
      })
    })
  },
)

after() 在 Action 返回的 Promise 成功完成后执行;onError() 在 Action 抛出异常或 Promise 拒绝时执行。

因此可以用它实现:

  • Action 性能统计;
  • 统一错误日志;
  • 用户行为审计;
  • 异步操作成功率统计。

$onAction() 不等价于 API 请求拦截器。它只能观察通过该 Store Action 发起的调用,不能自动捕获组件直接修改状态或 Store 外部的网络请求。


4.3 订阅的故障边界

订阅回调本身如果抛错,可能影响开发调试甚至打断后续逻辑。因此生产代码应隔离副作用:

cart.$subscribe((mutation, state) => {
  try {
    localStorage.setItem('cart', JSON.stringify(state))
  } catch (error) {
    console.error('购物车持久化失败', error)
  }
})

如果订阅用于日志上报,不应让日志服务失败导致业务 Action 失败:

auth.$onAction(({ name, after, onError }) => {
  after(() => {
    void reportAction({ name, status: 'success' }).catch(console.error)
  })

  onError((error) => {
    void reportAction({
      name,
      status: 'error',
      message: String(error),
    }).catch(console.error)
  })
})

五、持久化:把状态复制到外部存储

5.1 持久化不是 Pinia 的默认能力

Pinia 默认只把状态保存在内存中。刷新页面后,Store 会重新创建,状态也会回到初始值。

持久化的本质是:

Store 内存状态
      ↓ 序列化
localStorage / sessionStorage / IndexedDB / 服务端
      ↓ 反序列化
页面初始化时恢复 Store

这会引入两个额外问题:

  1. 存储格式是否稳定;
  2. 外部数据是否可信。

因此,持久化不能简单理解为“把整个 Store 做 JSON.stringify”。


5.2 一个手写持久化插件

下面实现一个只持久化指定字段的 Pinia 插件。它具备:

  • SSR 环境保护;
  • JSON 解析失败保护;
  • 版本字段;
  • 白名单字段;
  • 恢复失败时使用默认状态;
  • 存储超限时捕获异常。
// src/plugins/persisted-state.ts
import type { PiniaPluginContext } from 'pinia'

interface PersistOptions {
  key?: string
  paths: string[]
  version: number
  storage?: Storage
}

interface PersistedPayload {
  version: number
  state: Record<string, unknown>
}

function pickState(
  state: Record<string, unknown>,
  paths: string[],
): Record<string, unknown> {
  return Object.fromEntries(
    paths
      .filter((path) => path in state)
      .map((path) => [path, state[path]]),
  )
}

export function createPersistedState(
  options: PersistOptions,
) {
  return ({ store }: PiniaPluginContext) => {
    const storage =
      options.storage ??
      (typeof window === 'undefined'
        ? undefined
        : window.localStorage)

    if (!storage) {
      return
    }

    const key = options.key ?? `pinia:${store.$id}`

    try {
      const raw = storage.getItem(key)

      if (raw) {
        const payload = JSON.parse(raw) as PersistedPayload

        if (
          payload.version === options.version &&
          payload.state &&
          typeof payload.state === 'object'
        ) {
          store.$patch(payload.state)
        }
      }
    } catch (error) {
      console.warn(`恢复持久化状态失败:${key}`, error)
      storage.removeItem(key)
    }

    store.$subscribe(
      (_mutation, state) => {
        const payload: PersistedPayload = {
          version: options.version,
          state: pickState(state, options.paths),
        }

        try {
          storage.setItem(key, JSON.stringify(payload))
        } catch (error) {
          console.error(`保存持久化状态失败:${key}`, error)
        }
      },
      { detached: true },
    )
  }
}

在应用入口注册插件:

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { createPersistedState } from './plugins/persisted-state'

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

pinia.use(
  createPersistedState({
    paths: ['items'],
    version: 1,
  }),
)

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

这里的插件会对每一个 Store 生效,因此 paths: ['items'] 只适用于具有 items 字段的 Store。更常见的做法是让每个 Store 声明自己的持久化配置,或者根据 Store ID 分支:

pinia.use(({ store }) => {
  if (store.$id !== 'cart') {
    return
  }

  // 只对 cart Store 建立持久化逻辑
})

由于 Pinia 插件通过 pinia.use() 注册,通常应在 Store 首次使用之前完成注册。插件的具体执行还依赖 Pinia 是否已经安装到 Vue 应用;应用入口中先创建并配置 Pinia,再 app.use(pinia),可以避免时序不明确。


5.3 为什么要使用字段白名单

假设认证 Store 包含:

{
  token: string,
  user: User,
  permissions: string[],
  loginError: string | null,
}

通常不应该把所有字段都持久化:

  • token 可能是敏感凭据;
  • loginError 是临时 UI 状态;
  • 权限可能已经在服务端失效;
  • 用户数据可能涉及隐私。

例如只保存主题和购物车:

createPersistedState({
  paths: ['items'],
  version: 1,
})

对于认证信息,是否持久化 Token 必须依据认证方案决定。将 Token 放入 localStorage 会受到 XSS 读取风险;更安全的认证设计通常会考虑 HttpOnly、Secure、SameSite Cookie,但这属于完整认证架构的一部分,不能由 Pinia 自动解决。


5.4 版本迁移,而不是遇到变化就清空

当存储结构发生变化时,旧 JSON 可能仍然合法,但语义已经不兼容:

// 旧版本
{
  "version": 1,
  "state": {
    "items": [
      { "id": "p-1", "count": 2 }
    ]
  }
}

// 新版本
{
  "version": 2,
  "state": {
    "items": [
      { "productId": "p-1", "quantity": 2 }
    ]
  }
}

简单实现可以在版本不匹配时丢弃旧状态:

if (payload.version === options.version) {
  store.$patch(payload.state)
}

如果旧数据有保留价值,应显式迁移:

function migrate(
  version: number,
  state: Record<string, unknown>,
): Record<string, unknown> | null {
  if (version === 1) {
    const oldItems = Array.isArray(state.items)
      ? state.items
      : []

    return {
      items: oldItems.map((item) => {
        const value = item as {
          id?: string
          count?: number
        }

        return {
          productId: value.id,
          quantity: value.count,
        }
      }),
    }
  }

  if (version === 2) {
    return state
  }

  return null
}

生产代码还应校验字段类型。JSON.parse() 成功只表示字符串符合 JSON 语法,不表示它符合业务类型。浏览器存储中的数据可能来自旧版本、手工修改或其他脚本,不能当作可信输入。


5.5 并发和跨标签页问题

localStorage 的写入是同步 API,但它不提供应用级事务。多个标签页同时修改同一个 Store 时,可能出现:

标签页 A 读取 items = [a]
标签页 B 读取 items = [a]
标签页 A 写入 [a, b]
标签页 B 写入 [a, c]
最终结果可能只剩 [a, c]

浏览器还会触发 storage 事件通知其他文档,但 Pinia 不会自动将这个事件合并到当前 Store。若应用需要多标签页同步,必须自行设计冲突策略,例如:

  • 监听 window.storage
  • 使用 BroadcastChannel
  • 为数据增加时间戳或版本号;
  • 让服务端成为最终权威;
  • 对购物车等业务执行显式合并。
window.addEventListener('storage', (event) => {
  if (event.key !== 'pinia:cart' || !event.newValue) {
    return
  }

  try {
    const payload = JSON.parse(event.newValue)
    cart.$patch(payload.state)
  } catch (error) {
    console.warn('跨标签页状态同步失败', error)
  }
})

该示例只是“最后写入者获胜”的简单策略,不能解决所有并发冲突。对于订单、库存、权限等数据,浏览器本地状态不应作为最终事实来源。


六、重置:恢复初始状态、退出账号和切换用户

6.1 Option Store 的 $reset()

Option Store 自动提供 $reset()

import { defineStore } from 'pinia'

export const useFiltersStore = defineStore('filters', {
  state: () => ({
    keyword: '',
    page: 1,
    tags: [] as string[],
  }),

  actions: {
    resetToFirstPage() {
      this.page = 1
    },
  },
})

调用:

const filters = useFiltersStore()

filters.keyword = 'keyboard'
filters.page = 3
filters.$reset()

console.log(filters.$state)
// { keyword: '', page: 1, tags: [] }

Pinia 会再次调用 state() 得到初始状态,并将 Store 恢复为该结构。初始状态必须是工厂函数,而不是共享对象:

// 正确:每个 Store 实例获得独立状态
state: () => ({
  count: 0,
})

6.2 Setup Store 必须手动实现 $reset()

Setup Store 没有自动 $reset(),因为 Pinia 无法推断哪些 ref 是状态、哪些 ref 是临时变量,也无法自动推断自定义初始化逻辑。

import { computed, ref } from 'vue'
import { defineStore } from 'pinia'

export const useAuthStore = defineStore('auth', () => {
  const token = ref<string | null>(null)
  const user = ref<User | null>(null)
  const loginError = ref<string | null>(null)

  const isAuthenticated = computed(() => token.value !== null)

  function reset() {
    token.value = null
    user.value = null
    loginError.value = null
  }

  return {
    token,
    user,
    loginError,
    isAuthenticated,
    reset,
  }
})

这里的 reset() 不只是“清空所有字段”,还表达了业务语义:退出当前账号时应清理认证信息和错误信息。


6.3 全局重置与退出账号

pinia.state.value 是所有 Store 状态的根状态,可以整体替换或清空,但直接操作它会绕过各 Store 自定义的清理逻辑:

pinia.state.value = {}

因此,退出账号时通常更适合显式调用相关 Store 的 Action:

function logoutEverywhere() {
  const auth = useAuthStore()
  const cart = useCartStore()
  const checkout = useCheckoutStore()

  checkout.reset()
  cart.clear()
  auth.logout()
}

如果应用需要统一重置,可以建立约定:

// src/stores/reset.ts
import type { Pinia } from 'pinia'
import { useAuthStore } from './auth'
import { useCartStore } from './cart'

export function resetUserSession(pinia: Pinia) {
  useAuthStore(pinia).logout()
  useCartStore(pinia).clear()
}

“退出登录”和“页面刷新”不是同一件事:

  • 刷新只会重新加载内存状态,持久化数据可能被恢复;
  • 退出登录应清理用户相关 Store,并删除对应持久化键;
  • 切换账号时,必须避免旧账号的购物车、权限或草稿泄漏给新账号。

如果持久化键与用户有关,应把用户 ID 纳入键名:

const key = `cart:${userId}`

但用户 ID 在退出前后如何获取、旧键何时删除,仍需要由认证流程明确控制。


七、状态初始化与持久化恢复的时序

带持久化的 Store 初始化大致经历以下步骤:

sequenceDiagram
    participant App as Vue 应用
    participant Pinia as Pinia 实例
    participant Store as Store
    participant Storage as 浏览器存储
    participant UI as 组件

    App->>Pinia: app.use(pinia)
    App->>Store: useCartStore()
    Store->>Store: 创建默认内存状态
    Store->>Storage: 读取持久化 JSON
    Storage-->>Store: 返回字符串或空值
    Store->>Store: 解析、校验、迁移
    Store->>Store: $patch() 恢复状态
    Store-->>UI: 提供响应式状态
    UI->>Store: 调用 addItem()
    Store->>Store: 更新内存状态
    Store->>Storage: $subscribe() 写入新状态

关键路径是:

  1. Store 先创建默认状态;
  2. 持久化插件读取外部数据;
  3. 外部数据通过 $patch() 合并;
  4. 组件获得恢复后的响应式状态;
  5. 后续变化通过订阅写回存储。

如果在恢复完成前渲染依赖数据的页面,就可能出现短暂的默认状态闪烁。对于购物车,通常可以接受;对于用户权限和路由访问控制,则不能仅依赖异步恢复后的 Store 值决定首屏权限。应在应用启动阶段完成认证恢复,或显式维护:

const isAuthReady = ref(false)

只有在认证恢复完成后,才开始创建需要权限判断的页面流程。


八、测试:为每个测试提供独立 Pinia

8.1 为什么测试不能共用应用级 Store

Store 状态位于 Pinia 实例中。如果多个测试共享一个 Pinia 实例,前一个测试的状态可能影响后一个测试:

测试 A:添加商品,items.length = 1
测试 B:期望初始 items.length = 0
实际:items.length = 1

因此每个测试用例或每个测试套件都应创建隔离的 Pinia 实例。使用组件测试时,官方提供了 @pinia/testingcreateTestingPinia()

安装:

npm install -D @pinia/testing vitest

8.2 组件测试中的 Testing Pinia

组件:

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

const cart = useCartStore()
const { totalQuantity, totalPrice } = storeToRefs(cart)
</script>

<template>
  <section>
    <span data-testid="quantity">{{ totalQuantity }}</span>
    <span data-testid="price">{{ totalPrice }}</span>
  </section>
</template>

测试:

// src/components/CartSummary.spec.ts
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it } from 'vitest'
import CartSummary from './CartSummary.vue'

describe('CartSummary', () => {
  it('读取初始购物车状态', () => {
    const wrapper = mount(CartSummary, {
      global: {
        plugins: [
          createTestingPinia({
            initialState: {
              cart: {
                items: [
                  {
                    productId: 'p-1',
                    name: 'Keyboard',
                    price: 100,
                    quantity: 2,
                  },
                ],
              },
            },
          }),
        ],
      },
    })

    expect(wrapper.get('[data-testid="quantity"]').text()).toBe('2')
    expect(wrapper.get('[data-testid="price"]').text()).toBe('200')
  })
})

initialState 的键必须使用 Store ID,即这里的 cart,而不是变量名 cartStore 或文件名。


8.3 Action 默认是 Stub 还是实际执行

createTestingPinia() 默认会对 Action 进行 Stub。Stub 的含义是:Action 被替换成测试 Spy,调用可以被断言,但原始实现不会执行。

这适合测试组件是否正确调用 Action:

const pinia = createTestingPinia()

const wrapper = mount(SomeComponent, {
  global: {
    plugins: [pinia],
  },
})

const cart = useCartStore()

// 组件操作后
expect(cart.addItem).toHaveBeenCalled()

如果测试目标是 Action 本身的业务逻辑,则应关闭 Stub:

const pinia = createTestingPinia({
  stubActions: false,
})

完整示例:

import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it } from 'vitest'
import { useCartStore } from '@/stores/cart'

describe('cart store', () => {
  it('加入商品后计算数量和金额', () => {
    const pinia = createTestingPinia({
      stubActions: false,
    })

    const cart = useCartStore(pinia)

    cart.addItem({
      productId: 'p-1',
      name: 'Keyboard',
      price: 100,
    })

    expect(cart.items).toHaveLength(1)
    expect(cart.totalQuantity).toBe(1)
    expect(cart.totalPrice).toBe(100)
  })
})

如果忘记设置 stubActions: false,测试可能出现“Action 被调用了,但状态没有变化”的结果。这不是 Store 实现错误,而是测试 Pinia 按默认配置替换了 Action。


8.4 测试异步 Action 和错误路径

认证 Action 的测试应同时覆盖成功和失败:

import { createTestingPinia } from '@pinia/testing'
import { describe, expect, it } from 'vitest'
import { useAuthStore } from '@/stores/auth'

describe('auth store', () => {
  it('登录成功后设置用户和 token', async () => {
    const pinia = createTestingPinia({
      stubActions: false,
    })

    const auth = useAuthStore(pinia)

    await auth.login('admin', 'password')

    expect(auth.isAuthenticated).toBe(true)
    expect(auth.user?.role).toBe('admin')
  })

  it('错误凭据会拒绝', async () => {
    const pinia = createTestingPinia({
      stubActions: false,
    })

    const auth = useAuthStore(pinia)

    await expect(
      auth.login('wrong', 'wrong'),
    ).rejects.toThrow('用户名或密码错误')

    expect(auth.isAuthenticated).toBe(false)
  })
})

异步测试必须等待 Promise。以下写法不可靠:

auth.login('wrong', 'wrong')
expect(auth.isAuthenticated).toBe(false)

如果 Action 内部存在异步流程,断言可能发生在状态更新之前。


8.5 测试重置行为

Setup Store 的重置是自定义业务逻辑,因此应单独测试:

it('logout 会清除用户会话', async () => {
  const pinia = createTestingPinia({
    stubActions: false,
  })

  const auth = useAuthStore(pinia)

  await auth.login('admin', 'password')
  auth.logout()

  expect(auth.token).toBeNull()
  expect(auth.user).toBeNull()
  expect(auth.isAuthenticated).toBe(false)
})

如果持久化插件也参与测试,建议注入内存 Storage,而不是直接污染真实浏览器存储:

function createMemoryStorage(): Storage {
  const data = new Map<string, string>()

  return {
    get length() {
      return data.size
    },
    clear() {
      data.clear()
    },
    getItem(key) {
      return data.get(key) ?? null
    },
    key(index) {
      return [...data.keys()][index] ?? null
    },
    removeItem(key) {
      data.delete(key)
    },
    setItem(key, value) {
      data.set(key, value)
    },
  }
}

这样可以验证:

Action 修改状态
  → $subscribe 触发
  → 序列化写入内存 Storage
  → 新 Pinia 实例读取并恢复

测试持久化时,应检查版本不匹配、非法 JSON、字段缺失和存储写入异常,而不只是检查一次 setItem() 是否被调用。


九、常见失败表现与诊断方法

9.1 组件中解构后界面不更新

失败代码:

const store = useCartStore()
const { totalPrice } = store

如果 totalPrice 是响应式 getter,直接解构可能脱离 Store 的响应式代理。应改为:

const { totalPrice } = storeToRefs(store)

Action 则可以直接解构:

const { addItem } = store

诊断方法是先确认:

  1. Store 内部值是否发生变化;
  2. 模板是否读取了 storeToRefs() 返回的 ref;
  3. 是否错误地对 ref 再次使用或遗漏 .value
  4. 是否在测试中启用了 Action Stub。

9.2 在模块顶层使用 Store 报“没有 active Pinia”

典型错误:

getActivePinia() was called but there was no active Pinia

原因通常是调用 useStore() 时,app.use(pinia) 尚未执行。

修复方式:

  • 把调用移动到组件 setup()、路由守卫或函数内部;
  • 在组件外显式传入 pinia
  • 确保测试创建并传入 Testing Pinia。
const pinia = createPinia()
const auth = useAuthStore(pinia)

9.3 持久化数据导致用户状态串号

如果所有账号共用一个键:

pinia:cart

用户 A 退出后,用户 B 可能恢复到用户 A 的购物车。解决方式有两类:

  1. 退出时清除用户相关数据;
  2. 使用账号维度的键:
pinia:cart:user-a
pinia:cart:user-b

如果账号尚未恢复,不能在 userId 未知时盲目读取某个固定键,否则会把匿名状态和登录状态混在一起。


9.4 $subscribe() 没有按预期触发多次

$subscribe() 观察的是 Store 状态变化,而不是任意 JavaScript 语句。以下情况可能造成误解:

  • 修改了未暴露到 Store 的普通变量;
  • 只修改了外部对象,没有把它放入响应式状态;
  • 在测试中修改了错误的 Store 实例;
  • 使用异步流程时,在状态更新前就检查了调用次数。

同时,Pinia 会基于 Vue 的响应式观察调度订阅,因此不能把订阅回调当作“每一行赋值后立即同步执行”的日志钩子。需要逐条捕获 Action 步骤时,应使用 $onAction() 或在 Action 内明确记录。


9.5 localStorage 能保存数据,但生产仍然失败

常见原因包括:

  • SSR 环境不存在 window
  • 存储空间不足;
  • 隐私模式或浏览器策略阻止写入;
  • JSON 序列化遇到不可序列化对象;
  • 数据结构升级后旧数据无法读取;
  • 把敏感凭据放入可被脚本读取的存储;
  • 多标签页同时写入导致覆盖。

因此持久化代码必须具备降级路径:读取失败时使用默认状态,写入失败时保留内存状态,并记录可诊断日志,而不能让整个应用启动失败。


十、生产取舍:哪些能力应该放在哪里

可以用下面的边界判断实现位置:

需求 更合适的机制
多个组件共享响应式数据 Store state / getter
修改必须满足业务规则 Store Action
记录状态变化 $subscribe()
记录 Action 成功或失败 $onAction()
刷新后恢复少量普通状态 自定义持久化或 Pinia 持久化插件
认证安全凭据 优先由服务端和安全 Cookie 设计
用户退出后的清理 各 Store 的显式 reset / logout Action
组件是否调用了 Action createTestingPinia() 默认 Stub
验证 Action 的真实业务逻辑 stubActions: false
多测试之间隔离状态 每个测试创建独立 Pinia

Pinia 的规范保证是其响应式 Store、Action、getter、$patch()$subscribe()$onAction() 以及 Option Store 的 $reset() 等 API 行为。持久化插件、跨标签页同步、数据迁移和全局重置策略不是 Pinia 自动提供的统一业务方案,需要应用自行定义。

真正可靠的 Store 设计最终依赖三个边界:

  1. 状态边界:Store 只拥有明确的业务状态;
  2. 变更边界:复杂状态通过 Action 维护业务不变量;
  3. 生命周期边界:初始化、订阅、持久化、退出和测试都使用明确的 Pinia 实例和清理流程。

当这三个边界清晰时,Store 拆分不会沦为文件拆分,订阅不会变成无差别监听,持久化也不会把不可信的浏览器数据误当成服务端事实。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。