Pinia 渐进式学习指南
基于 Pinia v3 + Vue 3 Composition API,从零到熟练的完整学习路径。
目录
- [什么是 Pinia?](#什么是 Pinia? "#%E4%B8%80%E4%BB%80%E4%B9%88%E6%98%AF-pinia")
- 安装与初始化
- [第一个 Store](#第一个 Store "#%E4%B8%89%E7%AC%AC%E4%B8%80%E4%B8%AA-store")
- State(状态)
- Getters(计算属性)
- Actions(方法)
- [在组件中使用 Store](#在组件中使用 Store "#%E4%B8%83%E5%9C%A8%E7%BB%84%E4%BB%B6%E4%B8%AD%E4%BD%BF%E7%94%A8-store")
- [Store 之间的组合](#Store 之间的组合 "#%E5%85%ABstore-%E4%B9%8B%E9%97%B4%E7%9A%84%E7%BB%84%E5%90%88")
- 插件机制
- [测试 Store](#测试 Store "#%E5%8D%81%E6%B5%8B%E8%AF%95-store")
- 最佳实践总结
一、什么是 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 的人友好)
类比:state → data,getters → computed,actions → methods。
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开头,如useCounterStore、useUserStore。
四、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) |
进阶资源
- Pinia 官方文档
- Pinia GitHub
- pinia-plugin-persistedstate(状态持久化)
- VueUse(可在 Setup Store 中使用的组合式函数库)