Vue 3 与 Pinia 高级状态管理模式

概述

Pinia 是 Vue 3 官方推荐的状态管理库,提供了更现代、更简洁的 API,支持 TypeScript,并且完全兼容 Composition API。与 Vuex 相比,Pinia 具有更少的样板代码、更好的类型支持和更灵活的架构。本集将深入探讨 Pinia 的高级状态管理模式,包括模块化设计、持久化状态、插件系统、性能优化和最佳实践,帮助你构建可扩展、可维护的 Vue 3 应用。

核心知识点

1. Pinia 基本概念

1.1 核心组件

Pinia 包含以下核心组件:

  • Store:状态管理的核心,包含 state、getters、actions
  • State:应用的状态数据
  • Getters:计算属性,用于从 state 派生数据
  • Actions:用于修改 state 的方法,可以是异步的

1.2 创建基本 Store

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

export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Counter'
  }),
  getters: {
    doubleCount: (state) => state.count * 2,
    // 使用其他 getters
    doubleCountPlusOne: (state, getters) => getters.doubleCount + 1
  },
  actions: {
    increment() {
      this.count++
    },
    // 异步 actions
    async fetchData() {
      const data = await fetch('https://api.example.com/data')
      this.name = (await data.json()).name
    }
  }
})

2. 模块化状态管理

2.1 按功能划分模块

// src/stores/index.js
import { createPinia } from 'pinia'

const pinia = createPinia()

export default pinia

// src/stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: null
  }),
  getters: {
    isAuthenticated: (state) => !!state.token
  },
  actions: {
    async login(credentials) {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        body: JSON.stringify(credentials),
        headers: { 'Content-Type': 'application/json' }
      })
      const data = await response.json()
      this.user = data.user
      this.token = data.token
    },
    logout() {
      this.user = null
      this.token = null
    }
  }
})

// src/stores/cart.js
import { defineStore } from 'pinia'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    total: 0
  }),
  getters: {
    itemCount: (state) => state.items.length
  },
  actions: {
    addItem(item) {
      this.items.push(item)
      this.calculateTotal()
    },
    removeItem(itemId) {
      this.items = this.items.filter(item => item.id !== itemId)
      this.calculateTotal()
    },
    calculateTotal() {
      this.total = this.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
    }
  }
})

2.2 使用 setup 语法

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

export const useProductStore = defineStore('product', () => {
  // state
  const products = ref([])
  const loading = ref(false)
  
  // getters
  const featuredProducts = computed(() => {
    return products.value.filter(product => product.featured)
  })
  
  const getProductById = computed(() => (id) => {
    return products.value.find(product => product.id === id)
  })
  
  // actions
  async function fetchProducts() {
    loading.value = true
    try {
      const response = await fetch('/api/products')
      products.value = await response.json()
    } catch (error) {
      console.error('Failed to fetch products:', error)
    } finally {
      loading.value = false
    }
  }
  
  async function fetchProduct(id) {
    loading.value = true
    try {
      const response = await fetch(`/api/products/${id}`)
      const product = await response.json()
      const index = products.value.findIndex(p => p.id === id)
      if (index !== -1) {
        products.value[index] = product
      } else {
        products.value.push(product)
      }
      return product
    } catch (error) {
      console.error(`Failed to fetch product ${id}:`, error)
    } finally {
      loading.value = false
    }
  }
  
  return {
    products,
    loading,
    featuredProducts,
    getProductById,
    fetchProducts,
    fetchProduct
  }
})

3. 状态持久化

3.1 使用插件实现持久化

// src/plugins/piniaPersist.js
import { ref, computed } from 'vue'

export function piniaPersist() {
  return {
    install(pinia) {
      pinia.use(({ store }) => {
        // 初始化时从 localStorage 加载数据
        const savedState = localStorage.getItem(`pinia-${store.$id}`)
        if (savedState) {
          store.$patch(JSON.parse(savedState))
        }
        
        // 订阅状态变化,保存到 localStorage
        store.$subscribe((mutation, state) => {
          localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
        })
      })
    }
  }
}

// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { piniaPersist } from './plugins/piniaPersist'

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

// 使用持久化插件
pinia.use(piniaPersist())

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

3.2 使用第三方库

# 安装 pinia-plugin-persistedstate
npm install pinia-plugin-persistedstate
// src/main.js
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'

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

// 使用持久化插件
pinia.use(piniaPluginPersistedstate)

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

// src/stores/user.js
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', {
  state: () => ({
    user: null,
    token: null
  }),
  getters: {
    isAuthenticated: (state) => !!state.token
  },
  actions: {
    // ...
  },
  persist: true // 启用持久化
})

// 高级配置

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [],
    total: 0
  }),
  // ...
  persist: {
    key: 'custom-cart-key',
    storage: sessionStorage, // 使用 sessionStorage 而非默认的 localStorage
    paths: ['items'] // 只持久化 items 字段
  }
})

4. Pinia 插件系统

4.1 自定义插件

// src/plugins/piniaLogger.js
export function piniaLogger() {
  return {
    install(pinia) {
      pinia.use(({ store }) => {
        console.log(`[Pinia] Store ${store.$id} initialized`)
        
        // 监听 actions
        store.$onAction(({ name, args, after, onError }) => {
          console.log(`[Pinia] ${store.$id}.${name} starting with args:`, args)
          
          after((result) => {
            console.log(`[Pinia] ${store.$id}.${name} completed with result:`, result)
          })
          
          onError((error) => {
            console.error(`[Pinia] ${store.$id}.${name} failed with error:`, error)
          })
        })
        
        // 监听状态变化
        store.$subscribe((mutation, state) => {
          console.log(`[Pinia] ${store.$id} state changed:`, mutation, state)
        })
      })
    }
  }
}

// src/main.js
import { piniaLogger } from './plugins/piniaLogger'

// 使用日志插件
pinia.use(piniaLogger())

4.2 插件之间的通信

// src/plugins/piniaPluginA.js
export function piniaPluginA() {
  return {
    install(pinia) {
      pinia.use(({ store }) => {
        store.$customPropertyA = 'custom value A'
      })
    }
  }
}

// src/plugins/piniaPluginB.js
export function piniaPluginB() {
  return {
    install(pinia) {
      pinia.use(({ store }) => {
        // 可以访问其他插件添加的属性
        console.log(store.$customPropertyA) // 'custom value A'
        store.$customPropertyB = 'custom value B'
      })
    }
  }
}

5. 与 Vue Router 集成

// src/router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '../stores/user'

const routes = [
  {
    path: '/',
    name: 'Home',
    component: () => import('../views/HomeView.vue')
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('../views/AboutView.vue')
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('../views/DashboardView.vue'),
    meta: { requiresAuth: true }
  },
  {
    path: '/login',
    name: 'Login',
    component: () => import('../views/LoginView.vue')
  }
]

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes
})

// 路由守卫
router.beforeEach((to, from, next) => {
  const userStore = useUserStore()
  
  if (to.matched.some(record => record.meta.requiresAuth)) {
    if (!userStore.isAuthenticated) {
      next({ name: 'Login' })
    } else {
      next()
    }
  } else {
    next()
  }
})

export default router

6. 调试技巧

6.1 浏览器开发者工具

Pinia 提供了 Vue DevTools 集成,可以在开发者工具中查看和修改状态。

6.2 使用 $patch 批量更新

// 单个更新
store.count++
store.name = 'New Name'

// 批量更新
store.$patch({
  count: store.count + 1,
  name: 'New Name'
})

// 使用函数批量更新
store.$patch((state) => {
  state.items.push({ id: 1, name: 'Product 1', price: 100 })
  state.total += 100
})

6.3 重置状态

// 重置整个状态
store.$reset()

7. 性能优化

7.1 使用 shallowRef

// src/stores/product.js
import { defineStore } from 'pinia'
import { shallowRef, computed } from 'vue'

