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 支持两种主要写法:
- Option Store:接近 Vuex 或传统 Options API;
- Setup Store:使用
ref、computed和普通函数。
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
这会引入两个额外问题:
- 存储格式是否稳定;
- 外部数据是否可信。
因此,持久化不能简单理解为“把整个 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() 写入新状态
关键路径是:
- Store 先创建默认状态;
- 持久化插件读取外部数据;
- 外部数据通过
$patch()合并; - 组件获得恢复后的响应式状态;
- 后续变化通过订阅写回存储。
如果在恢复完成前渲染依赖数据的页面,就可能出现短暂的默认状态闪烁。对于购物车,通常可以接受;对于用户权限和路由访问控制,则不能仅依赖异步恢复后的 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/testing 的 createTestingPinia()。
安装:
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
诊断方法是先确认:
- Store 内部值是否发生变化;
- 模板是否读取了
storeToRefs()返回的 ref; - 是否错误地对 ref 再次使用或遗漏
.value; - 是否在测试中启用了 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 的购物车。解决方式有两类:
- 退出时清除用户相关数据;
- 使用账号维度的键:
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 设计最终依赖三个边界:
- 状态边界:Store 只拥有明确的业务状态;
- 变更边界:复杂状态通过 Action 维护业务不变量;
- 生命周期边界:初始化、订阅、持久化、退出和测试都使用明确的 Pinia 实例和清理流程。
当这三个边界清晰时,Store 拆分不会沦为文件拆分,订阅不会变成无差别监听,持久化也不会把不可信的浏览器数据误当成服务端事实。
系列导航与关联阅读
- 系列入口:Vue 完整学习路线:从响应式与组件到工程化、SSR 和生产交付
- 上一篇:Vue 登录与权限:路由、按钮、Token 刷新、403 和状态恢复
- 下一篇:Vue Query 数据状态:缓存键、失效、乐观更新、分页和 SSR
官方资料
本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论