Vue.jsPinia状态管理

Pinia 是 Vue.js 的官方状态管理库,专为 Vue 3 设计,同时支持 Vue 2。它提供了直观、类型安全、模块化的状态管理方案,是 Vuex 的现代化替代品。

Pinia 概述

Pinia 是一个轻量级、易用的状态管理库,具有以下特点:

极简API

相比 Vuex,API 更简洁直观,学习成本低

TypeScript支持

完善的 TypeScript 支持,类型推断准确

模块化

天然支持模块化,每个 Store 都是独立的

开发工具

与 Vue DevTools 完美集成,调试方便

为什么选择 Pinia?
  • Vue 官方推荐,Vuex 团队维护
  • 更简洁的 API,更少的样板代码
  • 完整的 TypeScript 支持
  • 更好的组合式 API 集成
  • 支持服务端渲染 (SSR)
  • 更好的模块热更新 (HMR)

安装与配置

安装 Pinia

安装使用 npm 或 yarn 安装

# 使用 npm
npm install pinia

# 使用 yarn
yarn add pinia

# 使用 pnpm
pnpm add pinia
                        

基本配置

配置创建 Pinia 实例并挂载到 Vue 应用

// main.js / main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

// 创建 Pinia 实例
const pinia = createPinia()

// 创建 Vue 应用
const app = createApp(App)

// 使用 Pinia
app.use(pinia)

app.mount('#app')
                        

核心概念

Pinia 的核心概念非常简单,主要由三个部分组成:

概念 对应 Vuex 说明 特点
State State 存储应用的状态数据 响应式,可直接修改
Getter Getter 计算属性,派生状态 类似 Vue 的计算属性
Action Mutation + Action 执行操作,修改状态 同步/异步操作,直接修改 state
注意

Pinia 没有 Mutation 的概念,Action 既可以执行同步操作,也可以执行异步操作。这与 Vuex 不同,Vuex 中同步操作使用 Mutation,异步操作使用 Action。

创建第一个 Store

定义 Store

示例定义一个计数器 Store

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

// 使用 defineStore 定义 Store
// 第一个参数是 Store 的唯一 ID
export const useCounterStore = defineStore('counter', {
  // State: 存储状态数据
  state: () => ({
    count: 0,
    name: '计数器'
  }),

  // Getter: 计算属性
  getters: {
    // 自动推断返回类型
    doubleCount: (state) => state.count * 2,

    // 使用其他 getter
    doubleCountPlusOne() {
      return this.doubleCount + 1
    },

    // 带参数的 getter
    multiplyBy: (state) => {
      return (multiplier) => state.count * multiplier
    }
  },

  // Action: 执行操作
  actions: {
    increment() {
      // 直接修改 state
      this.count++
    },

    decrement() {
      this.count--
    },

    // 带参数的 action
    incrementBy(amount) {
      this.count += amount
    },

    // 异步 action
    async incrementAsync() {
      // 模拟异步操作
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    },

    // 重置状态
    reset() {
      this.$reset() // Pinia 提供的重置方法
    }
  }
})
                        

TypeScript 支持

TypeScript使用 TypeScript 定义 Store

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

// 定义 State 类型
interface CounterState {
  count: number
  name: string
}

export const useCounterStore = defineStore('counter', {
  // State
  state: (): CounterState => ({
    count: 0,
    name: '计数器'
  }),

  // Getter
  getters: {
    doubleCount(state): number {
      return state.count * 2
    },

    // 使用 this 访问其他 getter
    doubleCountPlusOne(): number {
      return this.doubleCount + 1
    }
  },

  // Action
  actions: {
    increment(): void {
      this.count++
    },

    incrementBy(amount: number): void {
      this.count += amount
    },

    async incrementAsync(): Promise {
      await new Promise(resolve => setTimeout(resolve, 1000))
      this.count++
    }
  }
})
                        

在组件中使用 Store

Options API 中使用

示例Options API 中使用 Store