export const useProductStore = defineStore('product', () => {
  // 使用 shallowRef 处理大型数组或对象
  const products = shallowRef([])
  const loading = shallowRef(false)
  
  // ...
})

7.2 按需获取状态

// src/components/ProductList.vue
<script setup lang="ts">
import { useProductStore } from '@/stores/product'

// 使用 storeToRefs 避免不必要的重新渲染
import { storeToRefs } from 'pinia'

const productStore = useProductStore()
const { products, loading } = storeToRefs(productStore)
const { fetchProducts } = productStore
</script>

7.3 限制订阅频率

// src/plugins/piniaPersist.js
import { ref, computed } from 'vue'

export function piniaPersist() {
  return {
    install(pinia) {
      pinia.use(({ store }) => {
        // 初始化时从 localStorage 加载数据
        const savedState = localStorage.getItem(`pinia-${store.$id}`)
        if (savedState) {
          store.$patch(JSON.parse(savedState))
        }
        
        // 使用防抖函数限制保存频率
        let timeoutId
        store.$subscribe((mutation, state) => {
          clearTimeout(timeoutId)
          timeoutId = setTimeout(() => {
            localStorage.setItem(`pinia-${store.$id}`, JSON.stringify(state))
          }, 1000) // 1秒防抖
        })
      })
    }
  }
}

最佳实践

1. 遵循单一职责原则

  • 每个 Store 只负责一个功能领域
  • 避免在一个 Store 中包含不相关的状态
  • 保持 Store 的大小适中,便于维护

2. 使用 TypeScript

// src/stores/user.ts
import { defineStore } from 'pinia'

interface User {
  id: string
  name: string
  email: string
  role: 'admin' | 'user'
}

interface UserState {
  user: User | null
  token: string | null
  loading: boolean
}

export const useUserStore = defineStore('user', {
  state: (): UserState => ({
    user: null,
    token: null,
    loading: false
  }),
  getters: {
    isAuthenticated: (state): boolean => !!state.token,
    isAdmin: (state): boolean => state.user?.role === 'admin'
  },
  actions: {
    async login(credentials: { email: string; password: string }): Promise<void> {
      this.loading = true
      try {
        const response = await fetch('/api/auth/login', {
          method: 'POST',
          body: JSON.stringify(credentials),
          headers: { 'Content-Type': 'application/json' }
        })
        const data = await response.json()
        this.user = data.user
        this.token = data.token
      } catch (error) {
        console.error('Login failed:', error)
      } finally {
        this.loading = false
      }
    }
  }
})

3. 避免直接修改状态

// 不推荐
store.count++
store.items.push(newItem)

// 推荐
store.increment() // 通过 action 修改
store.addItem(newItem) // 通过 action 修改

4. 使用组合式 API

对于复杂的状态管理,使用组合式 API 可以更好地组织代码:

// src/stores/complex.js
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'

export const useComplexStore = defineStore('complex', () => {
  // 基础状态
  const count = ref(0)
  const items = ref([])
  
  // 计算属性
  const total = computed(() => {
    return items.value.reduce((sum, item) => sum + item.value, 0)
  })
  
  // 监听变化
  watch(count, (newCount) => {
    console.log(`Count changed to: ${newCount}`)
  })
  
  // 异步 action
  async function fetchData() {
    const response = await fetch('/api/data')
    const data = await response.json()
    items.value = data.items
  }
  
  return {
    count,
    items,
    total,
    fetchData
  }
})

5. 测试 Store

// src/stores/__tests__/user.test.js
import { describe, it, expect, beforeEach } from 'vitest'
import { createPinia } from 'pinia'
import { useUserStore } from '../user'

