Pinia 状态管理
Pinia 是 Vue 的官方状态管理库,它是 Vuex 的继任者。Pinia 提供了更简单的 API、更好的 TypeScript 支持和更直观的开发体验。
什么是 Pinia?
Pinia 是一个轻量级的状态管理库,具有以下特点:
- 🍍 直观的 API
- 🔥 热模块替换
- 🛠 时间旅行调试
- 🏪 模块化设计
- 📦 极小的包体积
- 🦾 完整的 TypeScript 支持
安装和设置
安装
bash
npm install pinia
# 或
yarn add pinia
# 或
pnpm add pinia
基本设置
javascript
// 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
基本 Store
javascript
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 组合式 API 风格
export const useCounterStore = defineStore('counter', () => {
// state
const count = ref(0)
const name = ref('Eduardo')
// getters
const doubleCount = computed(() => count.value * 2)
// actions
function increment() {
count.value++
}
function reset() {
count.value = 0
}
return {
count,
name,
doubleCount,
increment,
reset
}
})
// 选项式 API 风格
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Eduardo'
}),
getters: {
doubleCount: (state) => state.count * 2,
// 使用其他 getter
doubleCountPlusOne() {
return this.doubleCount + 1
},
// 传递参数的 getter
getUserById: (state) => {
return (userId) => state.users.find((user) => user.id === userId)
}
},
actions: {
increment() {
this.count++
},
reset() {
this.count = 0
},
async fetchUserData(userId) {
try {
const user = await api.getUser(userId)
this.userData = user
} catch (error) {
console.error('Failed to fetch user:', error)
throw error
}
}
}
})
在组件中使用 Store
vue
<template>
<div class="counter">
<h2>计数器: {{ counter.count }}</h2>
<p>双倍计数: {{ counter.doubleCount }}</p>
<p>用户: {{ counter.name }}</p>
<div class="buttons">
<button @click="counter.increment()">增加</button>
<button @click="counter.reset()">重置</button>
<button @click="changeName">改名</button>
</div>
<!-- 解构使用 -->
<div class="destructured">
<h3>解构后的值</h3>
<p>计数: {{ count }}</p>
<p>双倍: {{ doubleCount }}</p>
<button @click="increment">增加</button>
</div>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
// 解构 store(保持响应性)
const { count, doubleCount } = storeToRefs(counter)
const { increment, reset } = counter
function changeName() {
counter.name = 'New Name'
}
</script>
<style scoped>
.counter {
padding: 20px;
border: 1px solid #ddd;
border-radius: 8px;
margin: 20px;
}
.buttons {
margin: 15px 0;
}
.buttons button {
margin-right: 10px;
padding: 8px 16px;
background-color: #42b883;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.buttons button:hover {
background-color: #369870;
}
.destructured {
margin-top: 20px;
padding: 15px;
background-color: #f5f5f5;
border-radius: 4px;
}
</style>
实际应用示例
用户认证 Store
javascript
// stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authAPI } from '@/api/auth'
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref(null)
const token = ref(localStorage.getItem('token'))
const isLoading = ref(false)
const error = ref(null)
// Getters
const isAuthenticated = computed(() => !!token.value && !!user.value)
const userRole = computed(() => user.value?.role || 'guest')
const hasPermission = computed(() => {
return (permission) => {
if (!user.value) return false
return user.value.permissions?.includes(permission) || false
}
})
// Actions
async function login(credentials) {
isLoading.value = true
error.value = null
try {
const response = await authAPI.login(credentials)
token.value = response.token
user.value = response.user
// 保存到本地存储
localStorage.setItem('token', response.token)
localStorage.setItem('user', JSON.stringify(response.user))
return response
} catch (err) {
error.value = err.message || '登录失败'
throw err
} finally {
isLoading.value = false
}
}
async function logout() {
try {
if (token.value) {
await authAPI.logout()
}
} catch (err) {
console.error('Logout error:', err)
} finally {
// 清理状态
user.value = null
token.value = null
error.value = null
// 清理本地存储
localStorage.removeItem('token')
localStorage.removeItem('user')
}
}
async function fetchUser() {
if (!token.value) return
isLoading.value = true
try {
const userData = await authAPI.getProfile()
user.value = userData
} catch (err) {
console.error('Failed to fetch user:', err)
// 如果获取用户信息失败,可能 token 已过期
await logout()
} finally {
isLoading.value = false
}
}
async function updateProfile(profileData) {
isLoading.value = true
error.value = null
try {
const updatedUser = await authAPI.updateProfile(profileData)
user.value = { ...user.value, ...updatedUser }
// 更新本地存储
localStorage.setItem('user', JSON.stringify(user.value))
return updatedUser
} catch (err) {
error.value = err.message || '更新失败'
throw err
} finally {
isLoading.value = false
}
}
// 初始化(从本地存储恢复状态)
function initialize() {
const savedUser = localStorage.getItem('user')
if (savedUser) {
try {
user.value = JSON.parse(savedUser)
} catch (err) {
console.error('Failed to parse saved user:', err)
localStorage.removeItem('user')
}
}
// 如果有 token,获取最新用户信息
if (token.value) {
fetchUser()
}
}
return {
// State
user,
token,
isLoading,
error,
// Getters
isAuthenticated,
userRole,
hasPermission,
// Actions
login,
logout,
fetchUser,
updateProfile,
initialize
}
})
购物车 Store
javascript
// stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
// State
const items = ref([])
const isLoading = ref(false)
const discountCode = ref('')
const discountAmount = ref(0)
// Getters
const itemCount = computed(() => {
return items.value.reduce((total, item) => total + item.quantity, 0)
})
const subtotal = computed(() => {
return items.value.reduce((total, item) => {
return total + (item.price * item.quantity)
}, 0)
})
const total = computed(() => {
return Math.max(0, subtotal.value - discountAmount.value)
})
const isEmpty = computed(() => items.value.length === 0)
const getItemById = computed(() => {
return (id) => items.value.find(item => item.id === id)
})
// Actions
function addItem(product, quantity = 1) {
const existingItem = items.value.find(item => item.id === product.id)
if (existingItem) {
existingItem.quantity += quantity
} else {
items.value.push({
id: product.id,
name: product.name,
price: product.price,
image: product.image,
quantity: quantity
})
}
// 保存到本地存储
saveToLocalStorage()
}
function removeItem(productId) {
const index = items.value.findIndex(item => item.id === productId)
if (index > -1) {
items.value.splice(index, 1)
saveToLocalStorage()
}
}
function updateQuantity(productId, quantity) {
const item = items.value.find(item => item.id === productId)
if (item) {
if (quantity <= 0) {
removeItem(productId)
} else {
item.quantity = quantity
saveToLocalStorage()
}
}
}
function clearCart() {
items.value = []
discountCode.value = ''
discountAmount.value = 0
saveToLocalStorage()
}
async function applyDiscount(code) {
isLoading.value = true
try {
// 模拟 API 调用
const response = await fetch('/api/discount/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, subtotal: subtotal.value })
})
if (response.ok) {
const data = await response.json()
discountCode.value = code
discountAmount.value = data.discount
return { success: true, discount: data.discount }
} else {
throw new Error('无效的优惠码')
}
} catch (error) {
return { success: false, error: error.message }
} finally {
isLoading.value = false
}
}
function removeDiscount() {
discountCode.value = ''
discountAmount.value = 0
}
function saveToLocalStorage() {
localStorage.setItem('cart', JSON.stringify({
items: items.value,
discountCode: discountCode.value,
discountAmount: discountAmount.value
}))
}
function loadFromLocalStorage() {
const saved = localStorage.getItem('cart')
if (saved) {
try {
const data = JSON.parse(saved)
items.value = data.items || []
discountCode.value = data.discountCode || ''
discountAmount.value = data.discountAmount || 0
} catch (error) {
console.error('Failed to load cart from localStorage:', error)
}
}
}
return {
// State
items,
isLoading,
discountCode,
discountAmount,
// Getters
itemCount,
subtotal,
total,
isEmpty,
getItemById,
// Actions
addItem,
removeItem,
updateQuantity,
clearCart,
applyDiscount,
removeDiscount,
loadFromLocalStorage
}
})
使用购物车 Store
vue
<template>
<div class="shopping-cart">
<h2>购物车 ({{ cart.itemCount }})</h2>
<div v-if="cart.isEmpty" class="empty-cart">
<p>购物车是空的</p>
<router-link to="/products" class="btn btn-primary">
去购物
</router-link>
</div>
<div v-else>
<!-- 商品列表 -->
<div class="cart-items">
<div
v-for="item in cart.items"
:key="item.id"
class="cart-item"
>
<img :src="item.image" :alt="item.name" class="item-image">
<div class="item-details">
<h3>{{ item.name }}</h3>
<p class="item-price">¥{{ item.price }}</p>
</div>
<div class="quantity-controls">
<button
@click="cart.updateQuantity(item.id, item.quantity - 1)"
:disabled="item.quantity <= 1"
>
-
</button>
<span class="quantity">{{ item.quantity }}</span>
<button @click="cart.updateQuantity(item.id, item.quantity + 1)">
+
</button>
</div>
<div class="item-total">
¥{{ (item.price * item.quantity).toFixed(2) }}
</div>
<button
@click="cart.removeItem(item.id)"
class="remove-btn"
>
删除
</button>
</div>
</div>
<!-- 优惠码 -->
<div class="discount-section">
<div v-if="!cart.discountCode" class="discount-input">
<input
v-model="discountInput"
placeholder="输入优惠码"
@keyup.enter="applyDiscount"
>
<button
@click="applyDiscount"
:disabled="!discountInput || cart.isLoading"
>
{{ cart.isLoading ? '验证中...' : '应用' }}
</button>
</div>
<div v-else class="discount-applied">
<span>优惠码: {{ cart.discountCode }}</span>
<span class="discount-amount">-¥{{ cart.discountAmount }}</span>
<button @click="cart.removeDiscount()" class="remove-discount">
移除
</button>
</div>
</div>
<!-- 总计 -->
<div class="cart-summary">
<div class="summary-line">
<span>小计:</span>
<span>¥{{ cart.subtotal.toFixed(2) }}</span>
</div>
<div v-if="cart.discountAmount > 0" class="summary-line discount">
<span>优惠:</span>
<span>-¥{{ cart.discountAmount.toFixed(2) }}</span>
</div>
<div class="summary-line total">
<span>总计:</span>
<span>¥{{ cart.total.toFixed(2) }}</span>
</div>
<div class="cart-actions">
<button @click="cart.clearCart()" class="btn btn-secondary">
清空购物车
</button>
<button @click="checkout" class="btn btn-primary">
结算
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useCartStore } from '@/stores/cart'
import { useRouter } from 'vue-router'
const cart = useCartStore()
const router = useRouter()
const discountInput = ref('')
onMounted(() => {
cart.loadFromLocalStorage()
})
async function applyDiscount() {
if (!discountInput.value) return
const result = await cart.applyDiscount(discountInput.value)
if (result.success) {
discountInput.value = ''
alert(`优惠码应用成功!节省 ¥${result.discount}`)
} else {
alert(result.error)
}
}
function checkout() {
router.push('/checkout')
}
</script>
<style scoped>
.shopping-cart {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.empty-cart {
text-align: center;
padding: 40px;
}
.cart-item {
display: flex;
align-items: center;
padding: 15px;
border-bottom: 1px solid #eee;
gap: 15px;
}
.item-image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: 4px;
}
.item-details {
flex: 1;
}
.item-details h3 {
margin: 0 0 5px 0;
font-size: 16px;
}
.item-price {
color: #666;
margin: 0;
}
.quantity-controls {
display: flex;
align-items: center;
gap: 10px;
}
.quantity-controls button {
width: 30px;
height: 30px;
border: 1px solid #ddd;
background: white;
cursor: pointer;
}
.quantity {
min-width: 30px;
text-align: center;
}
.item-total {
font-weight: bold;
min-width: 80px;
text-align: right;
}
.remove-btn {
color: #e74c3c;
background: none;
border: none;
cursor: pointer;
padding: 5px 10px;
}
.discount-section {
margin: 20px 0;
padding: 15px;
background-color: #f8f9fa;
border-radius: 4px;
}
.discount-input {
display: flex;
gap: 10px;
}
.discount-input input {
flex: 1;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.discount-applied {
display: flex;
justify-content: space-between;
align-items: center;
}
.discount-amount {
color: #27ae60;
font-weight: bold;
}
.cart-summary {
border-top: 2px solid #ddd;
padding-top: 15px;
margin-top: 20px;
}
.summary-line {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.summary-line.total {
font-size: 18px;
font-weight: bold;
border-top: 1px solid #ddd;
padding-top: 10px;
margin-top: 10px;
}
.summary-line.discount {
color: #27ae60;
}
.cart-actions {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
}
.btn-primary {
background-color: #007bff;
color: white;
}
.btn-secondary {
background-color: #6c757d;
color: white;
}
.btn:hover {
opacity: 0.9;
}
</style>
Store 组合和模块化
Store 之间的通信
javascript
// stores/notifications.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useAuthStore } from './auth'
export const useNotificationsStore = defineStore('notifications', () => {
const notifications = ref([])
function addNotification(notification) {
const authStore = useAuthStore()
// 只有登录用户才能接收通知
if (!authStore.isAuthenticated) {
return
}
notifications.value.push({
id: Date.now(),
timestamp: new Date(),
userId: authStore.user.id,
...notification
})
}
return {
notifications,
addNotification
}
})
插件和中间件
javascript
// plugins/pinia-persistence.js
export function createPersistencePlugin(options = {}) {
return ({ store }) => {
const { key = store.$id, storage = localStorage } = options
// 从存储中恢复状态
const saved = storage.getItem(key)
if (saved) {
try {
store.$patch(JSON.parse(saved))
} catch (error) {
console.error('Failed to restore state:', error)
}
}
// 监听状态变化并保存
store.$subscribe((mutation, state) => {
storage.setItem(key, JSON.stringify(state))
})
}
}
// main.js
import { createPinia } from 'pinia'
import { createPersistencePlugin } from './plugins/pinia-persistence'
const pinia = createPinia()
pinia.use(createPersistencePlugin())
测试 Store
单元测试
javascript
// tests/stores/counter.test.js
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
test('increments counter', () => {
const counter = useCounterStore()
expect(counter.count).toBe(0)
counter.increment()
expect(counter.count).toBe(1)
expect(counter.doubleCount).toBe(2)
})
test('resets counter', () => {
const counter = useCounterStore()
counter.count = 10
counter.reset()
expect(counter.count).toBe(0)
})
})
组件测试
javascript
// tests/components/Counter.test.js
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import Counter from '@/components/Counter.vue'
import { useCounterStore } from '@/stores/counter'
describe('Counter Component', () => {
let wrapper
let store
beforeEach(() => {
setActivePinia(createPinia())
store = useCounterStore()
wrapper = mount(Counter, {
global: {
plugins: [createPinia()]
}
})
})
test('displays current count', () => {
expect(wrapper.text()).toContain('0')
store.count = 5
expect(wrapper.text()).toContain('5')
})
test('increments on button click', async () => {
const button = wrapper.find('[data-testid="increment"]')
await button.trigger('click')
expect(store.count).toBe(1)
})
})
最佳实践
1. Store 命名和组织
javascript
// ✅ 好的命名
export const useUserStore = defineStore('user', () => { /* ... */ })
export const useCartStore = defineStore('cart', () => { /* ... */ })
export const useProductsStore = defineStore('products', () => { /* ... */ })
// ❌ 避免的命名
export const userStore = defineStore('user', () => { /* ... */ })
export const store = defineStore('data', () => { /* ... */ })
2. 状态设计
javascript
// ✅ 扁平化状态结构
const state = {
users: [],
currentUserId: null,
isLoading: false,
error: null
}
// ❌ 过度嵌套
const state = {
data: {
users: {
list: [],
current: {
id: null,
profile: {}
}
}
}
}
3. Actions 设计
javascript
// ✅ 清晰的 action 命名和错误处理
async function fetchUsers() {
isLoading.value = true
error.value = null
try {
const response = await api.getUsers()
users.value = response.data
} catch (err) {
error.value = err.message
throw err // 让调用者处理错误
} finally {
isLoading.value = false
}
}
// ❌ 不清晰的错误处理
async function getUsers() {
const response = await api.getUsers()
users.value = response.data
}
4. TypeScript 支持
typescript
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
interface User {
id: number
name: string
email: string
role: 'admin' | 'user'
}
interface UserState {
users: User[]
currentUser: User | null
isLoading: boolean
error: string | null
}
export const useUserStore = defineStore('user', () => {
const users = ref<User[]>([])
const currentUser = ref<User | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
const isAdmin = computed(() => {
return currentUser.value?.role === 'admin'
})
async function fetchUsers(): Promise<void> {
// 实现
}
return {
users,
currentUser,
isLoading,
error,
isAdmin,
fetchUsers
}
})
下一步
Pinia 提供了现代化的状态管理解决方案,让你能够轻松管理复杂应用的状态!