<template>
  <div>
    <h3>计数器示例</h3>
    <p>当前计数: {{ count }}</p>
    <p>双倍计数: {{ doubleCount }}</p>
    <p>双倍计数加一: {{ doubleCountPlusOne }}</p>
    <p>乘以5: {{ multiplyBy(5) }}</p>

    <div class="mt-3">
      <button @click="increment">增加</button>
      <button @click="decrement">减少</button>
      <button @click="incrementBy(5)">增加5</button>
      <button @click="incrementAsync">异步增加</button>
      <button @click="reset">重置</button>
    </div>
  </div>
</template>

<script>
import { useCounterStore } from '@/stores/counter'
import { mapState, mapActions, mapWritableState } from 'pinia'

export default {
  name: 'CounterComponent',

  computed: {
    // 1. 直接使用 store 实例
    ...mapState(useCounterStore, ['count']),

    // 2. 使用 mapState 映射 getter
    ...mapState(useCounterStore, {
      doubleCount: 'doubleCount',
      doubleCountPlusOne: 'doubleCountPlusOne',
      multiplyBy: 'multiplyBy'
    }),

    // 3. 使用 mapWritableState 映射可写状态
    ...mapWritableState(useCounterStore, ['name'])
  },

  methods: {
    // 映射 actions
    ...mapActions(useCounterStore, [
      'increment',
      'decrement',
      'incrementBy',
      'incrementAsync',
      'reset'
    ])
  },

  mounted() {
    // 或者直接在方法中访问 store
    const store = useCounterStore()
    console.log('Store:', store)
  }
}
</script>
                        

Composition API 中使用

示例Composition API 中使用 Store

<template>
  <div>
    <h3>计数器示例 (Composition API)</h3>
    <p>当前计数: {{ count }}</p>
    <p>Store名称: {{ counterStore.name }}</p>
    <p>双倍计数: {{ doubleCount }}</p>
    <p>双倍计数加一: {{ doubleCountPlusOne }}</p>
    <p>乘以5: {{ multiplyBy(5) }}</p>

    <div class="mt-3">
      <button @click="increment">增加</button>
      <button @click="decrement">减少</button>
      <button @click="incrementBy(5)">增加5</button>
      <button @click="incrementAsync">异步增加</button>
      <button @click="reset">重置</button>
    </div>

    <div class="mt-3">
      <input v-model="newName" placeholder="修改store名称" />
      <button @click="updateName">更新名称</button>
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from 'pinia'

// 获取 store 实例
const counterStore = useCounterStore()

// 方法1: 直接访问 store
const count = computed(() => counterStore.count)
const doubleCount = computed(() => counterStore.doubleCount)
const doubleCountPlusOne = computed(() => counterStore.doubleCountPlusOne)
const multiplyBy = (multiplier) => counterStore.multiplyBy(multiplier)

// 方法2: 使用 storeToRefs 解构保持响应性
const { name } = storeToRefs(counterStore)
// 注意: 不能直接解构 actions,因为它们不是响应式的
const { increment, decrement, incrementBy, incrementAsync, reset } = counterStore

// 修改状态
const newName = ref('')
function updateName() {
  // 直接修改
  counterStore.name = newName.value
  // 或者使用 $patch
  counterStore.$patch({
    name: newName.value
  })
}

// 监听状态变化
import { watch } from 'vue'
watch(
  () => counterStore.count,
  (newCount, oldCount) => {
    console.log(`计数从 ${oldCount} 变为 ${newCount}`)
  }
)

// 访问整个 state
console.log('整个 state:', counterStore.$state)
</script>
                        

Pinia 与 Vuex 对比

特性 Pinia Vuex 4
API 设计 简洁直观,易于理解 相对复杂,概念较多
TypeScript 支持 原生支持,类型推断准确 需要额外配置,类型支持有限
模块化 天然模块化,每个 Store 独立 需要 namespaced 模块
Mutation 无,Action 处理所有操作 必需,用于同步修改状态
代码量 较少样板代码 较多样板代码
组合式 API 完美集成 需要额外配置
包大小 约 1.5KB 约 10KB
Vue 版本 Vue 2 和 Vue 3 Vue 2 (Vuex 3) / Vue 3 (Vuex 4)
何时选择 Pinia?
  • 新项目,特别是 Vue 3 项目
  • 需要良好的 TypeScript 支持
  • 希望减少样板代码
  • 需要更好的开发体验和调试工具

