Pinia是Vue.js的官方状态管理库,是Vuex的继任者。本章将详细介绍Pinia的使用方法、状态管理模式和最佳实践。

6.1 状态管理基础

什么是状态管理

状态管理是指在应用中统一管理和维护数据状态的模式。当应用变得复杂时,组件间的数据共享和状态同步变得困难,这时就需要状态管理库。

安装和配置Pinia

# 安装Pinia
npm install pinia

# 或使用yarn
yarn add pinia

# 或使用pnpm
pnpm add pinia
// main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.use(router)
app.mount('#app')

6.2 定义Store

基本Store定义

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 状态
  state: () => ({
    count: 0,
    name: 'Counter Store',
    history: []
  }),
  
  // 计算属性
  getters: {
    doubleCount: (state) => state.count * 2,
    
    // 带参数的getter
    countPlusN: (state) => {
      return (n) => state.count + n
    },
    
    // 访问其他getter
    doubleCountPlusOne() {
      return this.doubleCount + 1
    },
    
    // 访问其他store
    otherStoreValue() {
      const otherStore = useOtherStore()
      return otherStore.value
    }
  },
  
  // 动作
  actions: {
    increment() {
      this.count++
      this.history.push({ action: 'increment', timestamp: Date.now() })
    },
    
    decrement() {
      this.count--
      this.history.push({ action: 'decrement', timestamp: Date.now() })
    },
    
    incrementBy(amount) {
      this.count += amount
      this.history.push({ 
        action: 'incrementBy', 
        amount, 
        timestamp: Date.now() 
      })
    },
    
    async fetchData() {
      try {
        // 模拟API调用
        const response = await fetch('/api/counter')
        const data = await response.json()
        this.count = data.count
      } catch (error) {
        console.error('Failed to fetch data:', error)
      }
    },
    
    reset() {
      this.count = 0
      this.history = []
    },
    
    // 批量更新
    $patch(partialState) {
      // 这是内置方法,这里只是展示用法
    }
  }
})

Composition API风格的Store

// stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
  // 状态 (相当于state)
  const user = ref(null)
  const isLoggedIn = ref(false)
  const preferences = ref({
    theme: 'light',
    language: 'zh-CN',
    notifications: true
  })
  const loginHistory = ref([])
  
  // 计算属性 (相当于getters)
  const userName = computed(() => user.value?.name || 'Guest')
  const userInitials = computed(() => {
    if (!user.value?.name) return 'G'
    return user.value.name
      .split(' ')
      .map(word => word[0])
      .join('')
      .toUpperCase()
  })
  
  const isAdmin = computed(() => user.value?.role === 'admin')
  const canEdit = computed(() => isLoggedIn.value && user.value?.permissions?.includes('edit'))
  
  // 动作 (相当于actions)
  async function login(credentials) {
    try {
      // 模拟登录API
      const response = await fetch('/api/login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(credentials)
      })
      
      if (!response.ok) {
        throw new Error('Login failed')
      }
      
      const userData = await response.json()
      user.value = userData.user
      isLoggedIn.value = true
      
      // 记录登录历史
      loginHistory.value.push({
        timestamp: Date.now(),
        ip: userData.ip,
        userAgent: navigator.userAgent
      })
      
      // 保存到localStorage
      localStorage.setItem('user', JSON.stringify(userData.user))
      localStorage.setItem('token', userData.token)
      
      return userData
    } catch (error) {
      console.error('Login error:', error)
      throw error
    }
  }
  
  function logout() {
    user.value = null
    isLoggedIn.value = false
    
    // 清除localStorage
    localStorage.removeItem('user')
    localStorage.removeItem('token')
  }
  
  function updateProfile(profileData) {
    if (user.value) {
      user.value = { ...user.value, ...profileData }
      localStorage.setItem('user', JSON.stringify(user.value))
    }
  }
  
  function updatePreferences(newPreferences) {
    preferences.value = { ...preferences.value, ...newPreferences }
    localStorage.setItem('preferences', JSON.stringify(preferences.value))
  }
  
  function initializeFromStorage() {
    const storedUser = localStorage.getItem('user')
    const storedPreferences = localStorage.getItem('preferences')
    const token = localStorage.getItem('token')
    
    if (storedUser && token) {
      user.value = JSON.parse(storedUser)
      isLoggedIn.value = true
    }
    
    if (storedPreferences) {
      preferences.value = JSON.parse(storedPreferences)
    }
  }
  
  // 返回要暴露的内容
  return {
    // 状态
    user,
    isLoggedIn,
    preferences,
    loginHistory,
    
    // 计算属性
    userName,
    userInitials,
    isAdmin,
    canEdit,
    
    // 动作
    login,
    logout,
    updateProfile,
    updatePreferences,
    initializeFromStorage
  }
})

