换图标应用隐藏
130.52M · 2026-04-16
Pinia 是 Vue 官方推荐的状态管理库,也是 Vuex 的继任者。它具有以下特点:
与 Vuex 对比:
| 特性 | Vuex | Pinia |
|---|---|---|
| Mutations | 必须 | 不需要 |
| 模块嵌套 | 嵌套模块 | 扁平独立 Store |
| TypeScript | 需要额外配置 | 原生支持 |
| Composition API | 不原生支持 | 完整支持 |
| 代码量 | 较多 | 精简 |
npm install pinia
# 或
pnpm add pinia
// 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')
src/
stores/
counter.ts # 计数器 Store
user.ts # 用户 Store
cart.ts # 购物车 Store
components/
App.vue
main.ts
Pinia 提供两种风格,推荐使用 Setup Store(Composition API 风格)。
类比:state → data,getters → computed,actions → methods。
// stores/counter.ts
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Pinia',
}),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
和写 <script setup> 一模一样的体验:
// stores/counter.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
// ref() → state
const count = ref(0)
const name = ref('Pinia')
// computed() → getters
const doubleCount = computed(() => count.value * 2)
// function() → actions
function increment() {
count.value++
}
// 必须 return 所有需要被追踪的属性
return { count, name, doubleCount, increment }
})
// stores/user.ts
import { defineStore } from 'pinia'
interface UserInfo {
id: number
name: string
email: string
}
export const useUserStore = defineStore('user', {
state: () => ({
userList: [] as UserInfo[],
currentUser: null as UserInfo | null,
isLoading: false,
}),
})
const store = useUserStore()
// 直接赋值(Pinia 允许直接修改,无需 mutations)
store.isLoading = true
store.currentUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
当需要同时修改多个属性时,用 $patch 更高效(只触发一次响应):
// 对象语法
store.$patch({
isLoading: false,
currentUser: { id: 1, name: 'Alice', email: 'alice@example.com' },
})
// 函数语法(适合复杂操作,如数组 push)
store.$patch((state) => {
state.userList.push({ id: 2, name: 'Bob', email: 'bob@example.com' })
state.isLoading = false
})
Option Store 内置 $reset():
store.$reset() // 恢复到初始状态
Setup Store 需要手动实现:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
function $reset() {
count.value = 0
}
return { count, $reset }
})
// 类似 watch,但专为 Store 设计
store.$subscribe((mutation, state) => {
// mutation.type: 'direct' | 'patch object' | 'patch function'
// mutation.storeId: Store 的 id
console.log(`[${mutation.type}] storeId: ${mutation.storeId}`)
// 将状态持久化到 localStorage
localStorage.setItem('user', JSON.stringify(state))
})
// 组件卸载后仍然保持订阅(默认会随组件卸载而销毁)
store.$subscribe(callback, { detached: true })
Getters 等同于 Vue 的 computed,会自动缓存结果。
export const useCartStore = defineStore('cart', {
state: () => ({
items: [] as { name: string; price: number; qty: number }[],
}),
getters: {
// 参数 state,自动推断类型
totalPrice: (state) =>
state.items.reduce((sum, item) => sum + item.price * item.qty, 0),
// 访问其他 getter,需要用 this(需手动标注返回类型)
formattedTotal(): string {
return `¥${this.totalPrice.toFixed(2)}`
},
},
})
Getter 返回一个函数(注意:带参数的 Getter 不会缓存):
getters: {
getItemByName: (state) => {
return (name: string) => state.items.find((item) => item.name === name)
},
// 手动缓存:先过滤再查找
getActiveItem(state) {
const activeItems = state.items.filter((i) => i.qty > 0) // 缓存这部分
return (name: string) => activeItems.find((i) => i.name === name)
},
},
<script setup>
const cart = useCartStore()
const apple = cart.getItemByName('苹果')
</script>
import { useUserStore } from './user'
getters: {
personalizedGreeting(state) {
const userStore = useUserStore()
return `你好,${userStore.currentUser?.name}!你有 ${state.items.length} 件商品`
},
},
Actions 既可以是同步的,也可以是异步的,直接替代了 Vuex 中的 mutations + actions 两层结构。
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
increment() {
this.count++
},
incrementBy(amount: number) {
this.count += amount
},
},
})
export const useUserStore = defineStore('user', {
state: () => ({
currentUser: null as UserInfo | null,
isLoading: false,
error: null as string | null,
}),
actions: {
async fetchUser(userId: number) {
this.isLoading = true
this.error = null
try {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) throw new Error('请求失败')
this.currentUser = await response.json()
}
catch (e) {
this.error = (e as Error).message
}
finally {
this.isLoading = false
}
},
},
})
import { useAuthStore } from './auth'
actions: {
async loadProfile() {
const auth = useAuthStore()
if (!auth.isLoggedIn) return
// SSR 注意:在 await 之前调用所有 useStore()
this.profile = await fetchProfile(auth.userId)
},
},
const unsubscribe = store.$onAction(({ name, args, after, onError }) => {
console.log(`调用了 action: ${name},参数:`, args)
after((result) => {
console.log(`${name} 执行成功,结果:`, result)
})
onError((error) => {
console.error(`${name} 执行失败:`, error)
})
})
// 手动取消订阅
unsubscribe()
// 组件卸载后仍保持订阅
store.$onAction(callback, true)
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
</script>
<template>
<div>
<p>Count: {{ counter.count }}</p>
<p>Double: {{ counter.doubleCount }}</p>
<button @click="counter.increment">+1</button>
</div>
</template>
直接解构 store 会丢失响应性,必须用 storeToRefs:
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
// 错误:count 和 doubleCount 失去响应性
const { count, doubleCount } = counter
// 正确:state 和 getter 用 storeToRefs 解构
const { count, doubleCount } = storeToRefs(counter)
// 正确:actions 可以直接解构(它们不是响应式数据)
const { increment } = counter
</script>
<template>
<!-- v-model 直接绑定 store 中的状态 -->
<input v-model="userStore.searchKeyword" placeholder="搜索..." />
</template>
import { mapState, mapWritableState, mapActions } from 'pinia'
import { useCounterStore } from '@/stores/counter'
export default {
computed: {
// 只读映射(state + getters)
...mapState(useCounterStore, ['count', 'doubleCount']),
// 可写映射(state)
...mapWritableState(useCounterStore, ['count']),
},
methods: {
...mapActions(useCounterStore, ['increment']),
},
}
合理拆分 Store,通过在 action 或 getter 中引用其他 store:
// stores/order.ts
import { defineStore } from 'pinia'
import { useCartStore } from './cart'
import { useUserStore } from './user'
export const useOrderStore = defineStore('order', {
state: () => ({
orders: [] as Order[],
}),
actions: {
async submitOrder() {
const cart = useCartStore()
const user = useUserStore()
const order = await api.createOrder({
userId: user.currentUser!.id,
items: cart.items,
total: cart.totalPrice,
})
this.orders.push(order)
cart.$reset() // 下单后清空购物车
},
},
})
如果两个 Store 互相引用,可以将共享逻辑提取到第三个 Store 或普通的 composable 中:
userStore → authStore → userStore (循环)
authStore → userStore (单向)
sessionComposable(普通函数,不是 Store)
插件可以为所有 Store 添加全局属性、方法或订阅行为。
// plugins/persistPlugin.ts
import type { PiniaPlugin } from 'pinia'
export const persistPlugin: PiniaPlugin = ({ store }) => {
// 从 localStorage 恢复状态
const saved = localStorage.getItem(store.$id)
if (saved) {
store.$patch(JSON.parse(saved))
}
// 变化,自动持久化
store.$subscribe((_mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
}
// main.ts
import { createPinia } from 'pinia'
import { persistPlugin } from './plugins/persistPlugin'
const pinia = createPinia()
pinia.use(persistPlugin)
// 扩展 PiniaCustomProperties 接口
declare module 'pinia' {
export interface PiniaCustomProperties {
$persistedAt: Date
}
}
npm install -D @pinia/testing vitest
// stores/counter.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from './counter'
describe('useCounterStore', () => {
beforeEach(() => {
// 每次测试前创建新的 Pinia 实例,避免测试间状态污染
setActivePinia(createPinia())
})
it('初始 count 为 0', () => {
const store = useCounterStore()
expect(store.count).toBe(0)
})
it('increment 使 count 加 1', () => {
const store = useCounterStore()
store.increment()
expect(store.count).toBe(1)
})
it('doubleCount 是 count 的两倍', () => {
const store = useCounterStore()
store.count = 5
expect(store.doubleCount).toBe(10)
})
})
// components/Counter.test.ts
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import Counter from './Counter.vue'
import { useCounterStore } from '@/stores/counter'
it('点击按钮调用 increment', async () => {
const wrapper = mount(Counter, {
global: {
plugins: [
createTestingPinia({
// Actions 默认被替换为 spy(不真正执行)
createSpy: vi.fn,
initialState: {
counter: { count: 10 }, // 设置初始状态
},
}),
],
},
})
const store = useCounterStore()
await wrapper.find('button').trigger('click')
// 验证 action 被调用
expect(store.increment).toHaveBeenCalledOnce()
})
按业务领域划分 Store(user、cart、product)
每个 Store 职责单一,不要把所有状态塞到一个 Store
优先使用 Setup Store(更灵活,可使用 composables 和 watchers)
Store 只存放全局共享状态,局部组件状态用 ref/reactive
解构 state/getter 时使用 storeToRefs()
actions 可以直接解构,无需 storeToRefs
批量修改状态时用 $patch,减少响应触发次数
在 SSR 中,await 之前提前调用所有 useStore()
避免 Store 之间循环引用
不要在模块顶层(组件外)调用 useStore(),应在函数内部调用
敏感数据(token、密钥)不要存入 Store,避免 DevTools 泄露
| 需求 | 方案 |
|---|---|
| 修改单个属性 | store.count = 1 |
| 修改多个属性 | store.$patch({ ... }) |
| 复杂修改(数组等) | store.$patch(state => { ... }) |
| 重置到初始状态 | store.$reset() |
| 订阅状态变化 | store.$subscribe(cb) |
| 订阅 Action 调用 | store.$onAction(cb) |
| 解构保持响应性 | storeToRefs(store) |