Store 模块化

Pinia 天然支持模块化,每个 Store 都是独立的模块。

定义多个 Store

示例定义用户和商品 Store

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

export const useUserStore = defineStore('user', {
  state: () => ({
    id: null,
    name: '',
    email: '',
    isLoggedIn: false,
    token: ''
  }),

  getters: {
    isAuthenticated: (state) => state.isLoggedIn,
    userInfo: (state) => ({
      id: state.id,
      name: state.name,
      email: state.email
    })
  },

  actions: {
    login(credentials) {
      // 模拟登录
      return new Promise((resolve) => {
        setTimeout(() => {
          this.id = 1
          this.name = '张三'
          this.email = 'zhangsan@example.com'
          this.isLoggedIn = true
          this.token = 'fake-jwt-token'

          // 保存到 localStorage
          localStorage.setItem('auth_token', this.token)

          resolve(this.userInfo)
        }, 1000)
      })
    },

    logout() {
      this.$reset()
      localStorage.removeItem('auth_token')
    },

    updateProfile(userData) {
      this.name = userData.name || this.name
      this.email = userData.email || this.email
    }
  }
})

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

// 使用 Composition API 风格定义 Store
export const useProductStore = defineStore('product', () => {
  // State
  const products = ref([])
  const cart = ref([])
  const isLoading = ref(false)

  // Getter
  const totalProducts = computed(() => products.value.length)
  const cartTotal = computed(() =>
    cart.value.reduce((total, item) => total + item.price * item.quantity, 0)
  )
  const cartItemCount = computed(() =>
    cart.value.reduce((count, item) => count + item.quantity, 0)
  )

  // Action
  async function fetchProducts() {
    isLoading.value = true
    try {
      // 模拟 API 调用
      const response = await fetch('/api/products')
      products.value = await response.json()
    } catch (error) {
      console.error('获取商品失败:', error)
    } finally {
      isLoading.value = false
    }
  }

  function addToCart(product) {
    const existingItem = cart.value.find(item => item.id === product.id)
    if (existingItem) {
      existingItem.quantity++
    } else {
      cart.value.push({
        ...product,
        quantity: 1
      })
    }
  }

  function removeFromCart(productId) {
    cart.value = cart.value.filter(item => item.id !== productId)
  }

  function clearCart() {
    cart.value = []
  }

  return {
    // State
    products,
    cart,
    isLoading,

    // Getter
    totalProducts,
    cartTotal,
    cartItemCount,

    // Action
    fetchProducts,
    addToCart,
    removeFromCart,
    clearCart
  }
})
                        

Store 之间交互

示例Store 之间互相调用

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

export const useOrderStore = defineStore('order', {
  state: () => ({
    orders: [],
    currentOrder: null
  }),

  actions: {
    async createOrder(cartItems) {
      const userStore = useUserStore()
      const productStore = useProductStore()

      if (!userStore.isAuthenticated) {
        throw new Error('请先登录')
      }

      // 创建订单
      const order = {
        id: Date.now(),
        userId: userStore.id,
        items: cartItems,
        total: cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0),
        createdAt: new Date().toISOString()
      }

      this.orders.push(order)
      this.currentOrder = order

      // 清空购物车
      productStore.clearCart()

      return order
    },

    async fetchUserOrders() {
      const userStore = useUserStore()

      if (!userStore.isAuthenticated) {
        throw new Error('请先登录')
      }

      // 模拟获取用户订单
      this.orders = [
        {
          id: 1,
          userId: userStore.id,
          items: [],
          total: 100,
          createdAt: '2023-01-01'
        }
      ]
    }
  }
})
                        

高级特性

插件系统

示例创建 Pinia 插件

// plugins/pinia-plugin.js
import { createPinia } from 'pinia'

// 持久化存储插件
export function piniaPersistPlugin({ store }) {
  // 从 localStorage 恢复状态
  const storageKey = `pinia-${store.$id}`
  const savedState = localStorage.getItem(storageKey)

  if (savedState) {
    store.$patch(JSON.parse(savedState))
  }

  // 监听状态变化并保存到 localStorage
  store.$subscribe((mutation, state) => {
    localStorage.setItem(storageKey, JSON.stringify(state))
  })
}

