Vue3 Pinia 状态管理完全指南
Vue3 Pinia 状态管理完全指南
概述
Pinia 是 Vue3 官方推荐的新一代状态管理库,Vue 核心团队成员创建,取代了 Vuex 成为 Vue3 项目的默认选择。Pinia 具备完整的 TypeScript 支持、极简的 API 设计、模块化 store、自动按需加载等特性,是 Vue3 中大型项目不可或缺的核心基础设施。
为什么选择 Pinia
相比 Vuex,Pinia 去掉了 mutation(仅保留 action)、去掉了模块嵌套的复杂度(所有 store 都是扁平的)、提供了完整的 TypeScript 类型推导。Actions 可以是同步或异步,API 设计与 Composition API 一脉相承,学习成本低。Pinia 还支持 Vue DevTools 插件和持久化插件生态。
基础语法
import { defineStore } from 'pinia'
// 方式1:选项式 API(类似 Vuex)
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2
},
actions: {
increment() { this.count++ }
}
})
// 方式2:组合式 API(推荐,更灵活)
export const useUserStore = defineStore('user', () => {
const name = ref('')
const token = ref('')
const isLoggedIn = computed(() => !!token.value)
function login(credentials) { /* ... */ }
return { name, token, isLoggedIn, login }
})
完整代码示例
// ========== stores/counter.js ==========
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
// state:类似 data,返回一个函数确保每个调用都是独立状态
state: () => ({
count: 0,
history: [], // 操作历史
lastUpdate: null // 最后更新时间
}),
// getters:类似 computed,自动缓存,依赖响应式数据
getters: {
// 基础 getter:计算翻倍值
doubleCount: (state) => state.count * 2,
// 访问其他 store 的 getter
// 需要在 action 中通过 this.$store.otherStore 访问
// 判断是否为高计数(>10)
isHighCount: (state) => state.count > 10,
// 带参数的 getter(通过返回函数实现)
getHistoryByType: (state) => (type) => {
return state.history.filter(h => h.type === type)
},
// 计算历史平均操作增量
averageIncrement: (state) => {
if (state.history.length === 0) return 0
const increments = state.history.map(h => h.increment)
return increments.reduce((a, b) => a + b, 0) / increments.length
}
},
// actions:类似 methods,支持同步和异步
actions: {
// 同步 action
increment(amount = 1) {
const prev = this.count
this.count += amount
this.lastUpdate = new Date().toISOString()
this.history.push({
type: 'increment',
increment: amount,
prev,
next: this.count,
timestamp: this.lastUpdate
})
},
decrement() {
this.count--
this.lastUpdate = new Date().toISOString()
},
reset() {
this.count = 0
this.history = []
this.lastUpdate = null
},
// 异步 action:模拟 API 调用
async fetchInitialCount() {
try {
// 模拟 500ms 延迟的 API 调用
const response = await new Promise(resolve =>
setTimeout(() => resolve({ data: 42 }), 500)
)
this.count = response.data
this.lastUpdate = new Date().toISOString()
console.log('从服务器同步计数器:', response.data)
} catch (error) {
console.error('同步失败:', error)
}
}
}
})
// ========== stores/user.js(组合式风格)==========
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserStore = defineStore('user', () => {
// —— 状态 ——
const profile = ref({
id: null,
name: '',
email: '',
avatar: '',
roles: [],
preferences: {
theme: 'light',
language: 'zh-CN',
notifications: true
}
})
const token = ref(localStorage.getItem('token') || '')
const isLoading = ref(false)
// —— getters(计算属性)——
const isLoggedIn = computed(() => !!token.value)
const isAdmin = computed(() => profile.value.roles.includes('admin'))
const displayName = computed(() =>
profile.value.name || profile.value.email || '未登录用户'
)
const shortAvatar = computed(() => {
const name = displayName.value
return name.charAt(0).toUpperCase()
})
// —— actions ——
async function login(credentials) {
isLoading.value = true
try {
// 模拟登录 API
const response = await mockLoginAPI(credentials)
token.value = response.token
profile.value = { ...profile.value, ...response.user }
localStorage.setItem('token', response.token)
return { success: true }
} catch (error) {
return { success: false, error: error.message }
} finally {
isLoading.value = false
}
}
function logout() {
token.value = ''
profile.value = { id: null, name: '', email: '', avatar: '', roles: [], preferences: { theme: 'light', language: 'zh-CN', notifications: true } }
localStorage.removeItem('token')
}
async function updatePreferences(updates) {
profile.value.preferences = {
...profile.value.preferences,
...updates
}
// 模拟保存到服务器
await new Promise(r => setTimeout(r, 300))
console.log('偏好设置已更新')
}
// 模拟登录 API
function mockLoginAPI({ username, password }) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (username && password) {
resolve({
token: 'mock-jwt-token-' + Date.now(),
user: {
id: 1,
name: username,
email: username + '@example.com',
avatar: '',
roles: username === 'admin' ? ['admin', 'user'] : ['user'],
preferences: { theme: 'dark', language: 'zh-CN', notifications: true }
}
})
} else {
reject(new Error('用户名或密码错误'))
}
}, 800)
})
}
return {
profile, token, isLoading,
isLoggedIn, isAdmin, displayName, shortAvatar,
login, logout, updatePreferences
}
})
// ========== stores/cart.js(模块化购物车示例)==========
import { defineStore } from 'pinia'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [], // { id, name, price, quantity }
coupon: null,
shippingCost: 0
}),
getters: {
totalItems: (state) => state.items.reduce((sum, item) => sum + item.quantity, 0),
subtotal: (state) => state.items.reduce((sum, item) => sum + item.price * item.quantity, 0),
discount: (state) => {
if (!state.coupon) return 0
if (state.coupon.type === 'percent') {
return subtotal * (state.coupon.value / 100)
}
return state.coupon.value // 固定金额
},
// 依赖其他 getter 需要用 this
total() {
const sub = this.subtotal // 访问当前 store 其他 getter
return sub - this.discount + this.shippingCost
},
isEmpty: (state) => state.items.length === 0
},
actions: {
addItem(product) {
const existing = this.items.find(i => i.id === product.id)
if (existing) {
existing.quantity += 1
} else {
this.items.push({ ...product, quantity: 1 })
}
},
removeItem(productId) {
const index = this.items.findIndex(i => i.id === productId)
if (index !== -1) this.items.splice(index, 1)
},
updateQuantity(productId, quantity) {
const item = this.items.find(i => i.id === productId)
if (item) {
if (quantity <= 0) {
this.removeItem(productId)
} else {
item.quantity = quantity
}
}
},
clearCart() {
this.items = []
this.coupon = null
this.shippingCost = 0
},
async applyCoupon(code) {
// 模拟验证优惠券 API
await new Promise(r => setTimeout(r, 500))
if (code === 'VUE30') {
this.coupon = { code, type: 'percent', value: 30 }
} else if (code === 'FLAT50') {
this.coupon = { code, type: 'fixed', value: 50 }
} else {
throw new Error('无效的优惠券')
}
}
}
})
// ========== App.vue 中使用 ==========
<script setup>
import { useCounterStore } from './stores/counter'
import { useUserStore } from './stores/user'
import { useCartStore } from './stores/cart'
const counter = useCounterStore()
const user = useUserStore()
const cart = useCartStore()
// 直接访问 state(解构时需要 storeToRefs 保持响应式)
import { storeToRefs } from 'pinia'
const { count, doubleCount, isHighCount } = storeToRefs(counter)
const { increment, decrement, reset } = counter
async function handleLogin() {
const result = await user.login({ username: '林小鸣', password: 'pass123' })
if (result.success) {
console.log('登录成功')
}
}
</script>
<template>
<div>
<h2>Pinia 状态管理演示</h2>
<!-- 计数器 -->
<div class="counter">
<p>Count: {{ count }} | Double: {{ doubleCount }}</p>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
<button @click="reset">重置</button>
<button @click="counter.fetchInitialCount">同步服务器</button>
<p v-if="isHighCount" style="color: red;">⚠️ 计数已超过 10</p>
</div>
<!-- 用户 -->
<div class="user">
<p v-if="!user.isLoggedIn">未登录</p>
<div v-else>
<p>欢迎, {{ user.displayName }}</p>
<p>角色: {{ user.profile.roles.join(', ') }}</p>
<p>主题: {{ user.profile.preferences.theme }}</p>
</div>
<button v-if="!user.isLoggedIn" @click="handleLogin">登录</button>
<button v-else @click="user.logout">退出</button>
</div>
<!-- 购物车 -->
<div class="cart">
<p>购物车: {{ cart.totalItems }} 件 | 总价: ¥{{ cart.total }}</p>
<button @click="cart.addItem({ id: 1, name: 'Vue3 实战', price: 88 })">加书</button>
<button @click="cart.clearCart">清空</button>
</div>
</div>
</template>
<style scoped>
.counter, .user, .cart {
margin: 1rem 0;
padding: 1rem;
border: 1px solid #ddd;
border-radius: 8px;
}
button { margin-right: 0.5rem; }
</style>
运行效果说明
计数器面板显示当前值和翻倍值,点击 "+1"/"-1"/"重置" 立即响应。点击"同步服务器"显示加载状态,500ms 后数字变为 42。用户登录后显示用户名和角色,主题设置实时同步到 profile.preferences。购物车添加商品自动累加数量,应用优惠券后总价实时重新计算。
常见问题
Q1:为什么需要 <code>storeToRefs</code>?解构 store 直接用不行吗?
直接解构 const { count } = useCounterStore() 会丢失响应式,因为解构出来的是普通值。storeToRefs 将 store 的每个 state 和 getter 转换为 ref,保证解构后仍然是响应式的。Actions(函数)不需要 storeToRefs,直接解构调用即可。
Q2:Pinia 和 Vuex 能否在同一个项目中混用?
技术上可以,但强烈不建议。Pinia 和 Vuex 各自维护独立的状态树,混用会导致状态不一致、DevTools 追踪混乱。除非有历史包袱需要渐进迁移,否则应选择其一(推荐 Pinia)。
Q3:Pinia 的状态持久化如何实现?
使用 pinia-plugin-persistedstate 插件,将指定 store 的 state 自动同步到 localStorage/ sessionStorage。也可以手动在 action 中 localStorage.setItem('key', JSON.stringify(state)),配合 watch 监听变化自动保存。推荐使用插件方式,更规范且支持选择性持久化(只持久化特定字段)。
延伸阅读
- <a href="#">Vue3 组合式 API 完全指南</a> —— 组合式风格 defineStore 的最佳实践
- <a href="#">Vue3 依赖注入 provide/inject 详解</a> —— provide/inject 与 Pinia 的对比与配合
- <a href="#">Vue3 进阶项目结构设计</a> —— 大型 Vue3 项目中 store 的组织方式与模块划分