6.3 在组件中使用Store

基本使用

<!-- components/Counter.vue -->
<template>
  <div class="counter">
    <div class="counter-display">
      <h2>计数器</h2>
      <div class="count-value">{{ count }}</div>
      <div class="count-info">
        <p>双倍值: {{ doubleCount }}</p>
        <p>双倍值+1: {{ doubleCountPlusOne }}</p>
        <p>当前值+10: {{ countPlusN(10) }}</p>
      </div>
    </div>
    
    <div class="counter-controls">
      <button @click="decrement" class="btn btn-danger">-</button>
      <button @click="increment" class="btn btn-primary">+</button>
      <button @click="incrementBy(5)" class="btn btn-secondary">+5</button>
      <button @click="reset" class="btn btn-warning">重置</button>
    </div>
    
    <div class="counter-history">
      <h3>操作历史</h3>
      <div class="history-list">
        <div 
          v-for="(item, index) in history" 
          :key="index" 
          class="history-item"
        >
          <span class="action">{{ item.action }}</span>
          <span v-if="item.amount" class="amount">({{ item.amount }})</span>
          <span class="timestamp">{{ formatTime(item.timestamp) }}</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

export default {
  name: 'Counter',
  
  setup() {
    const counterStore = useCounterStore()
    
    // 使用storeToRefs保持响应性
    const { count, doubleCount, doubleCountPlusOne, history } = storeToRefs(counterStore)
    
    // 直接解构actions(不需要storeToRefs)
    const { increment, decrement, incrementBy, reset, countPlusN } = counterStore
    
    function formatTime(timestamp) {
      return new Date(timestamp).toLocaleTimeString()
    }
    
    return {
      count,
      doubleCount,
      doubleCountPlusOne,
      history,
      increment,
      decrement,
      incrementBy,
      reset,
      countPlusN,
      formatTime
    }
  }
}
</script>

<style scoped>
.counter {
  max-width: 400px;
  margin: 0 auto;
  padding: 2rem;
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.counter-display {
  text-align: center;
  margin-bottom: 2rem;
}

.counter-display h2 {
  color: #2c3e50;
  margin-bottom: 1rem;
}

.count-value {
  font-size: 3rem;
  font-weight: bold;
  color: #3498db;
  margin-bottom: 1rem;
}

.count-info {
  background: #f8f9fa;
  padding: 1rem;
  border-radius: 4px;
  margin-bottom: 1rem;
}

.count-info p {
  margin: 0.5rem 0;
  color: #666;
}

.counter-controls {
  display: flex;
  gap: 0.5rem;
  justify-content: center;
  margin-bottom: 2rem;
}

.btn {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
  font-weight: 500;
  transition: all 0.3s;
}

.btn-primary {
  background: #3498db;
  color: white;
}

.btn-primary:hover {
  background: #2980b9;
}

.btn-danger {
  background: #e74c3c;
  color: white;
}

.btn-danger:hover {
  background: #c0392b;
}

.btn-secondary {
  background: #95a5a6;
  color: white;
}

.btn-secondary:hover {
  background: #7f8c8d;
}

.btn-warning {
  background: #f39c12;
  color: white;
}

.btn-warning:hover {
  background: #e67e22;
}

.counter-history {
  border-top: 1px solid #dee2e6;
  padding-top: 1rem;
}

.counter-history h3 {
  color: #2c3e50;
  margin-bottom: 1rem;
  font-size: 1.1rem;
}

.history-list {
  max-height: 200px;
  overflow-y: auto;
}

.history-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.5rem;
  border-bottom: 1px solid #f1f1f1;
  font-size: 0.9rem;
}

.history-item:last-child {
  border-bottom: none;
}

.action {
  font-weight: 500;
  color: #2c3e50;
}

.amount {
  color: #3498db;
}

.timestamp {
  color: #7f8c8d;
  font-size: 0.8rem;
}
</style>

用户管理组件