// 日志插件
export function piniaLoggerPlugin({ store }) {
  // 监听状态变化
  store.$subscribe((mutation, state) => {
    console.group(`Pinia Store: ${store.$id}`)
    console.log('Mutation:', mutation)
    console.log('New State:', state)
    console.groupEnd()
  })

  // 监听 action 执行
  store.$onAction(({ name, store, args, after, onError }) => {
    console.group(`Action: ${name}`)
    console.log('Store:', store.$id)
    console.log('Args:', args)

    after((result) => {
      console.log('Result:', result)
      console.groupEnd()
    })

    onError((error) => {
      console.error('Error:', error)
      console.groupEnd()
    })
  })
}

// 在应用中使用插件
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const pinia = createPinia()

// 注册插件
pinia.use(piniaPersistPlugin)
pinia.use(piniaLoggerPlugin)

const app = createApp(App)
app.use(pinia)
app.mount('#app')
                        

状态订阅

示例订阅状态变化

import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()

// 1. 订阅状态变化
const unsubscribe = counterStore.$subscribe((mutation, state) => {
  console.log('状态变化:', mutation)
  console.log('新状态:', state)

  // mutation 包含以下信息:
  // - type: 'direct' | 'patch object' | 'patch function'
  // - storeId: store 的 ID
  // - events: 触发的事件
})

// 取消订阅
unsubscribe()

// 2. 订阅特定状态的变化
import { watch } from 'vue'
watch(
  () => counterStore.count,
  (newCount, oldCount) => {
    console.log(`count 从 ${oldCount} 变为 ${newCount}`)
  }
)

// 3. 订阅 action 执行
counterStore.$onAction(({ name, store, args, after, onError }) => {
  console.log(`Action ${name} 开始执行`)

  after((result) => {
    console.log(`Action ${name} 执行完成,结果:`, result)
  })

  onError((error) => {
    console.error(`Action ${name} 执行失败:`, error)
  })
})
                        

状态管理演示

Pinia 状态管理演示

模拟一个简单的购物车应用:

用户管理:
未登录
请先登录
购物车:
商品数量: 0
购物车: 0 件商品

计数器 Store:
当前计数: 0
双倍计数: 0 | 双倍计数+1: 1 | 乘以3: 0
当前 Store 状态:

等待操作...

点击上方按钮模拟 Pinia Store 的各种操作

Pinia 最佳实践

Pinia 最佳实践
  • 合理划分 Store:按功能模块划分,避免单个 Store 过于庞大
  • 使用 TypeScript:充分利用 Pinia 的类型支持
  • 保持 Store 纯净:Store 只关注状态管理,业务逻辑放在 Action 中
  • 合理使用 Getter:将复杂的计算逻辑放在 Getter 中
  • 异步操作处理:使用 async/await 处理异步 Action
  • 错误处理:在 Action 中进行适当的错误处理
  • 性能优化:避免在 Getter 中执行昂贵计算
  • 测试 Store:为 Store 编写单元测试

总结

Pinia 是 Vue.js 生态中一个现代化、高效的状态管理解决方案。通过简洁的 API 设计和优秀的 TypeScript 支持,它大大简化了 Vue 应用的状态管理。

关键要点回顾:

  • 定义 Store:使用 defineStore 函数定义 Store
  • State:存储应用状态,响应式且可直接修改
  • Getter:计算属性,用于派生状态
  • Action:执行操作,支持同步和异步
  • 模块化:天然支持,每个 Store 都是独立模块
  • TypeScript:完整的类型支持,开发体验优秀
  • 插件系统:可扩展性强,支持自定义插件

无论是新项目还是迁移现有项目,Pinia 都提供了一个优秀的状态管理方案,值得在 Vue 应用中尝试和使用。

测试你的理解

思考题
  1. Pinia 和 Vuex 的主要区别是什么?
  2. 如何在 Pinia 中定义一个带有 TypeScript 类型支持的 Store?
  3. 在 Composition API 中如何正确使用 Store?
  4. Pinia 的 Action 和 Vuex 的 Mutation 有什么区别?
  5. 如何实现 Pinia 状态的持久化存储?