Pinia和Vuex核心区别到底在哪?Vue3项目直接用Pinia会不会踩坑?
最近在前端开发社区逛,发现不少刚入手Vue3或者还在犹豫要不要替换Vuex的开发者,对Pinia的态度有点纠结:说它官方推荐吧,但Vuex4也支持Vue3;说它更简洁吧,好像功能又有点不一样?今天就用日常开发的真实场景当引子,把大家最关心的点掰扯清楚,最后再说说直接用Pinia会不会有坑,怎么提前规避。
什么是Pinia?先别扯“下一代Vuex”
很多教程一上来就给Pinia扣“下一代Vuex”的帽子,其实没必要,先说说它的出身:它是Vue核心团队成员Eduardo San Martin Morote做的状态管理库,2021年年底才被正式纳入Vue官方生态——注意,不是“取代Vuex”,而是给Vue3量身定制了一个更契合的选择,Vuex4现在主要是为了让Vue2转Vue3的项目平滑迁移用的。
简单理解的话,Pinia就是一个“去掉冗余的Vuex+TypeScript友好到骨子里的轻量状态管理工具”,轻量到什么程度?压缩后只有1KB左右,比Vuex4小了近一半,而且它的API设计,完全贴合Vue3的Composition API(组合式API)思维,不用再纠结什么“Mutations里只能放同步代码,Actions里异步调用Mutations”这种让人绕晕的老规矩。
从这5个场景,一眼看出Pinia和Vuex的核心差异
光说概念没用,咱们拿几个写代码时天天碰到的场景对比,保证看完就能懂。
场景1:创建一个简单的状态管理模块
先回忆下Vuex4的写法:你得先定义state、mutations、actions、getters,四个模块缺一不可,哪怕只用到state和getters,也要写个空的mutations或者actions占位,不然结构不对;而且要通过createStore创建实例,挂载到Vue实例的$store上,用的时候要么通过this.$store,要么用useStore钩子,调用时还要加.state、.commit、.dispatch这些前缀,代码量有点多。
比如做一个“用户登录状态+获取用户名首字母当头像”的模块,Vuex4的代码大概是这样:
// store/user.js(Vuex4写法)
import { createStore } from 'vuex'
export default createStore({
state() {
return {
username: '',
isLoggedIn: false
}
},
mutations: {
SET_USERNAME(state, name) {
state.username = name
},
SET_LOGIN_STATUS(state, status) {
state.isLoggedIn = status
}
},
actions: {
login({ commit }, { name, password }) {
// 模拟异步登录
setTimeout(() => {
commit('SET_USERNAME', name)
commit('SET_LOGIN_STATUS', true)
}, 1000)
}
},
getters: {
avatarInitial: state => state.username.charAt(0).toUpperCase()
}
})
然后在组件里用useStore:
<script setup>
import { useStore } from 'vuex'
const store = useStore()
</script>
<template>
<div v-if="store.state.isLoggedIn">
欢迎 {{ store.state.username }},您的头像:{{ store.getters.avatarInitial }}
</div>
</template>
看着还行,但如果你用Composition API的写法,这些前缀其实有点啰嗦。
再看Pinia的写法:模块不再叫“模块”,叫“Store”;你可以用Options API或者Composition API两种风格写Store,完全看你的习惯;第三,没有mutations了!同步异步修改状态都可以直接在Store里写,不需要再通过commit和dispatch中转;第四,getters可以直接写成普通的计算属性(如果用Options风格)或者ref/computed(如果用Composition风格)。
同样的登录模块,Pinia用Options风格写的话:
// stores/user.js(Pinia Options风格)
import { defineStore } from 'pinia'
// 第一个参数是Store的唯一ID,必须全局唯一
export const useUserStore = defineStore('user', {
state: () => ({
username: '',
isLoggedIn: false
}),
// getters对应计算属性,自动缓存
getters: {
avatarInitial: (state) => state.username.charAt(0).toUpperCase()
},
// actions可以放同步也可以放异步,修改状态直接用this
actions: {
async login({ name, password }) {
// 模拟异步登录
await new Promise(resolve => setTimeout(resolve, 1000))
this.username = name
this.isLoggedIn = true
}
}
})
在组件里用的话,直接解构就行,不用加任何前缀:
<script setup>
import { useUserStore } from '@/stores/user'
// 解构时如果想保留响应式,要加storeToRefs
const userStore = useUserStore()
const { username, isLoggedIn, avatarInitial } = storeToRefs(userStore)
const { login } = userStore // actions直接解构,不用加storeToRefs
</script>
<template>
<div v-if="isLoggedIn">
欢迎 {{ username }},您的头像:{{ avatarInitial }}
</div>
</template>
是不是明显简洁多了?而且没有了Mutations这个中间层,修改状态的逻辑更清晰,调试起来也方便。
场景2:TypeScript的支持程度
Vuex4虽然也支持TypeScript,但支持得很“被动”——你得自己写很多类型定义,比如state的类型、mutations的载荷类型、actions的载荷和返回值类型,还得给useStore做泛型包装,不然IDE根本不会提示你有哪些state、getters、actions,写代码全靠记忆,很容易出错。
比如Vuex4的TypeScript版本得这么写:
// store/user.ts(Vuex4 TypeScript版本)
import { createStore, Store, useStore as baseUseStore } from 'vuex'
import { InjectionKey } from 'vue'
// 定义state的类型
interface UserState {
username: string
isLoggedIn: boolean
}
// 定义整个Store的类型
interface RootState {
user: UserState
}
// 定义InjectionKey,这一步很麻烦,而且容易忘
export const key: InjectionKey<Store<RootState>> = Symbol()
// 创建Store
export const store = createStore<RootState>({
modules: {
user: {
state: (): UserState => ({
username: '',
isLoggedIn: false
}),
mutations: {
SET_USERNAME(state: UserState, name: string) {
state.username = name
},
SET_LOGIN_STATUS(state: UserState, status: boolean) {
state.isLoggedIn = status
}
},
actions: {
async login({ commit }, { name, password }: { name: string; password: string }) {
await new Promise(resolve => setTimeout(resolve, 1000))
commit('SET_USERNAME', name)
commit('SET_LOGIN_STATUS', true)
}
},
getters: {
avatarInitial: (state: UserState) => state.username.charAt(0).toUpperCase()
}
}
}
})
// 包装useStore,让它支持类型提示
export function useStore() {
return baseUseStore(key)
}
就算你这么写了,如果模块多了,还要给每个模块写类型,还要注意命名空间(Vuex4的命名空间TypeScript支持也不太好),简直是噩梦。
再看Pinia的TypeScript版本,简直是无缝衔接:不管你用Options风格还是Composition风格,IDE都会自动推断所有的类型,根本不需要你手动写InjectionKey、泛型包装这些东西。
同样的登录模块,Pinia的TypeScript Options风格版本:
// stores/user.ts(Pinia TypeScript Options风格版本)
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
username: '',
isLoggedIn: false
}),
getters: {
avatarInitial: (state) => state.username.charAt(0).toUpperCase() // 自动推断state的类型,getter的返回值也自动推断
},
actions: {
async login({ name, password }: { name: string; password: string }) {
await new Promise(resolve => setTimeout(resolve, 1000))
this.username = name // 自动提示this有username和isLoggedIn属性
this.isLoggedIn = true
}
}
})
在组件里用的话,解构出来的变量、调用的actions,IDE都会有完整的提示,写错一个字母立刻报错,开发效率提升不止一点点。
场景3:多个模块之间的调用
Vuex4多个模块之间调用时,需要注意命名空间(如果开启了的话),而且调用方式很绕:比如A模块要调用B模块的actions,要加root: true,还要注意路径;A模块要读取B模块的state或者getters,也要加rootState或者rootGetters,代码看起来很乱,维护起来也麻烦。
比如开启命名空间的情况下,A模块调用B模块的actions:
// store/moduleA.js(Vuex4命名空间版本)
export default {
namespaced: true,
actions: {
doSomething({ dispatch, rootState }) {
// 调用B模块的actions,必须加root: true
dispatch('moduleB/doSomethingElse', null, { root: true })
// 读取B模块的state
console.log(rootState.moduleB.someState)
}
}
}
而Pinia的模块调用就非常简单:不管是在组件里还是在另一个Store里,只要import对应的useXxxStore函数,调用一下拿到实例,就能直接访问它的state、getters、actions,没有任何命名空间的限制,也不用加root: true这种参数。
比如在store/cart.js里调用store/user.js的isLoggedIn属性:
// stores/cart.js(Pinia多模块调用)
import { defineStore } from 'pinia'
import { useUserStore } from './user'
export const useCartStore = defineStore('cart', {
state: () => ({
items: []
}),
getters: {
// 只有登录用户才能看到购物车总价
totalPrice: (state) => {
const userStore = useUserStore()
if (!userStore.isLoggedIn) return 0
return state.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
}
})
是不是清晰多了?每个Store都是独立的实例,想怎么调用就怎么调用。
场景4:状态的调试和时间旅行
很多人担心Pinia没有Vuex那样的时间旅行调试功能,其实完全不用担心——Pinia官方有自己的DevTools插件,而且和Vue3的DevTools无缝集成,功能比Vuex4的还要强大。
Pinia的DevTools里,每个Store都是独立的节点,你可以清晰地看到每个Store的state、getters、actions,不用像Vuex那样在一个大的rootState里找半天;Pinia的DevTools支持修改单个Store的状态,支持撤销和重做,也就是时间旅行功能,和Vuex的一模一样;第三,Pinia的DevTools还支持记录每个action的调用参数、返回值、执行时间,这是Vuex4的DevTools没有的功能,调试异步代码的时候特别有用。
Pinia的调试体验还有一个小细节:当你修改状态的时候,DevTools里会直接显示你修改的是哪个属性,不用像Vuex那样显示commit的mutation的名字和载荷,找问题更直观。
场景5:热更新
Vuex4的热更新功能也有,但配置起来有点麻烦,尤其是在有命名空间的情况下;而且热更新时,有时候会丢失状态,需要手动刷新页面。
Pinia的热更新功能是内置的,而且配置非常简单,只需要在main.js或者stores/index.js里加几行代码就行:
// stores/index.js(Pinia热更新配置)
import { createPinia } from 'pinia'
const pinia = createPinia()
// 热更新配置
if (import.meta.hot) {
import.meta.hot.accept(
['./user', './cart'], // 你要监听热更新的Store文件
() => {
pinia._p.forEach(plugin => {
if (plugin.$id) {
pinia.use(plugin)
}
})
}
)
}
export default pinia
配置好之后,当你修改Store文件时,页面不会刷新,状态也不会丢失,直接就能看到修改后的效果,开发体验非常好。
直接用Pinia会不会踩坑?这3个坑提前注意
虽然Pinia很好用,但刚入手的话还是可能踩一些小坑,下面说说最常见的3个,以及怎么提前规避。
坑1:解构Store时丢失响应式
刚才在对比场景1的时候提到过,解构Store的state和getters时,如果不加storeToRefs,就会丢失响应式,修改state的时候组件不会更新,这个坑很多新手都会踩,一定要记住:解构state和getters用storeToRefs,解构actions直接解构就行,因为actions是普通函数,不需要响应式。
storeToRefs的用法也很简单,从pinia里import出来,然后包裹一下Store实例就行,就像场景1里写的那样。
坑2:修改Store的状态时直接替换整个对象
虽然Pinia允许直接修改状态,但如果你直接替换整个state对象或者某个对象类型的state属性,就会丢失响应式,因为Vue3的响应式是基于Proxy的,替换整个对象会让Proxy失效。
比如不要这么写:
const userStore = useUserStore()
// 错误写法:直接替换整个userInfo对象
userStore.userInfo = { name: '张三', age: 18 }
应该这么写:
const userStore = useUserStore()
// 正确写法1:逐个修改属性
userStore.userInfo.name = '张三'
userStore.userInfo.age = 18
// 正确写法2:使用Object.assign
Object.assign(userStore.userInfo, { name: '张三', age: 18 })
// 正确写法3:使用Pinia提供的$patch方法(批量修改更高效)
userStore.$patch({
userInfo: { name: '张三', age: 18 }
})
这里推荐用$patch方法批量修改状态,因为Pinia会对$patch方法做优化,只会触发一次状态更新,而逐个修改属性会触发多次,性能更好。
坑3:在组件的setup外或者非Vue组件里使用Store
Pinia的useXxxStore函数,只能在组件的setup函数里、或者Vue的生命周期钩子函数里、或者其他Store里调用,不能在组件的setup外或者非Vue组件里直接调用,否则会报错:“getActivePinia was called with no active Pinia. Did you forget to install pinia?”
比如不要这么写:
// utils/api.js(非Vue组件,错误写法)
import { useUserStore } from '@/stores/user'
const userStore = useUserStore() // 这里会报错
export function fetchUserOrders() {
return fetch(`/api/orders?userId=${userStore.userId}`)
}
应该这么写:
// utils/api.js(非Vue组件,正确写法)
import { useUserStore } from '@/stores/user'
export function fetchUserOrders() {
// 在函数内部调用useXxxStore
const userStore = useUserStore()
return fetch(`/api/orders?userId=${userStore.userId}`)
}
如果是在组件的setup外调用,比如在路由守卫里调用,可以通过router.app获取到Vue实例,然后从实例的$pinia属性里获取到Pinia实例,再调用useXxxStore函数并传入Pinia实例:
// router/index.js(路由守卫,正确写法)
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHistory(),
routes: [
// 路由配置
]
})
router.beforeEach((to, from, next) => {
// 从router.app里获取Pinia实例
const pinia = router.app?.config.globalProperties.$pinia
if (pinia) {
const userStore = useUserStore(pinia)
if (to.meta.requireAuth && !userStore.isLoggedIn) {
next('/login')
} else {
next()
}
} else {
next()
}
})
export default router
Vue3项目到底选Pinia还是Vuex?
现在应该很清楚了吧?如果你是:
- 全新的Vue3项目:直接选Pinia,不用犹豫,它更简洁、更轻量、TypeScript支持更好、调试体验更好、热更新更方便。
- 从Vue2转Vue3的老项目:如果项目不大,或者你有时间重构,建议直接换成Pinia;如果项目很大,重构成本太高,可以先用Vuex4过渡,等以后有时间再慢慢替换。
再补充一句:Pinia的学习成本非常低,如果你用过Vuex,半天就能上手;如果你没用过Vuex,直接学Pinia就行,不用再学Vuex那些老规矩。
现在Pinia已经是Vue3的官方推荐状态管理库了,生态也越来越完善,很多第三方库都已经支持Pinia了,比如vue-router、element-plus、ant-design-vue等等,所以完全不用担心兼容性的问题。
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


