一个"受够了Vuex繁琐"的前端人,写给另一个前端的心里话
如果你是从 Vue 2 时代一路走来的老兵,你一定对 Vuex 又爱又恨。爱它,是因为当年它几乎是 Vue 生态中唯一成熟的状态管理方案,拯救了无数组件间通信的泥潭。恨它,是因为......算了,不装了,我恨它。
我记得 2021 年维护一个中型后台项目,Vuex 模块拆了十几个,每次加一个新状态,要经历:state → getter → mutation → action,然后去组件里 mapState、mapActions,还要操心命名空间。代码没写几行,模板代码先堆了上百行。更崩溃的是 TypeScript 支持------Vuex 对 TS 的推导能力约等于"薛定谔的类型",你永远不知道 dispatch 回来的到底是什么。
后来 Pinia 出来了。一开始我以为只是又一个"下一代状态管理"的噱头,直到我用一个下午把一个老模块从 Vuex 重写到 Pinia,代码从 200 行缩到 70 行,类型提示丝滑得像 VSCode 开了挂。那一刻我懂了:不是 Pinia 太新,而是 Vuex 太老了。
这篇文章不会跟你念文档,我想用一个真实的对比场景 ------ 一个"用户偏好设置"模块(主题、语言、侧边栏折叠状态)------ 来告诉你,Pinia 到底好在哪,以及为什么官方团队钦定 Pinia 为 Vue 3 的默认状态管理方案。
一、Vuex 的痛,从"仪式感"变成"仪式病"
我们先用 Vuex 4(Vue 3 版本)实现一个典型的 userPrefs 模块。
js
export default {
namespaced: true,
state: () => ({
theme: 'dark',
language: 'zh-CN',
sidebarCollapsed: false,
}),
getters: {
isDarkMode: (state) => state.theme === 'dark',
currentLanguage: (state) => state.language,
},
mutations: {
SET_THEME(state, theme) {
state.theme = theme
},
SET_LANGUAGE(state, lang) {
state.language = lang
},
TOGGLE_SIDEBAR(state) {
state.sidebarCollapsed = !state.sidebarCollapsed
},
},
actions: {
changeTheme({ commit }, theme) {
// 这里可以调用 API 存后端
localStorage.setItem('theme', theme)
commit('SET_THEME', theme)
},
changeLanguage({ commit }, lang) {
localStorage.setItem('language', lang)
commit('SET_LANGUAGE', lang)
},
toggleSidebar({ commit }) {
commit('TOGGLE_SIDEBAR')
},
},
}
然后在组件里使用:
vue
<template>
<div :class="{ dark: isDarkMode }">
<button @click="changeTheme('light')">亮色模式</button>
</div>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex'
export default {
computed: {
...mapState('userPrefs', ['theme', 'sidebarCollapsed']),
...mapGetters('userPrefs', ['isDarkMode']),
},
methods: {
...mapActions('userPrefs', ['changeTheme']),
},
}
</script>
看起来还行?等一等,问题藏在细节里:
1. 四个"仪式"动作,心智负担高
每加一个状态,就得手动维护四个位置。写 mutation 时你问自己:这个真的需要 mutation 吗?------答案是:Vuex 要求你写,哪怕它只是同步赋值。
2. TypeScript 推导 ≈ 不存在
mapState 返回的是 any,你永远不知道 this.theme 到底是不是 string。除非你手动给模块加类型声明,然后写一堆 RootState 接口,再然后......算了,改喝咖啡吧。
3. 命名空间是"手动挡"
你忘了加 namespaced: true?全家桶污染。你写了带命名空间的 dispatch?字符串容易写错,IDE 帮不了你。
4. 代码组织撕裂
同一个"偏好设置"的逻辑,状态写这里,mutations 写那里,actions 又跑到了下面。如果模块再大一点,你得上下滚动三百行才能改完一个功能。 这就是 Vuex 的"仪式病"------它把原本简单的数据更新,强行套上了一个基于 Flux 的严肃架构。放在 2016 年很先进,放在 2026 年,就像还在用 jQuery 写动画一样。
二、Pinia:少即是多,但不少功能
Pinia 的设计哲学一句话就能概括:把 store 写得像你手写的一个普通组合式函数 ,但自动帮你做响应式、DevTools 追踪、插件扩展。 同样一个 userPrefs 模块,用 Pinia 怎么写?
js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUserPrefsStore = defineStore('userPrefs', () => {
// state ------ 就是 ref / reactive
const theme = ref('dark')
const language = ref('zh-CN')
const sidebarCollapsed = ref(false)
// getters ------ 就是 computed
const isDarkMode = computed(() => theme.value === 'dark')
// actions ------ 就是普通 function,可以异步
function changeTheme(newTheme) {
theme.value = newTheme
localStorage.setItem('theme', newTheme)
}
function changeLanguage(lang) {
language.value = lang
localStorage.setItem('language', lang)
}
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value
}
// 把要暴露的东西 return 出去
return {
theme,
language,
sidebarCollapsed,
isDarkMode,
changeTheme,
changeLanguage,
toggleSidebar,
}
})
在组件里使用:
vue
<template>
<div :class="{ dark: userPrefs.isDarkMode }">
<button @click="userPrefs.changeTheme('light')">亮色模式</button>
</div>
</template>
<script setup>
import { useUserPrefsStore } from '@/stores/userPrefs'
const userPrefs = useUserPrefsStore()
// 直接解构也不会丢失响应式(内部用 storeToRefs 处理)
</script>
光是视觉上,Pinia 就赢了------它没有 mutation,没有命名空间字符串,没有 map 辅助函数。更关键的是,你的 store 就是一个普通函数,里面能用 Vue 的所有响应式 API。
对比总结表
| 维度 | Vuex 4 | Pinia |
|---|---|---|
| 代码行数(相同功能) | 约80行左右 | 约30行左右 |
| TypeScript 推导 | 手动写类型,常放弃 | 完美自动推导,像写 TS 一样 |
| 学习曲线 | 需要理解 mutation、action、getter 的区别 | 会 ref/computed 就能写 |
| 模块化 | namespaced: true + 字符串路径 |
每个 store 自动独立,自然模块化 |
| DevTools | 支持,但 mutation 记录冗余 | 支持,且更清晰(直接追踪 state 修改) |
| 服务端渲染 | 需要特殊处理 | 开箱即用,自动支持 SSR |
三、深度对比:三个让 Pinia "真香"的核心特性
3.1 没有 mutations ------ 不是偷懒,是解药
Vuex 里的 mutation 最初是为了时间旅行调试 和严格模式 。但实际开发中,99% 的 mutation 就是简单的 state.xxx = payload。结果我们被迫写:
js
mutations: {
SET_USER(state, user) { state.user = user }
},
actions: {
setUser({ commit }, user) { commit('SET_USER', user) }
}
Pinia 直接允许你修改 state 中的 ref,不仅代码更少,而且普通赋值也能被 DevTools 记录------因为 Pinia 底层依赖 Vue 3 的响应式系统,任何变化都会被自动捕获。
js
// Pinia 里直接改
userPrefs.theme = 'light'
// 或者封装成方法
userPrefs.changeTheme('light')
你不需要纠结"这个操作是同步还是异步",Pinia 都让你写在 action(也就是普通函数)里。异步示例:
js
const useUserStore = defineStore('user', () => {
const user = ref(null)
async function fetchUser(id) {
const res = await api.getUser(id)
user.value = res.data
}
return { user, fetchUser }
})
没有任何多余概念。这就是为什么人们说 Pinia 是"Vuex 5"------它把 Vuex 里最被人诟病的限制去掉了,同时保留了状态管理的核心:单一数据源、响应式、DevTools。
3.2 TypeScript:不再"像在写 AnyScript"
Vuex 对 TS 的支持是"社区缝缝补补",你需要:
ts
// Vuex 的典型 TS 写法(痛苦面具)
interface State { user: User | null }
const store = createStore<State>({ ... })
// 组件里还要额外包装
import { useStore } from 'vuex'
const store = useStore() as TypedStore
Pinia 天生为 TS 设计。上面的 useUserPrefsStore 返回的对象类型会自动推导。如果你想显式声明:
ts
interface User {
id: number
name: string
}
const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
// user.value 自动为 User | null
return { user }
})
在组件里 store.user 会正确提示 User | null,并且 store.user.id 会报错(因为可能是 null)。这种体验和手写一个组合式函数完全一样 ------ 润!
3.3 组合式 store vs Options store
Pinia 提供了两种写法:Options 风格 (类似 Vuex)和组合式风格 (上面用的那种)。我个人强烈推荐组合式风格,因为它和 Vue 3 的 <script setup> 浑然一体。 但如果你从 Vuex 迁移,可以用 Options 风格过渡:
js
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
double: (state) => state.count * 2,
},
actions: {
increment() {
this.count++
},
},
})
重点是:两种风格可以混用,并且共享同一个 DevTools 体验。
四、一个真实的迁移故事:从 Vuex 到 Pinia,我经历了什么
去年我把一个公司内部 CRM 系统的登录状态模块从 Vuex 迁移到 Pinia。原代码(Vuex):
- 3 个文件:
index.js,types.js,actions.js - 共 187 行
- mutation 常量定义占 30 行
- 类型声明文件(
.d.ts)另加 50 行 迁移后: - 1 个文件
stores/auth.js - 68 行
- 不需要额外类型声明 更让我惊讶的是:Pinia 插件系统让我们一行代码实现了登录状态持久化:
js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
// 在 store 里开启持久化
export const useAuthStore = defineStore(
'auth',
() => { /* ... */ },
{ persist: true } // 自动存 localStorage
)
而 Vuex 要实现同样功能,要么自己写 plugin,要么用 vuex-persistedstate 但需要额外配置。这就是生态的一致性好带来的红利。
五、Pinia 没有银弹,但足够好
当然,Pinia 并非完美无缺:
- 小型项目 :如果你只是跨组件共享少数几个状态,也许
provide/inject就够了,没必要引入 Pinia。 - 重度时间旅行调试:Vuex 严格的 mutation 记录在某些复杂调试场景仍有优势(但 99% 的项目用不到)。
- Vue 2 项目 :Pinia 也支持 Vue 2(通过
@vue/composition-api),但 Vue 2 本身的响应式局限会让体验打折扣。
但如果你是 Vue 3 + TypeScript 的新项目,或者正被 Vuex 折磨得想砸键盘,请毫不犹豫切换到 Pinia。它不是"下一代替代品",它就是现在的最佳答案。
尾声
我至今记得第一次看完 Pinia 文档时的心情:释然。终于不用再教新同事"mutation 和 action 的区别是什么",不用再写四个地方才能加一个字段,不用再对着 TS 报错束手无策。
技术是会自我进化的。Vuex 曾是英雄,但它带着 Flux 时代的包袱,就像一辆华丽但沉重的马车。而 Pinia 轻快、直觉、现代化,更像是为 Vue 3 量身定做的跑车。
如果你还没有试过,现在打开你的 Vue 3 项目:
bash
npm install pinia
然后写你的第一个 defineStore。十分钟后,你会回来感谢我的 ------ 或者感谢 Vue 团队做了这么正确的决定。