<!-- components/UserProfile.vue -->
<template>
  <div class="user-profile">
    <!-- 未登录状态 -->
    <div v-if="!isLoggedIn" class="login-form">
      <h2>用户登录</h2>
      <form @submit.prevent="handleLogin">
        <div class="form-group">
          <label for="email">邮箱:</label>
          <input 
            id="email"
            v-model="loginForm.email" 
            type="email" 
            required
            class="form-control"
          >
        </div>
        <div class="form-group">
          <label for="password">密码:</label>
          <input 
            id="password"
            v-model="loginForm.password" 
            type="password" 
            required
            class="form-control"
          >
        </div>
        <button type="submit" :disabled="isLoading" class="btn btn-primary">
          {{ isLoading ? '登录中...' : '登录' }}
        </button>
      </form>
      
      <div v-if="loginError" class="error-message">
        {{ loginError }}
      </div>
    </div>
    
    <!-- 已登录状态 -->
    <div v-else class="user-info">
      <div class="user-header">
        <div class="user-avatar">
          <img v-if="user.avatar" :src="user.avatar" :alt="userName">
          <div v-else class="avatar-placeholder">
            {{ userInitials }}
          </div>
        </div>
        <div class="user-details">
          <h2>{{ userName }}</h2>
          <p class="user-email">{{ user.email }}</p>
          <span class="user-role" :class="`role-${user.role}`">
            {{ getRoleLabel(user.role) }}
          </span>
        </div>
        <button @click="logout" class="btn btn-secondary">退出登录</button>
      </div>
      
      <!-- 用户偏好设置 -->
      <div class="preferences-section">
        <h3>偏好设置</h3>
        <div class="preference-item">
          <label>主题:</label>
          <select v-model="localPreferences.theme" @change="updatePreferences">
            <option value="light">浅色</option>
            <option value="dark">深色</option>
            <option value="auto">自动</option>
          </select>
        </div>
        <div class="preference-item">
          <label>语言:</label>
          <select v-model="localPreferences.language" @change="updatePreferences">
            <option value="zh-CN">中文</option>
            <option value="en-US">English</option>
            <option value="ja-JP">日本語</option>
          </select>
        </div>
        <div class="preference-item">
          <label>
            <input 
              v-model="localPreferences.notifications" 
              type="checkbox"
              @change="updatePreferences"
            >
            接收通知
          </label>
        </div>
      </div>
      
      <!-- 权限信息 -->
      <div class="permissions-section">
        <h3>权限信息</h3>
        <div class="permission-list">
          <div class="permission-item">
            <span>管理员权限:</span>
            <span :class="isAdmin ? 'status-yes' : 'status-no'">
              {{ isAdmin ? '是' : '否' }}
            </span>
          </div>
          <div class="permission-item">
            <span>编辑权限:</span>
            <span :class="canEdit ? 'status-yes' : 'status-no'">
              {{ canEdit ? '是' : '否' }}
            </span>
          </div>
        </div>
      </div>
      
      <!-- 登录历史 -->
      <div class="login-history">
        <h3>登录历史</h3>
        <div class="history-list">
          <div 
            v-for="(login, index) in loginHistory.slice(-5)" 
            :key="index"
            class="history-item"
          >
            <span class="login-time">
              {{ formatDateTime(login.timestamp) }}
            </span>
            <span class="login-ip">{{ login.ip }}</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { ref, reactive, computed } from 'vue'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'

export default {
  name: 'UserProfile',
  
  setup() {
    const userStore = useUserStore()
    
    // 从store获取响应式数据
    const { 
      user, 
      isLoggedIn, 
      preferences, 
      loginHistory,
      userName,
      userInitials,
      isAdmin,
      canEdit
    } = storeToRefs(userStore)
    
    // 获取actions
    const { login, logout, updatePreferences: storeUpdatePreferences } = userStore
    
    // 本地状态
    const isLoading = ref(false)
    const loginError = ref('')
    const loginForm = reactive({
      email: '',
      password: ''
    })
    
    // 本地偏好设置(用于双向绑定)
    const localPreferences = reactive({ ...preferences.value })
    
    // 监听偏好设置变化
    function updatePreferences() {
      storeUpdatePreferences(localPreferences)
    }
    
    // 处理登录
    async function handleLogin() {
      isLoading.value = true
      loginError.value = ''
      
      try {
        await login(loginForm)
        // 登录成功,重置表单
        loginForm.email = ''
        loginForm.password = ''
      } catch (error) {
        loginError.value = error.message || '登录失败,请重试'
      } finally {
        isLoading.value = false
      }
    }
    
    // 格式化日期时间
    function formatDateTime(timestamp) {
      return new Date(timestamp).toLocaleString('zh-CN')
    }
    
    // 获取角色标签
    function getRoleLabel(role) {
      const labels = {
        admin: '管理员',
        editor: '编辑者',
        user: '普通用户',
        guest: '访客'
      }
      return labels[role] || role
    }
    
    // 初始化
    userStore.initializeFromStorage()
    
    return {
      // 状态
      user,
      isLoggedIn,
      preferences,
      loginHistory,
      userName,
      userInitials,
      isAdmin,
      canEdit,
      isLoading,
      loginError,
      loginForm,
      localPreferences,
      
      // 方法
      handleLogin,
      logout,
      updatePreferences,
      formatDateTime,
      getRoleLabel
    }
  }
}
</script>

