Pinia 渐进式学习指南

Pinia 渐进式学习指南

基于 Pinia v3 + Vue 3 Composition API,从零到熟练的完整学习路径。


目录

  1. [什么是 Pinia?](#什么是 Pinia? "#%E4%B8%80%E4%BB%80%E4%B9%88%E6%98%AF-pinia")
  2. 安装与初始化
  3. [第一个 Store](#第一个 Store "#%E4%B8%89%E7%AC%AC%E4%B8%80%E4%B8%AA-store")
  4. State(状态)
  5. Getters(计算属性)
  6. Actions(方法)
  7. [在组件中使用 Store](#在组件中使用 Store "#%E4%B8%83%E5%9C%A8%E7%BB%84%E4%BB%B6%E4%B8%AD%E4%BD%BF%E7%94%A8-store")
  8. [Store 之间的组合](#Store 之间的组合 "#%E5%85%ABstore-%E4%B9%8B%E9%97%B4%E7%9A%84%E7%BB%84%E5%90%88")
  9. 插件机制
  10. [测试 Store](#测试 Store "#%E5%8D%81%E6%B5%8B%E8%AF%95-store")
  11. 最佳实践总结

一、什么是 Pinia?

Pinia 是 Vue 官方推荐的状态管理库,也是 Vuex 的继任者。它具有以下特点:

  • 更简洁的 API,无需 mutations,直接修改状态
  • 天然支持 TypeScript,类型推断开箱即用
  • 支持 Composition API,与 Vue 3 无缝集成
  • 模块化设计,每个 Store 都是独立的,没有嵌套模块
  • 集成 Vue DevTools,支持调试

与 Vuex 对比:

特性 Vuex Pinia
Mutations 必须 不需要
模块嵌套 嵌套模块 扁平独立 Store
TypeScript 需要额外配置 原生支持
Composition API 不原生支持 完整支持
代码量 较多 精简

二、安装与初始化

安装

csharp 复制代码
npm install pinia
# 或
pnpm add pinia

在 Vue 应用中挂载

javascript 复制代码
// main.ts
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')

推荐目录结构

bash 复制代码
src/
  stores/
    counter.ts     # 计数器 Store
    user.ts        # 用户 Store
    cart.ts        # 购物车 Store
  components/
  App.vue
  main.ts

三、第一个 Store

Store 的两种写法

Pinia 提供两种风格,推荐使用 Setup Store(Composition API 风格)。

Option Store(熟悉 Vue Options API 的人友好)

类比:statedatagetterscomputedactionsmethods

javascript 复制代码
// stores/counter.ts
import { defineStore } from 'pinia'
​
export const useCounterStore = defineStore('counter', {
  state: () => ({
    count: 0,
    name: 'Pinia',
  }),
  getters: {
    doubleCount: (state) => state.count * 2,
  },
  actions: {
    increment() {
      this.count++
    },
  },
})
Setup Store(推荐)

和写 <script setup> 一模一样的体验:

javascript 复制代码
// stores/counter.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
​
export const useCounterStore = defineStore('counter', () => {
  // ref() → state
  const count = ref(0)
  const name = ref('Pinia')
​
  // computed() → getters
  const doubleCount = computed(() => count.value * 2)
​
  // function() → actions
  function increment() {
    count.value++
  }
​
  // 必须 return 所有需要被追踪的属性
  return { count, name, doubleCount, increment }
})

命名约定 :Store 函数统一以 use 开头,如 useCounterStoreuseUserStore


四、State(状态)

基本使用

typescript 复制代码
// stores/user.ts
import { defineStore } from 'pinia'
​
interface UserInfo {
  id: number
  name: string
  email: string
}
​
export const useUserStore = defineStore('user', {
  state: () => ({
    userList: [] as UserInfo[],
    currentUser: null as UserInfo | null,
    isLoading: false,
  }),
})

直接修改 state

ini 复制代码
const store = useUserStore()
​
// 直接赋值(Pinia 允许直接修改,无需 mutations)
store.isLoading = true
store.currentUser = { id: 1, name: 'Alice', email: 'alice@example.com' }

批量修改:$patch

当需要同时修改多个属性时,用 $patch 更高效(只触发一次响应):

php 复制代码
// 对象语法
store.$patch({
  isLoading: false,
  currentUser: { id: 1, name: 'Alice', email: 'alice@example.com' },
})
​
// 函数语法(适合复杂操作,如数组 push)
store.$patch((state) => {
  state.userList.push({ id: 2, name: 'Bob', email: 'bob@example.com' })
  state.isLoading = false
})

重置状态:$reset

Option Store 内置 $reset()

php 复制代码
store.$reset() // 恢复到初始状态

Setup Store 需要手动实现:

javascript 复制代码
export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)

  function $reset() {
    count.value = 0
  }

  return { count, $reset }
})

监听状态变化:$subscribe

typescript 复制代码
// 类似 watch,但专为 Store 设计
store.$subscribe((mutation, state) => {
  // mutation.type: 'direct' | 'patch object' | 'patch function'
  // mutation.storeId: Store 的 id
  console.log(`[${mutation.type}] storeId: ${mutation.storeId}`)

  // 将状态持久化到 localStorage
  localStorage.setItem('user', JSON.stringify(state))
})

// 组件卸载后仍然保持订阅(默认会随组件卸载而销毁)
store.$subscribe(callback, { detached: true })

五、Getters(计算属性)

Getters 等同于 Vue 的 computed,会自动缓存结果。

基础用法

typescript 复制代码
export const useCartStore = defineStore('cart', {
  state: () => ({
    items: [] as { name: string; price: number; qty: number }[],
  }),
  getters: {
    // 参数 state,自动推断类型
    totalPrice: (state) =>
      state.items.reduce((sum, item) => sum + item.price * item.qty, 0),

    // 访问其他 getter,需要用 this(需手动标注返回类型)
    formattedTotal(): string {
      return `¥${this.totalPrice.toFixed(2)}`
    },
  },
})

带参数的 Getter

Getter 返回一个函数(注意:带参数的 Getter 不会缓存):

typescript 复制代码
getters: {
  getItemByName: (state) => {
    return (name: string) => state.items.find((item) => item.name === name)
  },

  // 手动缓存:先过滤再查找
  getActiveItem(state) {
    const activeItems = state.items.filter((i) => i.qty > 0) // 缓存这部分
    return (name: string) => activeItems.find((i) => i.name === name)
  },
},
xml 复制代码
<script setup>
const cart = useCartStore()
const apple = cart.getItemByName('苹果')
</script>

跨 Store 访问

javascript 复制代码
import { useUserStore } from './user'

getters: {
  personalizedGreeting(state) {
    const userStore = useUserStore()
    return `你好,${userStore.currentUser?.name}!你有 ${state.items.length} 件商品`
  },
},

六、Actions(方法)

Actions 既可以是同步的,也可以是异步的,直接替代了 Vuex 中的 mutations + actions 两层结构。

同步 Action

typescript 复制代码
export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment() {
      this.count++
    },
    incrementBy(amount: number) {
      this.count += amount
    },
  },
})

