Pinia 取代 Vuex 不是意外

Vuex 4 还在用 mutationsactionsgetters 三段式,写起来像填表格。Pinia 一把梭:一个 store 就是一组相关的状态和方法,没有 mutation 概念,直接改。

Vue 官方现在推荐 Pinia 作为默认状态管理方案。如果你的项目还在用 Vuex,可以开始迁了。

基础:一个完整的 Store

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// stores/user.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useUserStore = defineStore('user', () => {
// State
const token = ref('')
const profile = ref<UserProfile | null>(null)
const isLoggedIn = computed(() => !!token.value)

// Actions(直接改 state,不需要 mutation)
async function login(phone: string, code: string) {
const res = await api.login(phone, code)
token.value = res.token
profile.value = res.user
}

function logout() {
token.value = ''
profile.value = null
router.push('/login')
}

return { token, profile, isLoggedIn, login, logout }
})

关键区别:不需要 commit('SET_TOKEN'),直接 token.value = xxx。简洁到让你怀疑是不是漏了什么步骤——但这就是 Pinia。

Pinia vs Vuex:一张表说清

Vuex 4 Pinia
概念数量 State / Getters / Mutations / Actions / Modules State / Getters / Actions
类型推断 弱,需要额外声明 原生 TypeScript 支持
模块化 modules 嵌套,命名空间 平铺,每个 store 独立文件
修改状态 必须通过 commit mutation 直接赋值
DevTools 支持 支持,且更好用
体积 ~10KB ~5KB
组合式 API 不原生支持 原生支持 Setup Store

四种常用模式

1. Setup Store(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])

const totalPrice = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.quantity, 0)
)

const itemCount = computed(() => items.value.length)

function addItem(item: CartItem) {
const existing = items.value.find(i => i.id === item.id)
if (existing) {
existing.quantity++
} else {
items.value.push({ ...item, quantity: 1 })
}
}

function removeItem(id: string) {
items.value = items.value.filter(i => i.id !== id)
}

return { items, totalPrice, itemCount, addItem, removeItem }
})

Setup Store 本质就是一个 composable——所有你熟悉的 Vue API(refcomputedwatch)都能在里面用。

2. 模块化拆分

不要把所有状态塞进一个 store:

1
2
3
4
5
6
stores/
├── auth.ts # 登录、Token、用户信息
├── cart.ts # 购物车
├── notification.ts # 通知、Toast
├── theme.ts # 暗色模式、语言
└── app.ts # 全局 Loading、路由状态

每个 store 职责单一,组件按需引用。

3. Store 之间互相引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// stores/checkout.ts
import { useCartStore } from './cart'
import { useAuthStore } from './auth'

export const useCheckoutStore = defineStore('checkout', () => {
const cart = useCartStore()
const auth = useAuthStore()

async function submitOrder() {
if (!auth.isLoggedIn) throw new Error('请先登录')
await api.createOrder({
items: cart.items,
total: cart.totalPrice,
userId: auth.profile!.id,
})
cart.clear()
}

return { submitOrder }
})

跨 store 引用就像引用普通 composable,不需要 rootStaterootGetters 这些间接调用。

4. 持久化(刷新不丢)

1
npm install pinia-plugin-persistedstate
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// main.ts
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

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

// stores/auth.ts
export const useAuthStore = defineStore('auth', () => {
const token = ref('')

return { token }
}, {
persist: {
key: 'auth',
storage: localStorage, // 或 sessionStorage
paths: ['token'], // 只持久化 token,不存密码等敏感字段
}
})

组件中使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<script setup lang="ts">
import { useCartStore } from '@/stores/cart'
import { storeToRefs } from 'pinia'

const cart = useCartStore()

// 解构响应式数据必须用 storeToRefs
const { items, totalPrice, itemCount } = storeToRefs(cart)

// 方法可以直接解构(不是响应式,不需要 storeToRefs)
const { addItem, removeItem } = cart
</script>

<template>
<div>共 {{ itemCount }} 件,合计 ¥{{ totalPrice }}</div>
<button @click="addItem(product)">加入购物车</button>
</template>

常见坑: 不用 storeToRefs 直接解构会丢失响应式。

从 Vuex 迁移的步骤

  1. 安装 Pinia,和 Vuex 可以共存
  2. 新功能全部用 Pinia store
  3. 逐个模块迁移:先改一个小 store,验证没问题再推
  4. 最后一个模块迁完,卸载 Vuex
1
2
3
4
5
6
7
8
9
// Vuex module → Pinia store 对照
// Vuex:
// state.token → state: () => ({ token: '' })
// getters.isLoggedIn → getters: { isLoggedIn: s => !!s.token }
// mutations.SET_TOKEN → 不需要了
// actions.login → actions: { async login({ commit }) { ... commit('SET_TOKEN') } }

// Pinia:
// 全部进 defineStore 的 setup 函数,一步到位

什么时候不需要 Pinia

  • 组件内部状态(表单输入、展开/折叠)→ ref 就行
  • 父子组件通信 → props + emits
  • 跨层级但范围有限的共享 → provide / inject
  • URL 参数(筛选条件、页码)→ 路由参数

provide/inject 示例:

1
2
3
4
5
6
// 父组件
const theme = ref('light')
provide('theme', theme)

// 深层子组件
const theme = inject('theme')

不需要全局状态就别往 store 放。

总结

Pinia 的核心价值就一句话:用写 composable 的方式写状态管理。没有新概念、没有魔法字符串、没有 commit。Vue 开发者 10 分钟上手,30 分钟就能在项目里用起来。