Pinia 状态管理
Pinia 是 Vue 3 官方推荐的状态管理库,它提供了简洁的 API、完整的 TypeScript 支持和优秀的开发体验。相比 Vuex,Pinia 更加轻量、直观,并且完美契合 Vue 3 的组合式 API。
基础概念
什么是状态管理?
当应用变得复杂时,多个组件需要共享同一份状态,这时就需要状态管理工具来集中管理这些状态:
┌─────────────────────────────────────────────────────────┐
│ Pinia 工作原理 │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │Component│ │Component│ │Component│ │
│ │ A │ │ B │ │ C │ │
│ └────┬────┘ └────┬────┘ └────┬────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Pinia │ 集中管理状态 │
│ │ Store │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
为什么选择 Pinia?
| 特性 | Pinia | Vuex |
|---|---|---|
| API 复杂度 | 简洁直观 | 相对复杂 |
| TypeScript 支持 | ✅ 完美支持 | 需要额外配置 |
| 模块化 | 无需嵌套模块 | 需要 modules |
| Mutations | 不需要 | 必须使用 |
| Composition API | ✅ 完美契合 | 支持有限 |
| 开发工具 | ✅ Vue DevTools | ✅ Vue DevTools |
| 代码分割 | ✅ 自动支持 | 需要配置 |
安装 Pinia
npm install pinia
// main.js
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')
创建 Store
Pinia 提供两种定义 Store 的方式:选项式 Store 和组合式 Store。
选项式 Store
类似于 Vue 的选项式 API,包含 state、getters 和 actions:
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
// state:状态(必须是返回初始状态的函数)
state: () => ({
count: 0,
name: '计数器',
items: []
}),
// getters:计算属性
getters: {
doubleCount: (state) => state.count * 2,
// 访问其他 getter
doubleCountPlusOne() {
return this.doubleCount + 1
}
},
// actions:方法(可以是异步的)
actions: {
increment() {
this.count++
},
async fetchItems() {
const response = await fetch('/api/items')
this.items = await response.json()
}
}
})
组合式 Store
类似于 Vue 的组合式 API,使用 ref 和 computed:
// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// state
const name = ref('')
const token = ref('')
// getters
const isLoggedIn = computed(() => !!token.value)
const userInfo = computed(() => ({ name: name.value }))
// actions
function login(userData) {
name.value = userData.name
token.value = userData.token
}
function logout() {
name.value = ''
token.value = ''
}
// 必须返回所有要暴露的属性和方法
return {
name,
token,
isLoggedIn,
userInfo,
login,
logout
}
})
两种方式对比
| 特性 | 选项式 Store | 组合式 Store |
|---|---|---|
| 风格 | 类似 Options API | 类似 Composition API |
| 灵活性 | 结构固定 | 更灵活 |
| TypeScript | 需要额外类型声明 | 自动推断 |
| $reset() | ✅ 内置支持 | 需要自己实现 |
| 适用场景 | 简单状态 | 复杂逻辑、组合函数 |
使用 Store
基本使用
<script setup>
import { useCounterStore } from './stores/counter'
const counterStore = useCounterStore()
// 访问 state
console.log(counterStore.count)
// 访问 getters
console.log(counterStore.doubleCount)
// 调用 actions
counterStore.increment()
</script>
<template>
<div>
<p>计数: {{ counterStore.count }}</p>
<p>双倍: {{ counterStore.doubleCount }}</p>
<button @click="counterStore.increment">增加</button>
</div>
</template>
storeToRefs
使用 storeToRefs 解构 state 和 getters,保持响应式:
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from './stores/counter'
const counterStore = useCounterStore()
// ✅ 正确:使用 storeToRefs 保持响应式
const { count, name, doubleCount } = storeToRefs(counterStore)
// ✅ actions 不需要 storeToRefs
const { increment, fetchItems } = counterStore
// ❌ 错误:直接解构会丢失响应式
// const { count, name } = counterStore
</script>
<template>
<div>
<p>计数: {{ count }}</p>
<p>双倍: {{ doubleCount }}</p>
<button @click="increment">增加</button>
</div>
</template>
State 状态
访问和修改 State
const store = useCounterStore()
// 读取状态
console.log(store.count)
console.log(store.$state.count)
// 直接修改状态
store.count++
// 可以直接绑定到 v-model
<template>
<input v-model="store.count" type="number" />
</template>
$patch 批量修改
使用 $patch 可以批量修改多个状态:
const store = useCounterStore()
// 对象语法:批量修改
store.$patch({
count: store.count + 1,
name: '新名称',
items: [...store.items, { id: 1 }]
})
// 函数语法:适合复杂修改(推荐)
store.$patch((state) => {
state.items.push({ name: '新商品', quantity: 1 })
state.count++
state.hasChanged = true
})
$patch 的优势:
- 批量修改只触发一次响应式更新,性能更好
- 在 Vue DevTools 中只记录一次变更,便于调试
- 函数语法可以访问当前状态,适合复杂逻辑
$reset 重置状态
将状态重置为初始值(仅选项式 Store 内置):
const store = useCounterStore()
// 重置到初始状态
store.$reset()
组合式 Store 需要自己实现 $reset:
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('计数器')
// 自己实现 reset
function $reset() {
count.value = 0
name.value = '计数器'
}
return { count, name, $reset }
})
$subscribe 订阅状态变化
监听状态变化,类似于 Vuex 的 subscribe:
const store = useCounterStore()
// 订阅状态变化
store.$subscribe((mutation, state) => {
// mutation.type: 变更类型
// - 'direct': 直接修改 state.count = 1
// - 'patch object': 使用 $patch({ count: 1 })
// - 'patch function': 使用 $patch(state => { state.count = 1 })
console.log('变更类型:', mutation.type)
console.log('Store ID:', mutation.storeId)
// 当使用 patch object 时,可以获取修改的内容
if (mutation.type === 'patch object') {
console.log('修改内容:', mutation.payload)
}
// state 是最新的状态
console.log('当前状态:', state)
// 持久化到 localStorage
localStorage.setItem('cart', JSON.stringify(state))
})
订阅选项:
// 立即同步执行(默认是异步)
store.$subscribe(callback, { flush: 'sync' })
// 组件卸载后仍然保持订阅
store.$subscribe(callback, { detached: true })
$state 替换整个状态
// 替换整个状态(内部调用 $patch)
store.$state = {
count: 100,
name: '新计数器',
items: []
}
Getters 计算属性
基本用法
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
items: [
{ name: '苹果', price: 5, quantity: 2 },
{ name: '香蕉', price: 3, quantity: 1 }
]
}),
getters: {
// 基本用法
doubleCount: (state) => state.count * 2,
// 访问其他 getter(使用 this)
doubleCountPlusOne() {
return this.doubleCount + 1
},
// 使用箭头函数访问其他 getter
// 需要通过返回函数的方式
getItemByName: (state) => (name) => {
return state.items.find(item => item.name === name)
}
}
})
传递参数
getters: {
// 返回一个函数来接收参数
getUserById: (state) => (id) => {
return state.users.find(user => user.id === id)
},
// 组合使用
getItemsByCategory: (state) => (category) => {
return state.items.filter(item => item.category === category)
}
}
<script setup>
const store = useStore()
// 调用时传递参数
const user = store.getUserById(1)
const items = store.getItemsByCategory('electronics')
</script>
访问其他 Store
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', {
state: () => ({ items: [] }),
getters: {
summary() {
const userStore = useUserStore()
return `${userStore.name} 的购物车有 ${this.items.length} 件商品`
}
}
})
Actions 方法
Actions 是修改状态的主要方式,支持同步和异步操作。
基本用法
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
actions: {
// 同步操作
increment() {
this.count++
},
incrementBy(amount) {
this.count += amount
},
// 异步操作
async fetchCount() {
const response = await fetch('/api/count')
const data = await response.json()
this.count = data.count
}
}
})
组合式 Store 的 Actions
export const useUserStore = defineStore('user', () => {
const user = ref(null)
const token = ref('')
async function login(credentials) {
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
const data = await response.json()
user.value = data.user
token.value = data.token
// 可以调用其他 action
saveToLocalStorage()
} catch (error) {
throw error
}
}
function logout() {
user.value = null
token.value = ''
localStorage.removeItem('token')
}
function saveToLocalStorage() {
localStorage.setItem('token', token.value)
}
return { user, token, login, logout }
})
访问其他 Store
import { useAuthStore } from './auth'
export const useSettingsStore = defineStore('settings', {
state: () => ({ preferences: null }),
actions: {
async fetchUserPreferences() {
const authStore = useAuthStore()
if (!authStore.isLoggedIn) {
throw new Error('用户未登录')
}
this.preferences = await fetchPreferences(authStore.user.id)
}
}
})
$onAction 订阅 Actions
监听 action 的调用、成功和失败:
const store = useCounterStore()
// 订阅 action 调用
const unsubscribe = store.$onAction(
({
name, // action 名称
store, // store 实例
args, // 传递给 action 的参数数组
after, // action 成功后的回调
onError // action 失败后的回调
}) => {
console.log(`调用 action: ${name}`)
console.log(`参数:`, args)
// action 成功完成(包括 Promise resolve)
after((result) => {
console.log(`action ${name} 完成,结果:`, result)
})
// action 抛出错误或 Promise reject
onError((error) => {
console.error(`action ${name} 失败:`, error)
})
}
)
// 取消订阅
unsubscribe()
实际应用:错误处理和日志
// 在应用入口设置全局 action 监听
const store = useUserStore()
store.$onAction(
({
name,
args,
after,
onError
}) => {
const startTime = Date.now()
console.log(`[${new Date().toISOString()}] 开始执行 ${name}`)
after((result) => {
console.log(`[${new Date().toISOString()}] ${name} 完成,耗时 ${Date.now() - startTime}ms`)
})
onError((error) => {
console.error(`[${new Date().toISOString()}] ${name} 失败:`, error)
// 上报错误到监控系统
reportError(error)
})
},
true // 组件卸载后仍然保持订阅
)
插件
Pinia 插件可以扩展 Store 的功能。
创建插件
// plugins/persist.js
export function piniaPersistedState({ store }) {
// 从 localStorage 恢复状态
const saved = localStorage.getItem(store.$id)
if (saved) {
store.$patch(JSON.parse(saved))
}
// 监听状态变化,保存到 localStorage
store.$subscribe((mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
}
// main.js
const pinia = createPinia()
pinia.use(piniaPersistedState)
插件选项
export function myPiniaPlugin(options) {
return ({ store }) => {
if (options.stores?.includes(store.$id)) {
// 只对指定的 store 应用插件
}
}
}
pinia.use(myPiniaPlugin({ stores: ['user', 'cart'] }))
添加全局属性
pinia.use(({ store }) => {
// 给所有 store 添加全局方法
store.$log = (message) => {
console.log(`[${store.$id}] ${message}`)
}
})
// 使用
counterStore.$log('初始化完成')
模块化组织
按功能划分
stores/
├── index.js # 导出所有 store
├── user.js # 用户相关
├── cart.js # 购物车相关
├── product.js # 产品相关
└── order.js # 订单相关
// stores/user.js
export const useUserStore = defineStore('user', { ... })
// stores/cart.js
export const useCartStore = defineStore('cart', { ... })
// stores/index.js
export { useUserStore } from './user'
export { useCartStore } from './cart'
统一导出使用
// 在组件中
import { useUserStore, useCartStore } from '@/stores'
实际示例:购物车
// stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
// state
const items = ref([])
// getters
const totalItems = computed(() =>
items.value.reduce((sum, item) => sum + item.quantity, 0)
)
const totalPrice = computed(() =>
items.value.reduce((sum, item) => sum + item.price * item.quantity, 0)
)
const isEmpty = computed(() => items.value.length === 0)
// actions
function addItem(product) {
const existingItem = items.value.find(item => item.id === product.id)
if (existingItem) {
existingItem.quantity++
} else {
items.value.push({
id: product.id,
name: product.name,
price: product.price,
quantity: 1
})
}
}
function removeItem(productId) {
const index = items.value.findIndex(item => item.id === productId)
if (index > -1) {
items.value.splice(index, 1)
}
}
function updateQuantity(productId, quantity) {
const item = items.value.find(item => item.id === productId)
if (item) {
if (quantity <= 0) {
removeItem(productId)
} else {
item.quantity = quantity
}
}
}
function clearCart() {
items.value = []
}
function $reset() {
items.value = []
}
return {
items,
totalItems,
totalPrice,
isEmpty,
addItem,
removeItem,
updateQuantity,
clearCart,
$reset
}
})
<!-- Cart.vue -->
<script setup>
import { useCartStore } from '@/stores/cart'
import { storeToRefs } from 'pinia'
const cartStore = useCartStore()
const { items, totalItems, totalPrice, isEmpty } = storeToRefs(cartStore)
const { addItem, removeItem, updateQuantity, clearCart } = cartStore
</script>
<template>
<div class="cart">
<h2>购物车</h2>
<div v-if="isEmpty" class="empty">购物车为空</div>
<div v-else>
<div v-for="item in items" :key="item.id" class="cart-item">
<span class="name">{{ item.name }}</span>
<span class="price">¥{{ item.price }}</span>
<input
type="number"
:value="item.quantity"
@input="updateQuantity(item.id, Number($event.target.value))"
min="1"
/>
<button @click="removeItem(item.id)">删除</button>
</div>
<div class="summary">
<p>总数量: {{ totalItems }}</p>
<p>总价: ¥{{ totalPrice }}</p>
<button @click="clearCart">清空购物车</button>
</div>
</div>
</div>
</template>
<style scoped>
.cart {
max-width: 500px;
margin: 0 auto;
}
.cart-item {
display: flex;
gap: 10px;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid #eee;
}
.empty {
text-align: center;
color: #999;
padding: 40px;
}
.summary {
margin-top: 20px;
padding: 15px;
background: #f5f5f5;
border-radius: 4px;
}
</style>
TypeScript 支持
定义类型
// types/user.ts
export interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
// stores/user.ts
import { defineStore } from 'pinia'
import type { User } from '@/types/user'
export const useUserStore = defineStore('user', {
state: () => ({
user: null as User | null,
token: '' as string
}),
getters: {
isLoggedIn: (state) => !!state.token,
isAdmin: (state) => state.user?.role === 'admin'
},
actions: {
setUser(user: User) {
this.user = user
}
}
})
组合式 Store 的类型
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { User } from '@/types/user'
export const useUserStore = defineStore('user', () => {
// 类型会自动推断
const user = ref<User | null>(null)
const token = ref('')
const isLoggedIn = computed(() => !!token.value)
async function login(email: string, password: string): Promise<void> {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
})
const data = await response.json()
user.value = data.user
token.value = data.token
}
return { user, token, isLoggedIn, login }
})
调试技巧
使用 Vue DevTools
Pinia 完美支持 Vue DevTools,可以:
- 查看所有 Store 的状态
- 追踪状态变化历史
- 时间旅行调试
- 直接修改状态
添加调试日志
// 开发环境添加调试
if (import.meta.env.DEV) {
const pinia = createPinia()
pinia.use(({ store }) => {
store.$subscribe((mutation, state) => {
console.log(`[${store.$id}]`, mutation.type, state)
})
store.$onAction(({ name, args, after, onError }) => {
console.log(`[${store.$id}] Action: ${name}`, args)
after((result) => {
console.log(`[${store.$id}] Action ${name} result:`, result)
})
onError((error) => {
console.error(`[${store.$id}] Action ${name} error:`, error)
})
})
})
}
小结
本章我们详细学习了 Pinia 状态管理的完整内容:
- Pinia 基础:安装和基本概念
- 创建 Store:选项式和组合式两种方式
- State 管理:访问、修改、reset、$subscribe
- Getters:计算属性的定义和使用
- Actions:同步和异步操作
- $onAction:监听 action 的调用和结果
- 插件系统:扩展 Store 功能
- TypeScript:完整的类型支持
- 实际示例:购物车的完整实现
最佳实践总结:
- 使用组合式 Store 更好地配合 Composition API
- 使用
$patch批量修改状态 - 使用
storeToRefs解构保持响应式 - Actions 中处理异步操作
- 使用插件实现持久化等功能
- 开发环境添加调试日志
练习
- 创建一个用户认证 Store,包含登录、登出、token 管理
- 创建一个待办事项 Store,支持增删改查和筛选
- 实现一个带持久化的主题切换 Store
- 使用 $onAction 实现全局错误处理