异步 Action(最常用)

typescript 复制代码
export const useUserStore = defineStore('user', {
  state: () => ({
    currentUser: null as UserInfo | null,
    isLoading: false,
    error: null as string | null,
  }),
  actions: {
    async fetchUser(userId: number) {
      this.isLoading = true
      this.error = null
      try {
        const response = await fetch(`/api/users/${userId}`)
        if (!response.ok) throw new Error('请求失败')
        this.currentUser = await response.json()
      }
      catch (e) {
        this.error = (e as Error).message
      }
      finally {
        this.isLoading = false
      }
    },
  },
})

跨 Store 调用

javascript 复制代码
import { useAuthStore } from './auth'

actions: {
  async loadProfile() {
    const auth = useAuthStore()
    if (!auth.isLoggedIn) return

    // SSR 注意:在 await 之前调用所有 useStore()
    this.profile = await fetchProfile(auth.userId)
  },
},

监听 Action 调用:$onAction

javascript 复制代码
const unsubscribe = store.$onAction(({ name, args, after, onError }) => {
  console.log(`调用了 action: ${name},参数:`, args)

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

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

// 手动取消订阅
unsubscribe()

// 组件卸载后仍保持订阅
store.$onAction(callback, true)

七、在组件中使用 Store

基本使用

xml 复制代码
<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()
</script>

<template>
  <div>
    <p>Count: {{ counter.count }}</p>
    <p>Double: {{ counter.doubleCount }}</p>
    <button @click="counter.increment">+1</button>
  </div>
</template>

解构时保持响应性:storeToRefs

直接解构 store 会丢失响应性 ,必须用 storeToRefs

xml 复制代码
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore()

// ❌ 错误:count 和 doubleCount 失去响应性
const { count, doubleCount } = counter

// ✅ 正确:state 和 getter 用 storeToRefs 解构
const { count, doubleCount } = storeToRefs(counter)

// ✅ 正确:actions 可以直接解构(它们不是响应式数据)
const { increment } = counter
</script>

在模板中直接绑定 Store 状态

xml 复制代码
<template>
  <!-- v-model 直接绑定 store 中的状态 -->
  <input v-model="userStore.searchKeyword" placeholder="搜索..." />
</template>

Options API 中使用(兼容写法)

javascript 复制代码
import { mapState, mapWritableState, mapActions } from 'pinia'
import { useCounterStore } from '@/stores/counter'

export default {
  computed: {
    // 只读映射(state + getters)
    ...mapState(useCounterStore, ['count', 'doubleCount']),
    // 可写映射(state)
    ...mapWritableState(useCounterStore, ['count']),
  },
  methods: {
    ...mapActions(useCounterStore, ['increment']),
  },
}

八、Store 之间的组合

单向依赖

合理拆分 Store,通过在 action 或 getter 中引用其他 store:

javascript 复制代码
// stores/order.ts
import { defineStore } from 'pinia'
import { useCartStore } from './cart'
import { useUserStore } from './user'

export const useOrderStore = defineStore('order', {
  state: () => ({
    orders: [] as Order[],
  }),
  actions: {
    async submitOrder() {
      const cart = useCartStore()
      const user = useUserStore()

      const order = await api.createOrder({
        userId: user.currentUser!.id,
        items: cart.items,
        total: cart.totalPrice,
      })

      this.orders.push(order)
      cart.$reset() // 下单后清空购物车
    },
  },
})

避免循环依赖

如果两个 Store 互相引用,可以将共享逻辑提取到第三个 Store 或普通的 composable 中:

复制代码
❌ userStore → authStore → userStore  (循环)

✅ authStore → userStore              (单向)
✅ sessionComposable(普通函数,不是 Store)

九、插件机制

插件可以为所有 Store 添加全局属性、方法或订阅行为。

创建插件

typescript 复制代码
// plugins/persistPlugin.ts
import type { PiniaPlugin } from 'pinia'

export const persistPlugin: PiniaPlugin = ({ store }) => {
  // 从 localStorage 恢复状态
  const saved = localStorage.getItem(store.$id)
  if (saved) {
    store.$patch(JSON.parse(saved))
  }

  // 监听变化,自动持久化
  store.$subscribe((_mutation, state) => {
    localStorage.setItem(store.$id, JSON.stringify(state))
  })
}

注册插件

javascript 复制代码
// main.ts
import { createPinia } from 'pinia'
import { persistPlugin } from './plugins/persistPlugin'

const pinia = createPinia()
pinia.use(persistPlugin)

为插件属性添加 TypeScript 类型

typescript 复制代码
// 扩展 PiniaCustomProperties 接口
declare module 'pinia' {
  export interface PiniaCustomProperties {
    $persistedAt: Date
  }
}

推荐插件pinia-plugin-persistedstate 是社区成熟的持久化插件,生产环境可直接使用。


十、测试 Store

安装测试工具

bash 复制代码
npm install -D @pinia/testing vitest

单独测试 Store 逻辑

scss 复制代码
// stores/counter.test.ts
import { describe, it, expect, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useCounterStore } from './counter'

describe('useCounterStore', () => {
  beforeEach(() => {
    // 每次测试前创建新的 Pinia 实例,避免测试间状态污染
    setActivePinia(createPinia())
  })

  it('初始 count 为 0', () => {
    const store = useCounterStore()
    expect(store.count).toBe(0)
  })

  it('increment 使 count 加 1', () => {
    const store = useCounterStore()
    store.increment()
    expect(store.count).toBe(1)
  })

  it('doubleCount 是 count 的两倍', () => {
    const store = useCounterStore()
    store.count = 5
    expect(store.doubleCount).toBe(10)
  })
})

在组件测试中 Mock Store

javascript 复制代码
// components/Counter.test.ts
import { mount } from '@vue/test-utils'
import { createTestingPinia } from '@pinia/testing'
import { vi } from 'vitest'
import Counter from './Counter.vue'
import { useCounterStore } from '@/stores/counter'

it('点击按钮调用 increment', async () => {
  const wrapper = mount(Counter, {
    global: {
      plugins: [
        createTestingPinia({
          // Actions 默认被替换为 spy(不真正执行)
          createSpy: vi.fn,
          initialState: {
            counter: { count: 10 }, // 设置初始状态
          },
        }),
      ],
    },
  })

  const store = useCounterStore()

  await wrapper.find('button').trigger('click')

  // 验证 action 被调用
  expect(store.increment).toHaveBeenCalledOnce()
})

十一、最佳实践总结

Store 设计原则

sql 复制代码
✅ 按业务领域划分 Store(user、cart、product)
✅ 每个 Store 职责单一,不要把所有状态塞到一个 Store
✅ 优先使用 Setup Store(更灵活,可使用 composables 和 watchers)
✅ Store 只存放全局共享状态,局部组件状态用 ref/reactive

响应性规范

bash 复制代码
✅ 解构 state/getter 时使用 storeToRefs()
✅ actions 可以直接解构,无需 storeToRefs
✅ 批量修改状态时用 $patch,减少响应触发次数

性能与安全

scss 复制代码
✅ 在 SSR 中,await 之前提前调用所有 useStore()
✅ 避免 Store 之间循环引用
✅ 不要在模块顶层(组件外)调用 useStore(),应在函数内部调用
✅ 敏感数据(token、密钥)不要存入 Store,避免 DevTools 泄露

快速查阅

需求 方案
修改单个属性 store.count = 1
修改多个属性 store.$patch({ ... })
复杂修改(数组等) store.$patch(state => { ... })
重置到初始状态 store.$reset()
订阅状态变化 store.$subscribe(cb)
订阅 Action 调用 store.$onAction(cb)
解构保持响应性 storeToRefs(store)

进阶资源

相关推荐
你听得到114 小时前
周下载60w,但是作者删库!我从本地 pub 缓存里把它救出来,顺手备份到了自己的 GitHub
前端·flutter
PeterMap4 小时前
Vue组合式API响应式状态声明:ref与reactive实战解析
前端·vue.js
CodeGuru4 小时前
UniApp Vue3 生成海报并分享到朋友圈
前端
三原4 小时前
附源码:三原管理系统新增俩种常用布局
java·前端·vue.js
布局呆星4 小时前
Vue3 | 组件化开发---组件插槽与通信
前端·javascript·vue.js
DyLatte4 小时前
当我想把所有角色都做好时,就开始内耗了
前端·后端·程序员
a1117764 小时前
汽车展厅项目 开源项目 ThreeJS
前端·开源·html
阳火锅5 小时前
Element / AntD 官方都没做好的功能,被这个开源小插件搞定了!
前端·vue.js·面试
大阳光男孩5 小时前
Uniapp+Vue3树形选择器
前端·javascript·uni-app