Appearance
Pinia
Pinia 是 Vue 的官方状态管理库,它是 Vuex 的继任者。Pinia 提供了更简单的 API、更好的 TypeScript 支持,以及更直观的开发体验。
为什么选择 Pinia?
- 类型安全:为 TypeScript 而生,提供完整的类型推断
- 开发工具支持:出色的开发者体验,支持 Vue DevTools
- 模块化:每个 store 都是独立的,支持代码分割
- 轻量级:压缩后仅约 1kb
- 简单的 API:没有 mutations,只有 state、getters 和 actions
- 支持插件:可扩展的插件系统
安装
bash
npm install pinia基本设置
1. 创建 Pinia 实例
js
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
app.mount('#app')2. 定义 Store
js
// stores/counter.js
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Eduardo'
}),
getters: {
doubleCount: (state) => state.count * 2,
// 使用箭头函数时无法使用 `this`
// 可以使用普通函数来访问其他 getters
doubleCountPlusOne() {
return this.doubleCount + 1
}
},
actions: {
increment() {
this.count++
},
async fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`)
const userData = await response.json()
this.name = userData.name
return userData
} catch (error) {
console.error('Failed to fetch user data:', error)
throw error
}
}
}
})组合式 API 风格
你也可以使用组合式 API 的方式定义 store:
js
// stores/counter.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCounterStore = defineStore('counter', () => {
// state
const count = ref(0)
const name = ref('Eduardo')
// getters
const doubleCount = computed(() => count.value * 2)
const doubleCountPlusOne = computed(() => doubleCount.value + 1)
// actions
function increment() {
count.value++
}
async function fetchUserData(userId) {
try {
const response = await fetch(`/api/users/${userId}`)
const userData = await response.json()
name.value = userData.name
return userData
} catch (error) {
console.error('Failed to fetch user data:', error)
throw error
}
}
return {
count,
name,
doubleCount,
doubleCountPlusOne,
increment,
fetchUserData
}
})在组件中使用 Store
vue
<template>
<div>
<h2>计数器: {{ counter.count }}</h2>
<p>双倍计数: {{ counter.doubleCount }}</p>
<p>双倍计数+1: {{ counter.doubleCountPlusOne }}</p>
<p>用户名: {{ counter.name }}</p>
<button @click="counter.increment()">增加</button>
<button @click="increment()">增加 (解构)</button>
<button @click="reset()">重置</button>
<button @click="loadUser()">加载用户数据</button>
</div>
</template>
<script setup>
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'
const counter = useCounterStore()
// 解构 actions(不需要 storeToRefs)
const { increment, fetchUserData } = counter
// 解构 state 和 getters(需要 storeToRefs 保持响应性)
const { count, name, doubleCount } = storeToRefs(counter)
// 直接修改 state
function reset() {
counter.count = 0
}
// 使用 $patch 批量更新
function batchUpdate() {
counter.$patch({
count: counter.count + 1,
name: 'New Name'
})
}
// 使用函数形式的 $patch
function batchUpdateWithFunction() {
counter.$patch((state) => {
state.count++
state.name = 'Another Name'
})
}
async function loadUser() {
try {
await fetchUserData(123)
} catch (error) {
console.error('加载用户数据失败:', error)
}
}
</script>高级用法
1. Store 之间的相互调用
js
// stores/user.js
import { defineStore } from 'pinia'
import { useSettingsStore } from './settings'
export const useUserStore = defineStore('user', {
state: () => ({
userData: null,
isLoggedIn: false
}),
actions: {
async login(credentials) {
try {
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
if (response.ok) {
this.userData = await response.json()
this.isLoggedIn = true
// 调用其他 store
const settingsStore = useSettingsStore()
await settingsStore.loadUserSettings(this.userData.id)
}
} catch (error) {
console.error('Login failed:', error)
throw error
}
},
logout() {
this.userData = null
this.isLoggedIn = false
// 清理其他 store
const settingsStore = useSettingsStore()
settingsStore.clearSettings()
}
}
})2. 插件系统
js
// plugins/persistedState.js
export function createPersistedState(options = {}) {
return (context) => {
const { store } = context
const storageKey = options.key || store.$id
// 从 localStorage 恢复状态
const savedState = localStorage.getItem(storageKey)
if (savedState) {
store.$patch(JSON.parse(savedState))
}
// 监听状态变化并保存到 localStorage
store.$subscribe((mutation, state) => {
localStorage.setItem(storageKey, JSON.stringify(state))
})
}
}
// main.js
import { createPinia } from 'pinia'
import { createPersistedState } from './plugins/persistedState'
const pinia = createPinia()
pinia.use(createPersistedState())3. 订阅状态变化
js
// 在组件中订阅 store 变化
export default {
setup() {
const store = useCounterStore()
// 订阅整个 store 的变化
store.$subscribe((mutation, state) => {
console.log('Store changed:', mutation, state)
// 可以根据 mutation.type 判断变化类型
if (mutation.type === 'direct') {
console.log('直接修改 state')
} else if (mutation.type === 'patch object') {
console.log('通过 $patch 修改')
}
})
// 订阅 actions
store.$onAction(({
name, // action 名称
store, // store 实例
args, // action 参数
after, // action 成功后的钩子
onError, // action 失败后的钩子
}) => {
console.log(`Action ${name} called with args:`, args)
after((result) => {
console.log(`Action ${name} finished with result:`, result)
})
onError((error) => {
console.error(`Action ${name} failed:`, error)
})
})
return { store }
}
}实际应用示例
购物车 Store
js
// stores/cart.js
import { defineStore } from 'pinia'
import { useProductStore } from './product'
export const useCartStore = defineStore('cart', {
state: () => ({
items: [],
isLoading: false
}),
getters: {
totalItems: (state) => state.items.reduce((total, item) => total + item.quantity, 0),
totalPrice: (state) => {
const productStore = useProductStore()
return state.items.reduce((total, item) => {
const product = productStore.getProductById(item.productId)
return total + (product?.price || 0) * item.quantity
}, 0)
},
cartItems: (state) => {
const productStore = useProductStore()
return state.items.map(item => ({
...item,
product: productStore.getProductById(item.productId)
}))
}
},
actions: {
addToCart(productId, quantity = 1) {
const existingItem = this.items.find(item => item.productId === productId)
if (existingItem) {
existingItem.quantity += quantity
} else {
this.items.push({
productId,
quantity,
addedAt: new Date()
})
}
},
removeFromCart(productId) {
const index = this.items.findIndex(item => item.productId === productId)
if (index > -1) {
this.items.splice(index, 1)
}
},
updateQuantity(productId, quantity) {
const item = this.items.find(item => item.productId === productId)
if (item) {
if (quantity <= 0) {
this.removeFromCart(productId)
} else {
item.quantity = quantity
}
}
},
clearCart() {
this.items = []
},
async checkout() {
this.isLoading = true
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
items: this.items
})
})
if (response.ok) {
const result = await response.json()
this.clearCart()
return result
} else {
throw new Error('Checkout failed')
}
} catch (error) {
console.error('Checkout error:', error)
throw error
} finally {
this.isLoading = false
}
}
}
})用户认证 Store
js
// stores/auth.js
import { defineStore } from 'pinia'
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: localStorage.getItem('token'),
isLoading: false,
error: null
}),
getters: {
isAuthenticated: (state) => !!state.token && !!state.user,
userRole: (state) => state.user?.role || 'guest',
hasPermission: (state) => (permission) => {
if (!state.user) return false
return state.user.permissions?.includes(permission) || false
}
},
actions: {
async login(credentials) {
this.isLoading = true
this.error = null
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(credentials)
})
if (response.ok) {
const data = await response.json()
this.token = data.token
this.user = data.user
localStorage.setItem('token', data.token)
// 设置默认请求头
this.setAuthHeader()
return data
} else {
const errorData = await response.json()
throw new Error(errorData.message || 'Login failed')
}
} catch (error) {
this.error = error.message
throw error
} finally {
this.isLoading = false
}
},
async logout() {
try {
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`
}
})
} catch (error) {
console.error('Logout error:', error)
} finally {
this.user = null
this.token = null
this.error = null
localStorage.removeItem('token')
this.removeAuthHeader()
}
},
async fetchUser() {
if (!this.token) return
this.isLoading = true
try {
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${this.token}`
}
})
if (response.ok) {
this.user = await response.json()
} else {
// Token 可能已过期
this.logout()
}
} catch (error) {
console.error('Fetch user error:', error)
this.logout()
} finally {
this.isLoading = false
}
},
setAuthHeader() {
// 设置 axios 默认请求头或其他 HTTP 客户端
if (this.token) {
// axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`
}
},
removeAuthHeader() {
// 移除 axios 默认请求头
// delete axios.defaults.headers.common['Authorization']
},
async refreshToken() {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.token}`
}
})
if (response.ok) {
const data = await response.json()
this.token = data.token
localStorage.setItem('token', data.token)
this.setAuthHeader()
return data.token
} else {
this.logout()
}
} catch (error) {
console.error('Token refresh error:', error)
this.logout()
}
}
}
})测试 Store
js
// tests/stores/counter.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from '@/stores/counter'
describe('Counter Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('increments count', () => {
const counter = useCounterStore()
expect(counter.count).toBe(0)
counter.increment()
expect(counter.count).toBe(1)
})
it('computes double count', () => {
const counter = useCounterStore()
counter.count = 5
expect(counter.doubleCount).toBe(10)
})
it('patches state', () => {
const counter = useCounterStore()
counter.$patch({
count: 10,
name: 'Test User'
})
expect(counter.count).toBe(10)
expect(counter.name).toBe('Test User')
})
})最佳实践
1. Store 命名约定
js
// 使用 use 前缀和 Store 后缀
export const useUserStore = defineStore('user', {})
export const useCartStore = defineStore('cart', {})
export const useProductStore = defineStore('product', {})2. 组织 Store 文件
stores/
├── index.js # 导出所有 stores
├── auth.js # 认证相关
├── user.js # 用户信息
├── cart.js # 购物车
├── product.js # 产品数据
└── modules/ # 复杂 store 的子模块
├── admin/
└── settings/3. 类型定义(TypeScript)
ts
// types/store.ts
export interface User {
id: number
name: string
email: string
role: 'admin' | 'user' | 'moderator'
permissions: string[]
}
export interface AuthState {
user: User | null
token: string | null
isLoading: boolean
error: string | null
}
// stores/auth.ts
import { defineStore } from 'pinia'
import type { AuthState, User } from '@/types/store'
export const useAuthStore = defineStore('auth', {
state: (): AuthState => ({
user: null,
token: localStorage.getItem('token'),
isLoading: false,
error: null
}),
getters: {
isAuthenticated: (state): boolean => !!state.token && !!state.user,
userRole: (state): string => state.user?.role || 'guest'
},
actions: {
async login(credentials: { email: string; password: string }): Promise<User> {
// 实现登录逻辑
}
}
})4. 错误处理
js
export const useApiStore = defineStore('api', {
state: () => ({
loading: {},
errors: {}
}),
actions: {
async fetchData(endpoint, options = {}) {
const loadingKey = options.loadingKey || endpoint
this.loading[loadingKey] = true
this.errors[loadingKey] = null
try {
const response = await fetch(endpoint, options)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return await response.json()
} catch (error) {
this.errors[loadingKey] = error.message
throw error
} finally {
this.loading[loadingKey] = false
}
}
}
})迁移指南
从 Vuex 迁移到 Pinia
js
// Vuex
const store = new Vuex.Store({
state: {
count: 0
},
mutations: {
increment(state) {
state.count++
}
},
actions: {
increment(context) {
context.commit('increment')
}
},
getters: {
doubleCount: state => state.count * 2
}
})
// Pinia
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0
}),
actions: {
increment() {
this.count++
}
},
getters: {
doubleCount: (state) => state.count * 2
}
})