<style scoped>
.user-profile {
  max-width: 600px;
  margin: 0 auto;
  padding: 2rem;
}

.login-form {
  background: white;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}

.login-form h2 {
  color: #2c3e50;
  margin-bottom: 1.5rem;
  text-align: center;
}

.form-group {
  margin-bottom: 1rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
  color: #555;
  font-weight: 500;
}

.form-control {
  width: 100%;
  padding: 0.75rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 1rem;
}

.form-control:focus {
  outline: none;
  border-color: #3498db;
  box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}

.btn {
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 1rem;
  transition: all 0.3s;
}

.btn-primary {
  background: #3498db;
  color: white;
  width: 100%;
}

.btn-primary:hover:not(:disabled) {
  background: #2980b9;
}

.btn-primary:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.btn-secondary {
  background: #95a5a6;
  color: white;
}

.btn-secondary:hover {
  background: #7f8c8d;
}

.error-message {
  color: #e74c3c;
  margin-top: 1rem;
  padding: 0.75rem;
  background: #fdf2f2;
  border: 1px solid #fecaca;
  border-radius: 4px;
}

.user-info {
  background: white;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  overflow: hidden;
}

.user-header {
  display: flex;
  align-items: center;
  padding: 2rem;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
}

.user-avatar {
  margin-right: 1.5rem;
}

.user-avatar img {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  object-fit: cover;
}

.avatar-placeholder {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  background: rgba(255,255,255,0.2);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 2rem;
  font-weight: bold;
}

.user-details {
  flex: 1;
}

.user-details h2 {
  margin: 0 0 0.5rem 0;
}

.user-email {
  margin: 0 0 0.5rem 0;
  opacity: 0.9;
}

.user-role {
  padding: 0.25rem 0.75rem;
  border-radius: 12px;
  font-size: 0.8rem;
  font-weight: 500;
}

.role-admin {
  background: #e74c3c;
}

.role-editor {
  background: #f39c12;
}

.role-user {
  background: #3498db;
}

.preferences-section,
.permissions-section,
.login-history {
  padding: 1.5rem 2rem;
  border-bottom: 1px solid #f1f1f1;
}

.preferences-section:last-child,
.permissions-section:last-child,
.login-history:last-child {
  border-bottom: none;
}

.preferences-section h3,
.permissions-section h3,
.login-history h3 {
  color: #2c3e50;
  margin-bottom: 1rem;
  font-size: 1.1rem;
}

.preference-item {
  display: flex;
  align-items: center;
  margin-bottom: 1rem;
}

.preference-item label {
  min-width: 80px;
  margin-right: 1rem;
  margin-bottom: 0;
}

.preference-item select {
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
}

.permission-list {
  display: grid;
  gap: 0.5rem;
}

.permission-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem;
  background: #f8f9fa;
  border-radius: 4px;
}

.status-yes {
  color: #27ae60;
  font-weight: 500;
}

.status-no {
  color: #e74c3c;
  font-weight: 500;
}

.history-list {
  display: grid;
  gap: 0.5rem;
}

.history-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 0.75rem;
  background: #f8f9fa;
  border-radius: 4px;
  font-size: 0.9rem;
}

.login-time {
  font-weight: 500;
  color: #2c3e50;
}

.login-ip {
  color: #7f8c8d;
  font-family: monospace;
}

@media (max-width: 768px) {
  .user-header {
    flex-direction: column;
    text-align: center;
  }
  
  .user-avatar {
    margin-right: 0;
    margin-bottom: 1rem;
  }
  
  .preference-item {
    flex-direction: column;
    align-items: flex-start;
  }
  
  .preference-item label {
    margin-bottom: 0.5rem;
  }
  
  .history-item {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.25rem;
  }
}
</style>

