Skip to content

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
  }
})

下一步

vue study guide - 专业的 Vue.js 学习平台