describe('User Store', () => {
  let userStore
  
  beforeEach(() => {
    const pinia = createPinia()
    userStore = useUserStore(pinia)
  })
  
  it('should initialize with null user and token', () => {
    expect(userStore.user).toBeNull()
    expect(userStore.token).toBeNull()
    expect(userStore.isAuthenticated).toBe(false)
  })
  
  it('should login successfully', async () => {
    // Mock fetch
    global.fetch = vi.fn(() => 
      Promise.resolve({
        json: () => Promise.resolve({
          user: { id: '1', name: 'Test User', email: 'test@example.com' },
          token: 'test-token'
        })
      })
    )
    
    await userStore.login({ email: 'test@example.com', password: 'password' })
    
    expect(userStore.user).toEqual({ id: '1', name: 'Test User', email: 'test@example.com' })
    expect(userStore.token).toBe('test-token')
    expect(userStore.isAuthenticated).toBe(true)
  })
  
  it('should logout successfully', () => {
    userStore.user = { id: '1', name: 'Test User', email: 'test@example.com' }
    userStore.token = 'test-token'
    
    userStore.logout()
    
    expect(userStore.user).toBeNull()
    expect(userStore.token).toBeNull()
    expect(userStore.isAuthenticated).toBe(false)
  })
})

常见问题和解决方案

1. 状态不更新

问题:修改状态后,组件没有重新渲染

解决方案

  • 确保使用 storeToRefs 来解构状态
  • 避免直接修改对象或数组,使用 $patch 或创建新的引用
  • 检查是否使用了 shallowRef 而没有正确处理

2. 持久化不生效

问题:状态没有被正确持久化到 localStorage

解决方案

  • 检查插件是否正确安装
  • 检查 localStorage 是否可用
  • 确保状态是可序列化的(不包含函数、Symbol 等)

3. 类型错误

问题:TypeScript 类型错误

解决方案

  • 确保为状态和 getters 定义了正确的类型
  • 使用 defineStore 的泛型参数
  • 检查是否正确导入了类型

4. 性能问题

问题:Store 过大导致性能问题

解决方案

  • 拆分 Store 为多个小模块
  • 使用 shallowRef 处理大型数据
  • 避免不必要的状态订阅
  • 按需加载 Store

5. 路由守卫中使用 Store

问题:在路由守卫中无法使用 Store

解决方案

  • 确保在创建路由之前初始化 Pinia
  • 在路由守卫中使用 useUserStore() 而不是通过依赖注入

进阶学习资源

1. 官方文档

2. 工具和库

3. 最佳实践指南

4. 学习案例

实践练习

练习 1:基础 Store 创建

  1. 创建一个 Vue 3 + TypeScript 项目
  2. 安装和配置 Pinia
  3. 创建一个简单的 Counter Store
  4. 在组件中使用 Store
  5. 测试状态更新和 getters

练习 2:模块化设计

  1. 创建用户 Store(user.js)
  2. 创建购物车 Store(cart.js)
  3. 创建产品 Store(product.js)
  4. 在组件中使用多个 Store
  5. 实现 Store 之间的交互

练习 3:状态持久化

  1. 使用 pinia-plugin-persistedstate 插件
  2. 配置用户 Store 的持久化
  3. 配置购物车 Store 的持久化
  4. 测试页面刷新后状态是否保留

练习 4:路由集成

  1. 创建需要认证的页面
  2. 实现登录和登出功能
  3. 配置路由守卫
  4. 测试认证流程

练习 5:性能优化

  1. 使用 shallowRef 处理大型数据
  2. 使用 storeToRefs 避免不必要的重新渲染
  3. 实现 Store 的按需加载
  4. 测试性能优化效果

总结

Pinia 是 Vue 3 官方推荐的状态管理库,提供了更现代、更简洁的 API,支持 TypeScript,并且完全兼容 Composition API。通过掌握 Pinia 的高级状态管理模式,包括模块化设计、持久化状态、插件系统、路由集成、调试技巧和性能优化,你可以构建可扩展、可维护的 Vue 3 应用。

下一集我们将学习 Vue 3 与 Vue Router 高级路由模式,敬请期待!

« 上一篇 Vue 3与LogRocket用户行为分析 - 全面用户体验优化解决方案 下一篇 » Vue 3与Vue Router高级路由模式 - 现代化导航系统解决方案