前端圈最近有个挺有意思的现象:两三年前大家新建 Vue 项目,第一件事就是装 Vuex,不装反而显得不专业;而这两年的新项目,默认方案已经换成了 Pinia,甚至有人直接放话说“Vuex 已死”。我自己的态度没这么极端,但也是从 Vuex 一路用过来的,从 Vue 2 时代的“唯 Vuex 论”,到后来在几个中大型项目里被 Vuex 的样板代码和类型问题反复折磨,再到亲手把一个运营了四年的老项目从 Vuex 迁到 Pinia——这个过程里踩过的坑、得出的结论,我觉得比单纯站队“谁更好”更有参考价值。
这篇文章不打算做那种罗列 API 的官方文档式对比,而是想从实际开发的角度,聊聊 Vuex 为什么曾经是标准,它的痛点到底痛在哪,以及站在 2025 年这个时间点,我们面对“状态管理”这件事应该有什么样的新思路。特别是最近“对话状态管理”这个热度上来了——聊天式产品、AI Agent、多轮交互应用越来越多,你会发现传统 Vuex 的那套设计哲学,在“对话即状态”的场景里几乎彻底失效。这才是 Vuex 之痛背后真正值得思考的问题。
1. 回看 Vuex 的黄金年代:为什么它当年是标准答案
要理解 Vuex 为什么会成为“曾经的标准”,得先回到它诞生的上下文。2015 年前后,前端状态管理领域最响亮的名字是 Redux 和它背后的 Flux 架构理念。Flux 的核心主张是:状态不能随便改,所有变更必须走一个单向数据流——视图触发 action,action 经过 dispatch 进入 store,store 内部的 reducer 或 mutation 生成新状态,然后视图重新渲染。这套模式把 Facebook 大型应用里“状态到处乱改、最后谁也说不清界面为什么变成这样”的混乱问题,用强约束给摁住了。
Vuex 基本就是 Flux 理念在 Vue 生态里的落地,再加上 Vue 自己的响应式系统。它的核心设计在今天看来仍然有很清晰的逻辑:
- 单一状态树:整个应用的所有全局状态放进一个对象里。好处是调试时一眼能看到全局快照,服务端渲染时也方便做状态注入。
- mutation 必须是同步函数:这是 Vuex 最“硬”的一条规则。同步意味着 DevTools 可以精确记录每一次状态变更的前后值,实现时间旅行调试。这是当时它作为“标准”的最大底气。
- action 负责异步逻辑:异步不能直接改状态,必须等结果回来后 commit 一个 mutation。这样网络请求、定时器等副作用被隔离在 action 层,状态变更仍然可控。
- module 模块化:应用变大之后,单一状态树会膨胀到难以阅读,所以 Vuex 允许拆成多个 module,每个 module 有自己的 state、mutations、actions、getters,用 namespace 隔离。
这套设计放在 Vue 2 的时代是完全成立的。Vue 2 的组件通信手段有限,props 逐层传递在组件树深的时候非常痛苦,event bus 又容易让事件关系变成一团乱麻,而 Vuex 提供了一套“任何组件都能拿到全局状态、任何组件都能通过规范流程改状态”的统一方案。加上 Vue DevTools 对 Vuex 的深度集成,团队协作时数据流是可预期的——新成员接手项目,看一眼 store 目录就大概知道业务状态的流转路径。
所以说,Vuex 当年被封为“标准答案”,不是因为大家跟风,而是在 Vue 2 + Options API 的技术土壤里,它确实是解决全局状态管理问题的最优解之一。那个时代的“痛点”不是 Vuex 带来的,而是没有 Vuex 的裸奔式开发带来的。但问题也出在这里——Vuex 的设计深深绑定在那个时代的技术假设上,当 Vue 3 的 Composition API 出现、TypeScript 越来越成为刚需、应用形态逐渐从“界面状态”走向“对话数据流”的时候,它身上那套为了“规范”而设计的条条框框,就开始从优点变成负担了。
2. 痛点解剖:繁琐的样板代码把简单需求变成体力劳动
先说最直观的问题:样板代码。Vuex 为了保证“规范”,强制拆出了 mutation type 常量、mutation 函数、action 函数、组件里的调用、组件里的状态映射,这一条链路有五个环节。一个简单的“保存用户信息”需求,在 Vuex 里要写多少东西?我直接贴一段实际项目里会出现的代码,你们感受一下。
// store/modules/user.js import { defineStore } from 'vuex' // 实际上是普通 module 导出 const state = () => ({ userInfo: null, loginLoading: false }) const mutations = { SET_USER_INFO(state, payload) { state.userInfo = payload }, SET_LOADING(state, payload) { state.loginLoading = payload } } const actions = { async login({ commit }, payload) { commit('SET_LOADING', true) try { const userInfo = await loginApi(payload) commit('SET_USER_INFO', userInfo) } finally { commit('SET_LOADING', false) } } }// store/index.js import user from './modules/user' export default new Vuex.Store({ modules: { user } })<!-- 组件里还要这样用 --> <script> import { mapState, mapActions } from 'vuex' export default { computed: { ...mapState('user', ['userInfo', 'loginLoading']) }, methods: { ...mapActions('user', ['login']) } } </script>这套代码最大的问题不是“长”,而是“绕”。开发者想做的本质操作只有一个——掉接口,把用户信息存起来。但 Vuex 强制把这件事拆成了四步:定义 mutation type、写 mutation、写 action、组件里 dispatch。而且这里每个环节都有重复感:SET_USER_INFO字符串出现了两次(type 常量和 mutation 名字)、映射关系在组件里靠字符串再写一遍。如果某个 mutation 改名了而组件里的 mapState 没同步改,不会报错,只是静默失效。
异步流程这里也有个隐蔽的坑。action 里既可以dispatch其他 action,也可以commitmutation,还可以直接return一个 Promise 给调用方。新人经常搞混 commit 和 dispatch 的使用场景,结果就是同一个业务动作,有人习惯在组件里 dispatch action,有人直接在组件里 commit mutation——如果 mutation 里有异步操作,这就是一个标准的推荐做法与危险用法的碰撞。到了多 action 串行的场景更痛苦:action A 调用 action B,B 的结果又要传给 C,中间任何一环的错误处理漏了,整个流程的状态就悬在半空。
你说 Vuex 官方也提供了mapState、mapMutations、mapActions这些辅助函数来减少样板代码,但辅助函数本质是字符串映射,还是没有逃开“魔法字符串”的问题。而且当项目里同时存在多个 namespace 的 module 时,你会看到一个组件里挤满了mapState('user', ...)和mapState('order', ...),可读性直线下降。
我拿一个“在不同方案下实现同一个功能需要多少行有效代码”的对比来量化一下。假设需求是:登录后把用户信息暴露给所有组件,同时记录登录中状态。
| 维度 | Vuex 传统写法 | Pinia 写法 | 组件内直接用 reactive 全局变量 |
|---|---|---|---|
| 状态定义 | state 函数 + 各种常量 | defineStore + ref/setup store | 一个 reactive 对象即可 |
| 修改状态 | mutation + action 分离 | action 里直接改 state | 直接改 ref.value |
| 组件使用 | mapState/mapActions + 字符串映射 | storeToRefs 后直接用实例属性 | import 全局变量后直接读写 |
| 异步登录逻辑 | login action 手动分派给 mutation | 一个 action 函数内搞定 | 一个函数内搞定 |
| 类型提示 | 几乎无推导,需要手动声明 | 全自动推导 | 需要手动声明类型 |
Pinia 之所以能在写法上砍掉那么多样板,本质原因是它把“同步变更”和“异步逻辑”的强制分离给取消了。你可以直接在 action 里改 state,不需要先 commit 一个 mutation。Vuex 必须分离同步和异步,是为了 DevTools 的时间旅行——这是一个好功能,但为了“所有状态都能被时间旅行”这个目标,开发者要长期支付“写两套函数”的成本。当项目里 80% 的状态变更根本用不着时间旅行调试的时候,这个成本就显得过于昂贵了。
3. TypeScript 场景下的 Vuex:写多少逻辑就要补多少类型补丁
如果说样板代码是 Vuex 的皮外伤,类型支持就是它的内伤。Vuex 4 虽然基于 Vue 3 重写了,但 TypeScript 支持一直是“能用,但很难受”的状态。
最典型的问题在 dispatch。Vuex 的dispatch方法签名是(type: string, payload?: any) => Promise<any>,在 TypeScript 里它的 type 参数就是 string——这意味着你在组件里写store.dispatch('user/login'),TypeScript 完全不知道'user/login'这个字符串对应的 action 到底存不存在、payload 应该是什么类型。你在 Vuex 里可以 dispatch 一个完全不存在的 action 而不报错,直到运行时它静默失败或抛错。这对以类型安全为卖点的大型团队项目来说,几乎等于没有类型。
再看 mutation 里的state上下文。Vuex 模块系统在内部把每个 module 的 state 合并到根 state 上,但你在 mutation 函数里拿到的 state 类型需要手动声明。不声明的话它是any,声明的话你写state: UserState,但如果跨模块访问别的 module 的 state,类型就彻底失真了,得用rootState去拿,而rootState的类型在复杂嵌套里也是混乱的。类似的还有 getters 的state、getters、rootState多参数的类型推导,几乎每个 getter 都要自己把整套上下文类型写一遍。
为了绕过这些坑,社区产生了 vuex-module-decorators、vuex-type-helper 之类的第三方解决方案。但引入这些库本身就增加了学习和维护成本,而且它们各自有各自的限制。我见过不止一个项目,TypeScript 搭得很完整,到了 Vuex store 这一层直接摆烂写any——Store 内部几乎没有任何类型约束,类型安全的防线从 store 开始崩塌,导致全项目的顶层状态类型工程质量被拖垮。
这事的深层原因是历史设计问题。Vuex 的 API 是为 JS 设计的,它用“字符串 action type + 全局 store 单例”的模式,天然不利于类型推导。TypeScript 的类型推导要靠“对象属性”而不是“字符串解析”——除非做非常复杂的模板字面量类型体操,否则不可能让dispatch('user/login')推导出 payload 的类型。而 Pinia 从设计第一天就把 TypeScript 作为一等公民。它不依赖字符串 type,store 就是一个对象实例,state里的属性、getters、actions都直接挂在实例上,TypeScript 可以像推导普通对象属性一样自动推导类型。
这段对比让我印象非常深:同样的“用户信息模块”,Vuex 要写 50 行类型声明和相关注释来维持勉强可用的类型体验,Pinia 零额外声明,所有类型自动推导。如果你所在的团队已经全面 TypeScript 化,单是这一条就足够成为迁移到 Pinia 的理由。
4. 迁移还是留守:存量 Vuex 项目的理性决策与渐进式迁移路径
聊了这么多 Vuex 的痛点,肯定有人会问:那我的老项目怎么办?马上全量重写吗?别冲动,这里我给一个比较理性的决策参考。
先说清楚一个结论:**新项目直接上 Pinia,没有任何悬念。**但是如果你的存量项目已经稳定跑了两三年,状态模型不太复杂,团队也习惯了 Vuex 的写法,那“维持现状”完全是一种合理选择——因为重构这件事最大的成本是风险,而不是代码量。为了“跟上潮流”去重写一个稳定系统,属于没事找事。
那什么情况下值得启动迁移?我列几个信号,命中两条以上再考虑:
- 项目里 Vuex store 的代码量已经占总业务代码的相当比例,且你发现为了绕过 Vuex 的类型问题,写过大量
any和“映射字符串”。 - 新业务需求需要深度使用 Composition API,而现有 Vuex 配合 Composition API 用起来很别扭——每次都要
useStore()然后再computed包装,远不如 Pinia 直接在 setup 里解构引用顺畅。 - 状态模块之间的耦合到了失控边缘,你想趁机做一次状态模型的清理与重新分层。
- 项目准备长期维护三五年以上,团队 TypeScript 比例明显提升,留着一个类型体验垫底的模块会一直拖累整体工程质量。
至于迁移方式,我强烈不建议“大爆炸式重构”。我实际操作过的方案是渐进式的双轨迁移,分三步走。
**第一步:Pinia 和 Vuex 在同一应用内共存。**这个完全没问题,createPinia()和new Vuex.Store()可以在同一个 Vue 实例里分别注册,互不干扰。老页面继续用旧 Vuex store,新页面先按 Pinia 的写法开发。这一步几乎零风险,接入成本也就是在 main.ts 里多挂一个 pinia 实例。
**第二步:按模块粒度逐个替换。**把某个旧 Vuex module 迁移为新的 Pinia store,同时做一个映射层,让老的mapState和mapActions还能正常工作。举个例子,假设旧模块是 user:
// store/pinia/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ userInfo: null, loginLoading: false }), actions: { async login(payload) { this.loginLoading = true try { this.userInfo = await loginApi(payload) } finally { this.loginLoading = false } } } })老组件在迁移期间基本不用改,只需要在 Pinia store 里加几个计算属性或者方法,把数据“映射”回原来的 Vuex 形态。等所有用到 user 模块的组件都逐步改完,再删掉旧 Vuex module。这样每个模块的迁移都是独立的,不会出现“迁到一半项目不能跑”的状态。
**第三步:清理残留。**所有模块迁完后,删掉 Vuex 依赖和new Vuex.Store()的注册代码。这一步注意检查有没有遗漏的plugins、middlewares、subscribe事件——Vuex 的store.subscribe和 Pinia 的$subscribe机制不同,如果有依赖 Vuex 响应式订阅的周边逻辑,需要同步改造。
这套渐进式路径的核心理念是:**不追求一次到位,追求每一步都能独立验证、独立回滚。**我在实际项目里用了大概一个月左右的时间分批完成迁移,中途没有任何一次“上不了线”的情况。这里也分享一个坑:迁移期间最怕的是同一个状态在 Vuex 和 Pinia 里各存一份,两套数据源没有同步,导致部分组件显示的新数据、部分组件显示的旧数据。解决方案是前面说的“映射层”,新 pinia store 作为唯一数据源,Vuex 侧的旧状态只保留一个指向新 store 的 getter,不让老组件直接产生写入路径。
关于“什么时候彻底放弃 Vuex”,我的看法是:如果项目不需要时间旅行调试,不需要严格区分 mutation/action(大多数业务真不需要),并且你对 TypeScript 有要求,那么彻底切换只是时间问题。但如果你在一个状态极简单的项目里,Vuex 那点繁琐其实也不算致命——关键是团队是否觉得痛。
5. 从“界面状态”到“对话状态”:状态管理正在进入下一个形态
说完了 Vuex 的过去与现在,我想把眼光往前放一点。最近“对话状态管理”这个词热度明显上来了,背景不用多说——聊天式产品、AI 对话应用、多轮交互表单、语音助手、Agent 类产品越来越多,这些场景里的“状态”和传统后台管理系统的“界面状态”有本质差异。
我拿一个典型的 AI 对话前端举例。一个聊天窗口要维护什么状态?首先是消息列表messages: Array<{ id, role, content, status, attachments, createdAt }>,每条消息有它自己的状态生命周期:正在流式接收、接收完成、生成失败、用户已重新生成等等。其次是流式输出的缓冲——服务端通过 SSE 或者 WebSocket 逐字推送 token,前端要高频地增量追加文本,可能每 50 毫秒就更新一次。接着是用户交互时序:用户可以在 AI 回复过程中就发新消息(打断)、可以点击“停止生成”、可以重试某条失败消息,这些操作都会改变会话的运行状态。
这类场景如果交给 Vuex 管,你会立刻撞上两个墙。第一个墙是“高频增量 vs mutation 同步规则”。虽然 mutation 同步本身不是问题,但为了流式追加你就得写“每来一批 token 就 commit 一个新 mutation”的循环,然后组件再响应这个 state 的变化。这样高频的 commit 会把 DevTools 刷成流水账,时间旅行调试在这种场景下基本没有意义,反而白白增加每帧的性能开销。第二个墙是“历史序列 vs 单一快照”。Vuex 的 state 本质是“当前状态的快照”,要表示对话历史你得存一个巨大的嵌套数组,然后每一次更新都不可变修改整个数组。流式追加时每次都复制一份完整 messages 数组,很快就内存吃紧。
对话场景真正需要的,是一个事件流式、增量追加、支持异步时序的会话运行时,而不是传统意义上的“全局状态存储”。我现在的做法是这样分层的:
- **对话内容数据:不进全局 store。**消息数组、流式缓冲、会话历史快照放在对话运行时层,用类似
ref的本地响应式对象承载。 - **全局 store 只存 UI 形态状态:**当前会话 id、侧边栏开合、是否显示加载动效这类和业务数据无关的界面状态,才放进全局 store。
- **对话运行时内部用“事件日志”模型:**每条消息都是日志里的一条记录,新 token 是这条记录的增量更新,打断是追加一条中断事件。状态永远是“日志的投影”,而不是手动维护的可变快照。
如果非要用 Pinia 来组织这种场景,其实也能写得比较舒坦。因为 Pinia 的 action 直接操作 state,没有“改状态必须先 commit”的约束,在流式场景里的表达能力改善是跨级别的。下面是一个用 Pinia 管理“一个会话流”的抽象示例:
import { defineStore } from 'pinia' import { ref } from 'vue' export const useChatSessionStore = defineStore('chatSession', () => { const messages = ref<ChatMessage[]>([]) const streamingId = ref<string | null>(null) function ensureMessage(messageId: string) { if (!messages.value.some((m) => m.id === messageId)) { messages.value.push({ id: messageId, role: 'streaming', content: '', status: 'pending' }) } } async function appendStreamChunk(messageId: string, chunk: string) { ensureMessage(messageId) const target = messages.value.find((m) => m.id === messageId) if (target) { target.content += chunk } } async function startStreaming(messageId: string) { streamingId.value = messageId } async function finishStreaming(messageId: string) { const target = messages.value.find((m) => m.id === messageId) if (target) { target.status = 'done' } streamingId.value = null } return { messages, streamingId, appendStreamChunk, startStreaming, finishStreaming } })看着是不是很平铺直叙?没有 mutation 常量、没有 action 嵌套、没有字符串映射。重要的不是代码多简洁,而是这个代码结构和我们脑子里的“对话流程”是对齐的——起流、推流、断流,每一个函数对应一个真实的事件。这就是我在第 2 章说的那个道理走到了今天:当状态管理被用来描述“过程”而不是“结果”的时候,Vuex 那种“静态快照 + 严格变更流程”的设计就彻底不合身了。
我还想强调一点,这不是“换 Pinia 就万事大吉”的故事。对话应用的复杂主体在会话运行时本身——包括服务端连接管理、重连断点续传、消息顺序校对、跨端同步——这些都不是前端状态库能解决的。Pinia 在其中的角色,只是把“需要被 UI 读取的那一小部分对话状态”用最轻的方式暴露出来。状态管理的边界正在往回缩,这是这两年我体会很深的一个趋势:**前端状态管理库越来越不该尝试“管理所有状态”,而是只负责“UI 需要的那部分”。**复杂的业务数据流和时序逻辑,应该下沉到专门的数据层去治理。
回看 Vuex 的这十年,它完成了自己的使命——在 Vue 2 时代树立了“可预测、可调试、可协作”的全局状态管理范式。它的痛点,不是某一个具体 API 写得不好,而是整个“应用全局状态应该被一个中心化 store 严格管理”的模型,在 TypeScript 普及、Composition API 兴起、应用从界面工具变成对话式伴侣的这三个浪潮里逐渐过时了。
如果你还在用 Vuex,不用焦虑,我上面给的渐进式迁移路径完全可以以周为单位推进;如果你已经换了 Pinia,也建议不要停下思考“哪些状态根本不该放 store”。状态管理这个领域没有一个终极答案,只有“在不断变化的应用形态面前,找到和当前问题最匹配的那一层抽象”。这大概就是写代码最有意思的地方——你永远在跟问题的形态赛跑。