Skip to content

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 提供了现代化的状态管理解决方案,让你能够轻松管理复杂应用的状态!

基于 Vue.js 官方文档构建的学习宝典