6.4 Store组合和模块化

购物车Store示例

// stores/cart.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useProductStore } from './product'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    coupon: null,
    shippingMethod: 'standard'
  }),
  
  getters: {
    // 购物车商品数量
    itemCount: (state) => {
      return state.items.reduce((total, item) => total + item.quantity, 0)
    },
    
    // 购物车总价
    subtotal: (state) => {
      return state.items.reduce((total, item) => {
        return total + (item.price * item.quantity)
      }, 0)
    },
    
    // 折扣金额
    discountAmount() {
      if (!this.coupon) return 0
      
      if (this.coupon.type === 'percentage') {
        return this.subtotal * (this.coupon.value / 100)
      } else if (this.coupon.type === 'fixed') {
        return Math.min(this.coupon.value, this.subtotal)
      }
      
      return 0
    },
    
    // 运费
    shippingCost() {
      const costs = {
        standard: 10,
        express: 25,
        overnight: 50
      }
      
      // 满99免运费
      if (this.subtotal >= 99) return 0
      
      return costs[this.shippingMethod] || 0
    },
    
    // 最终总价
    total() {
      return this.subtotal - this.discountAmount + this.shippingCost
    },
    
    // 是否为空购物车
    isEmpty: (state) => state.items.length === 0,
    
    // 获取特定商品
    getItemById: (state) => {
      return (productId) => state.items.find(item => item.id === productId)
    }
  },
  
  actions: {
    // 添加商品到购物车
    addItem(product, quantity = 1) {
      const existingItem = this.items.find(item => item.id === product.id)
      
      if (existingItem) {
        existingItem.quantity += quantity
      } else {
        this.items.push({
          id: product.id,
          name: product.name,
          price: product.price,
          image: product.image,
          quantity,
          addedAt: Date.now()
        })
      }
      
      this.saveToStorage()
    },
    
    // 更新商品数量
    updateQuantity(productId, quantity) {
      const item = this.items.find(item => item.id === productId)
      
      if (item) {
        if (quantity <= 0) {
          this.removeItem(productId)
        } else {
          item.quantity = quantity
          this.saveToStorage()
        }
      }
    },
    
    // 移除商品
    removeItem(productId) {
      const index = this.items.findIndex(item => item.id === productId)
      
      if (index > -1) {
        this.items.splice(index, 1)
        this.saveToStorage()
      }
    },
    
    // 清空购物车
    clear() {
      this.items = []
      this.coupon = null
      this.saveToStorage()
    },
    
    // 应用优惠券
    applyCoupon(couponCode) {
      // 模拟优惠券验证
      const validCoupons = {
        'SAVE10': { type: 'percentage', value: 10, minAmount: 50 },
        'SAVE20': { type: 'fixed', value: 20, minAmount: 100 },
        'FREESHIP': { type: 'shipping', value: 0, minAmount: 0 }
      }
      
      const coupon = validCoupons[couponCode.toUpperCase()]
      
      if (!coupon) {
        throw new Error('无效的优惠券代码')
      }
      
      if (this.subtotal < coupon.minAmount) {
        throw new Error(`订单金额需满${coupon.minAmount}元才能使用此优惠券`)
      }
      
      this.coupon = { ...coupon, code: couponCode.toUpperCase() }
      this.saveToStorage()
    },
    
    // 移除优惠券
    removeCoupon() {
      this.coupon = null
      this.saveToStorage()
    },
    
    // 设置配送方式
    setShippingMethod(method) {
      this.shippingMethod = method
      this.saveToStorage()
    },
    
    // 结算
    async checkout() {
      const userStore = useUserStore()
      
      if (!userStore.isLoggedIn) {
        throw new Error('请先登录')
      }
      
      if (this.isEmpty) {
        throw new Error('购物车为空')
      }
      
      try {
        // 模拟结算API调用
        const orderData = {
          items: this.items,
          subtotal: this.subtotal,
          discount: this.discountAmount,
          shipping: this.shippingCost,
          total: this.total,
          coupon: this.coupon,
          shippingMethod: this.shippingMethod,
          userId: userStore.user.id
        }
        
        const response = await fetch('/api/checkout', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${localStorage.getItem('token')}`
          },
          body: JSON.stringify(orderData)
        })
        
        if (!response.ok) {
          throw new Error('结算失败')
        }
        
        const result = await response.json()
        
        // 清空购物车
        this.clear()
        
        return result
      } catch (error) {
        console.error('Checkout error:', error)
        throw error
      }
    },
    
    // 保存到本地存储
    saveToStorage() {
      localStorage.setItem('cart', JSON.stringify({
        items: this.items,
        coupon: this.coupon,
        shippingMethod: this.shippingMethod
      }))
    },
    
    // 从本地存储加载
    loadFromStorage() {
      const saved = localStorage.getItem('cart')
      
      if (saved) {
        const data = JSON.parse(saved)
        this.items = data.items || []
        this.coupon = data.coupon || null
        this.shippingMethod = data.shippingMethod || 'standard'
      }
    }
  }
})

产品Store示例

// stores/product.js
import { defineStore } from 'pinia'

export const useProductStore = defineStore('product', {
  state: () => ({
    products: [],
    categories: [],
    currentProduct: null,
    loading: false,
    error: null,
    filters: {
      category: '',
      priceRange: [0, 1000],
      sortBy: 'name',
      sortOrder: 'asc'
    },
    pagination: {
      page: 1,
      pageSize: 12,
      total: 0
    }
  }),
  
  getters: {
    // 过滤后的产品
    filteredProducts() {
      let filtered = [...this.products]
      
      // 按分类过滤
      if (this.filters.category) {
        filtered = filtered.filter(product => 
          product.category === this.filters.category
        )
      }
      
      // 按价格范围过滤
      filtered = filtered.filter(product => 
        product.price >= this.filters.priceRange[0] && 
        product.price <= this.filters.priceRange[1]
      )
      
      // 排序
      filtered.sort((a, b) => {
        const aValue = a[this.filters.sortBy]
        const bValue = b[this.filters.sortBy]
        
        if (this.filters.sortOrder === 'asc') {
          return aValue > bValue ? 1 : -1
        } else {
          return aValue < bValue ? 1 : -1
        }
      })
      
      return filtered
    },
    
    // 分页后的产品
    paginatedProducts() {
      const start = (this.pagination.page - 1) * this.pagination.pageSize
      const end = start + this.pagination.pageSize
      return this.filteredProducts.slice(start, end)
    },
    
    // 总页数
    totalPages() {
      return Math.ceil(this.filteredProducts.length / this.pagination.pageSize)
    },
    
    // 按分类分组的产品
    productsByCategory() {
      const grouped = {}
      
      this.products.forEach(product => {
        if (!grouped[product.category]) {
          grouped[product.category] = []
        }
        grouped[product.category].push(product)
      })
      
      return grouped
    },
    
    // 获取特定产品
    getProductById: (state) => {
      return (id) => state.products.find(product => product.id === id)
    }
  },
  
  actions: {
    // 获取产品列表
    async fetchProducts() {
      this.loading = true
      this.error = null
      
      try {
        const response = await fetch('/api/products')
        
        if (!response.ok) {
          throw new Error('Failed to fetch products')
        }
        
        const data = await response.json()
        this.products = data.products
        this.pagination.total = data.total
        
      } catch (error) {
        this.error = error.message
        console.error('Error fetching products:', error)
      } finally {
        this.loading = false
      }
    },
    
    // 获取产品分类
    async fetchCategories() {
      try {
        const response = await fetch('/api/categories')
        
        if (!response.ok) {
          throw new Error('Failed to fetch categories')
        }
        
        const data = await response.json()
        this.categories = data
        
      } catch (error) {
        console.error('Error fetching categories:', error)
      }
    },
    
    // 获取单个产品详情
    async fetchProduct(id) {
      this.loading = true
      this.error = null
      
      try {
        const response = await fetch(`/api/products/${id}`)
        
        if (!response.ok) {
          throw new Error('Product not found')
        }
        
        const product = await response.json()
        this.currentProduct = product
        
        return product
      } catch (error) {
        this.error = error.message
        console.error('Error fetching product:', error)
        throw error
      } finally {
        this.loading = false
      }
    },
    
    // 搜索产品
    async searchProducts(query) {
      this.loading = true
      this.error = null
      
      try {
        const response = await fetch(`/api/products/search?q=${encodeURIComponent(query)}`)
        
        if (!response.ok) {
          throw new Error('Search failed')
        }
        
        const data = await response.json()
        this.products = data.products
        this.pagination.total = data.total
        this.pagination.page = 1 // 重置到第一页
        
      } catch (error) {
        this.error = error.message
        console.error('Error searching products:', error)
      } finally {
        this.loading = false
      }
    },
    
    // 更新过滤器
    updateFilters(newFilters) {
      this.filters = { ...this.filters, ...newFilters }
      this.pagination.page = 1 // 重置到第一页
    },
    
    // 更新分页
    updatePagination(newPagination) {
      this.pagination = { ...this.pagination, ...newPagination }
    },
    
    // 重置过滤器
    resetFilters() {
      this.filters = {
        category: '',
        priceRange: [0, 1000],
        sortBy: 'name',
        sortOrder: 'asc'
      }
      this.pagination.page = 1
    }
  }
})

6.5 Pinia插件和持久化

持久化插件

// plugins/persistence.js
export function createPersistencePlugin(options = {}) {
  return (context) => {
    const { store, options: storeOptions } = context
    
    // 检查是否启用持久化
    if (!storeOptions.persist) return
    
    const config = {
      key: storeOptions.persist.key || store.$id,
      storage: storeOptions.persist.storage || localStorage,
      paths: storeOptions.persist.paths || null,
      ...options
    }
    
    // 从存储中恢复状态
    function restore() {
      try {
        const saved = config.storage.getItem(config.key)
        if (saved) {
          const data = JSON.parse(saved)
          
          if (config.paths) {
            // 只恢复指定的路径
            config.paths.forEach(path => {
              if (data[path] !== undefined) {
                store.$patch({ [path]: data[path] })
              }
            })
          } else {
            // 恢复整个状态
            store.$patch(data)
          }
        }
      } catch (error) {
        console.error('Failed to restore state:', error)
      }
    }
    
    // 保存状态到存储
    function persist() {
      try {
        let dataToSave
        
        if (config.paths) {
          // 只保存指定的路径
          dataToSave = {}
          config.paths.forEach(path => {
            dataToSave[path] = store.$state[path]
          })
        } else {
          // 保存整个状态
          dataToSave = store.$state
        }
        
        config.storage.setItem(config.key, JSON.stringify(dataToSave))
      } catch (error) {
        console.error('Failed to persist state:', error)
      }
    }
    
    // 恢复状态
    restore()
    
    // 监听状态变化并持久化
    store.$subscribe((mutation, state) => {
      persist()
    })
  }
}
// main.js - 使用持久化插件
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createPersistencePlugin } from './plugins/persistence'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

// 添加持久化插件
pinia.use(createPersistencePlugin())

app.use(pinia)
app.mount('#app')
// stores/settings.js - 使用持久化
import { defineStore } from 'pinia'

export const useSettingsStore = defineStore('settings', {
  state: () => ({
    theme: 'light',
    language: 'zh-CN',
    notifications: true,
    autoSave: true,
    tempData: null // 这个不会被持久化
  }),
  
  actions: {
    updateTheme(theme) {
      this.theme = theme
    },
    
    updateLanguage(language) {
      this.language = language
    },
    
    toggleNotifications() {
      this.notifications = !this.notifications
    }
  }
}, {
  // 持久化配置
  persist: {
    key: 'app-settings',
    storage: localStorage,
    paths: ['theme', 'language', 'notifications', 'autoSave'] // 只持久化这些字段
  }
})

本章小结

本章我们学习了Pinia状态管理的核心概念:

  1. Store定义:Options API和Composition API两种风格
  2. 状态管理:state、getters、actions的使用
  3. 组件集成:在组件中使用store和保持响应性
  4. Store组合:多个store之间的协作
  5. 插件系统:持久化和其他扩展功能

下一章预告

下一章我们将学习Vue.js的高级特性,包括: - 自定义指令和插件开发 - 组合式API深入应用 - 性能优化技巧 - 测试策略

练习题

基础练习

  1. 计数器Store

    • 创建一个计数器store
    • 实现增加、减少、重置功能
    • 添加操作历史记录
  2. 用户管理Store

    • 实现用户登录/登出
    • 添加用户偏好设置
    • 实现数据持久化

进阶练习

  1. 购物车系统

    • 创建完整的购物车功能
    • 实现优惠券系统
    • 添加结算流程
  2. 状态持久化

    • 实现自定义持久化插件
    • 支持选择性持久化
    • 添加数据迁移功能

提示:状态管理是大型应用的核心,合理的状态设计能大大提升开发效率和代码可维护性。