1. 从面试场景说起:面试官到底想听到什么
这个问题我碰到过很多次,自己也作为面试官问过不少候选人。先说说面试官问这句“Pinia和Vuex在使用上有什么区别”背后的意图——他不是想听你背文档,不是想听“Pinia是Vuex的替代品”这种一句话结论。他想确认的是:你真正在项目里用过这两个东西,踩过它们的坑,知道它们的脾气,而不是只在简历上写了一行“熟悉Vue状态管理”。
实际上面试官心里有个隐形的打分表:
- 第一层:能不能说出二者最基本的差异,比如 Pinia 去掉了 mutation,只有 state、getters、actions;
- 第二层:能不能说到使用体感上的差异,比如 store 的定义方式、组件里如何取值、如何调用 action,TypeScript 推导是否顺畅;
- 第三层:能不能说出坑和迁移经验,比如 Vuex 的模块嵌套地狱、命名空间问题、mapState 串了 key 怎么办,Pinia 的 store 互相调用、$reset、HMR 热更新体验等。
这篇文章我不打算讲太多源码层面的东西,就聚焦在“使用上有什么区别”。因为面试官问的也是“使用上”,这说明他在乎的是你的实战经验。下面我按实际开发中最常遇到的几个维度拆开讲,每个维度都带上代码对比,尽量还原我在真实项目里的体感。
2. Store 的定义方式完全不同
2.1 Vuex:一个全局 store 加上模块拆分
Vuex 的使用逻辑是“一个 store 管所有”,即便拆了 modules,本质上还是一个大对象,通过modules字段挂到根 store 上。写代码的时候你的思维是被迫收敛到“全局单例”上的。
// store/index.js import { createStore } from 'vuex' const store = createStore({ state: { userInfo: null, token: '' }, mutations: { SET_USER_INFO(state, payload) { state.userInfo = payload } }, actions: { fetchUserInfo({ commit }) { // 异步请求 commit('SET_USER_INFO', res.data) } } }) export default store模块化之后是另一套逻辑,需要开启namespaced,然后通过dispatch('user/fetchUserInfo')这种带斜杠的字符串来调用。一旦模块多了,字符串拼接键名很容易写错,而且编辑器也没有任何提示。我自己在项目里就吃过好几次这种亏,尤其当模块嵌套两层以后,'user/profile/updateProfile'这种路径经常容易漏层级。
2.2 Pinia:独立的 store,不需要挂到一个全局树上
Pinia 的 store 是独立定义的,想建几个就建几个,每个 store 就是一个defineStore的调用结果。没有 modules、没有 namespaced,这个概念直接消失了。
// stores/user.js import { defineStore } from 'pinia' export const useUserStore = defineStore('user', { state: () => ({ userInfo: null, token: '' }), getters: { isLoggedIn: (state) => !!state.token }, actions: { async fetchUserInfo() { // 异步请求 this.userInfo = res.data } } })这是最直观的使用差异:在 Vuex 里写代码必须先理解“根 store + 模块注册”的全局概念;在 Pinia 里,store 更像是“一个独立的、可复用的数据集合”,和组件里的 Composition API 思路完全一致。
2.3 一个容易被忽略的小差异:定义时的 id
注意上面 Pinia 代码里的'user',这个字符串是 store 的唯一标识,跟 Vuex 模块名的作用类似,但它是必填的。很多新手第一次写 Pinia 会漏掉这个参数,直接报错。而 Vuex 的模块名是在modules注册时指定的,顺序完全不同。这点使用上的体感差异,面试时说出来会显得你确实上手过。
3. 组件里取数据的方式差异很大
3.1 Vuex:依赖 useStore 加 computed
Vuex 在组件里最标准的写法是:
import { useStore } from 'vuex' import { computed } from 'vue' export default { setup() { const store = useStore() const userInfo = computed(() => store.state.userInfo) const isLoggedIn = computed(() => store.getters.isLoggedIn) // 触发 action const handleLogin = () => { store.dispatch('fetchUserInfo') } return { userInfo, isLoggedIn, handleLogin } } }麻烦在哪里呢?你必须用 computed 包一层,否则 state 变化不会触发视图更新。而且当页面里用到多个模块的数据时,代码会变得非常啰嗦——每个字段都要写一个 computed,十几个字段就是十几行模板代码。虽然可以用 mapState、mapGetters 这些辅助函数缓解,但在 Composition API 写法的组件里,这些辅助函数用起来手感并不好,尤其配合setup()时还需要额外用createNamespacedHelpers处理命名空间。
3.2 Pinia:直接解构,响应式问题要注意
Pinia 的初体验是——store 直接拿来即用:
import { useUserStore } from '@/stores/user' export default { setup() { const userStore = useUserStore() // 注意:这样直接解构会丢失响应性 // const { userInfo, isLoggedIn } = userStore // 解构 + 保持响应性的方式是 storeToRefs const { userInfo, isLoggedIn } = storeToRefs(userStore) const handleLogin = () => { userStore.fetchUserInfo() } return { userInfo, isLoggedIn, handleLogin } } }这个差异非常关键。Vuex 里你必须写 computed 才能保持响应式;Pinia 里你可以通过 setup store 的方式直接写在 composition 函数里,但解构的时候必须storeToRefs包一层。如果不包,解构出来的字段就是普通值,视图不会更新。这是一个高频坑,也是面试时很好的谈资——说明你不只是看文档,真的写坏了几个组件才学会的。
如果用的是 setup 语法的 Pinia store(后面会讲),组件里还可以直接在 store 的函数里用到 Composition API 的各种工具,比如ref、computed、watch,这是 Vuex 完全没法比的。
4. 修改状态的姿势:mutation 消失之后,一切变简单了
4.1 Vuex 的严格单向数据流
Vuex 在理念上非常强调单向数据流,组件不能直接改 store 的数据,必须显式提交 mutation:
// 组件里 this.$store.commit('SET_USER_INFO', data) // 异步逻辑必须走 action this.$store.dispatch('fetchUserInfo')这套设计在早期是非常好的约束,尤其在大型团队里,能保证数据变更可追踪。但实际用下来,最大的痛点是啰嗦——明明一个简单的赋值操作,要先定 mutation 类型,再写 mutation 函数,如果涉及异步还要再包一层 action,三个文件跑来跑去。尤其是小项目里,这种仪式感会让人烦到想用别的方案硬编码。而且 mutation 里只能同步执行,异步代码必须放在 action 里,这个约束也很容易在开发时忘记。
4.2 Pinia 直接赋值,想改就改
Pinia 删掉了 mutation,state 是可以直接写的:
userStore.userInfo = res.data // 或者一次改多个 userStore.$patch({ userInfo: res.data, token: res.data.token })如果这个修改变量较多,也可以用$patch的函数式写法:
userStore.$patch((state) => { state.userInfo = res.data state.token = res.data.token })这个变化不仅仅是 API 少了几个,更本质的是它把状态管理从“事件驱动(commit/dispatch)”变成了“直接操作数据”的体验。我用下来最大的感受:写 Pinia 的 action 时,脑子里不需要再切换两种模式——同步逻辑直接赋值,异步逻辑 await 完也直接赋值,全程一套思路。
4.3 面试怎么答这一点
如果面试官问“Pinia 为什么要删掉 mutation”,不能只说“简化了代码”。可以补充:mutation 的设计初衷是配合 DevTools 做时间旅行调试,但实际开发中同步更新和异步更新分得并不那么清楚,且 TypeScript 支持较差——因为 mutation 里的 state 类型推断经常不顺畅。Pinia 直接用普通函数和属性赋值,天然更贴合现代前端开发的 TypeScript 和组合式编程习惯。这段回答能把问题从“用起来有什么区别”拔高到“为什么这样设计”,明显加印象分。
5. 异步处理的体验差异
5.1 Vuex:action 回传逻辑相对绕
Vuex 的 action 里,如果你想要一个返回值,需要让 action 返回一个 Promise,然后调用方.then或者await:
actions: { async fetchUserInfo({ commit }) { const res = await api.getUserInfo() commit('SET_USER_INFO', res.data) return res.data } } // 组件里 const data = await store.dispatch('fetchUserInfo')这本身没问题,但一旦涉及多个 action 之间的组合调用,比如“先获取用户信息,然后根据用户角色再拉取菜单”,在 Vuex 里就会变成在一个 action 里 dispatch 另一个 action。跨模块时还得小心命名空间,否则要么忘记加前缀,要么加了前缀但拼错路径。这种字符串路径的 dispatch 设计,在大型项目里配合重构需求时非常痛苦——全局搜dispatch后根本不确定改动会影响哪个模块。
5.2 Pinia:action 就是普通函数,直接调用直接 await
Pinia 的 action 就是一个普通的 async 函数,返回值同样可以拿到:
// stores/user.js actions: { async fetchUserInfo() { const res = await api.getUserInfo() this.userInfo = res.data return res.data } } // 组件里 const userInfo = await userStore.fetchUserInfo()而且 action 内部调用同一个 store 的其他 action,直接用this就能调到,跨 store 调用就引入另一个 store 实例:
// stores/menu.js import { useUserStore } from './user' export const useMenuStore = defineStore('menu', { actions: { async fetchMenuByRole() { const userStore = useUserStore() const userInfo = await userStore.fetchUserInfo() // 按 userInfo.role 拉菜单 } } })这种跨 store 的组合逻辑,写起来和写普通的 compose 函数没有任何区别,没有字符串键名,没有命名空间顾虑,类型推断也是全自动的。这个差异在面试时特别值得展开,因为 Vuex 的动作系统是基于全局事件分发的(dispatch),而 Pinia 的动作系统就是函数调用。
5.3 实际项目里的表象
我在一个后台管理系统里同时维护过 Vuex 和 Pinia 两个代码分支(因为历史原因),同一个“登录”功能:
- Vuex 版本:涉及 store/index.js 注册、user.js 的 state、mutations、actions、getters 四个区块,组件里用 dispatch + commit 两段逻辑,还要在组件里处理 loading、错误信息的状态管量;
- Pinia 版本:store 里一个
loginaction 内搞定 loading、请求、error 状态,组件里await userStore.login(form)一行调用,所有状态都在 store 内部自动管理。
这个对比不是我夸张,真实开发里 Pinia 的代码量大概能少 40% 左右。对面试官来说,“代码量少多少”不是重点,“思维负担减少多少”才是它设计上的价值。
6. TypeScript 支持:一个天上一个地下
6.1 Vuex 的 TS 体验:手动声明是常态
Vuex 在使用上最弱的一环是 TypeScript 支持。虽然可以给 state 和 getters 写类型,但大项目中模块与根 store 的类型联动非常复杂,我见过好几个项目最后是写了额外的d.ts文件手动把 store 的 state 和 dispatch 的类型“map”出来,才能勉强有提示。更麻烦的是commit和dispatch这种字符串方法,编辑器根本不知道某个字符串是不是合法的 mutation 或 action 名称,只能靠运行时跑错才知道。
// Vuex + TS 常见的最优解写法(手动告诉 TS 类型) type State = { userInfo: UserInfo | null; token: string } const store = createStore<State>({ state: { userInfo: null, token: '' }, mutations: { SET_USER_INFO(state, payload: UserInfo) { state.userInfo = payload } } })即使这样,store.commit('SET_USER_INFO', data)这里的字符串 'SET_USER_INFO' 依然没有类型校验,写错不会在编译期报错。在大型项目里,这个体验基本属于“TS 只能防一半”。
6.2 Pinia 的 TS 体验:推断来自定义,零额外声明
Pinia 因为 store 本身是一个对象,类型直接从defineStore推导,所以 state、getters、actions 的类型全部是自动的:
export const useUserStore = defineStore('user', { state: () => ({ userInfo: null as UserInfo | null, token: '' }), actions: { async fetchUserInfo() { const res = await api.getUserInfo() this.userInfo = res.data } } })组件里:
const userStore = useUserStore() // userInfo 的类型自动推导为 UserInfo | null // fetchUserInfo 的类型自动推导为 () => Promise<void>在编辑器里输入userStore.的时候,所有 state、getters、actions 自动补全,函数参数和返回值全部有类型提示。这个差异在“使用体验”层面是碾压级的,面试时可以主动提一嘴:“Vuex 想用 TS 得手动声明很多东西,且 dispatch 的字符串键没有类型保护,而 Pinia 开箱即用,类型全自动推导。”这句话就够说明你实际对比过两个生态。
7. 模块化与嵌套结构的使用差异
7.1 Vuex 的模块化:命名空间是双刃剑
Vuex 的模块化设计得比较早,模块嵌套时通过namespaced: true隔离作用域。看起来合理,真正用的时候会遇到几个麻烦:
- 模块多了以后,
dispatch('order/apply/updateStatus')这种跨层路径写起来容易出错; - 命名空间的辅助函数
mapGetters('order/detail', ...)也容易发生字符串键错位; - 模块之间要互相访问数据时很别扭,虽然可以在 action 里用
rootState和rootGetters,但类型提示基本就断了,传参靠记。
// Vuex 模块间访问 actions: { updateProfile({ commit, rootState }) { const token = rootState.user.token // 手动指定根状态路径 commit('SET_PROFILE', data, { root: true }) // 提交根 mutation } }这段代码我能写出来,是因为真的踩过太多次坑。{ root: true }这个选项的存在本身就说明了模块化带来的连通成本。
7.2 Pinia 的模块化:store 天然隔离,无需命名空间
Pinia 没有“模块”概念,每一个defineStore的返回就是一个独立 store。跨 store 使用就直接 import,然后在 action 里调用,不需要任何命名空间字符串:
export const useProfileStore = defineStore('profile', { actions: { async updateProfile(data) { const userStore = useUserStore() const token = userStore.token // 类型自动推导 // ... } } })这种使用方式让人很舒服的一点在于:一旦你 import 了useUserStore,它的所有字段和类型都摆在明面上,编辑器自动补全;不会出现 Vuex 里那种“我知道这里应该有个 rootState,但不知道准确拼写”的情况。对项目架构来说,Pinia 也更符合现在的工程化思路——按业务模块独立目录,谁要用谁 import,依赖关系显式可见。
7.3 嵌套模块:Vuex 的硬伤
如果 Vuex 的模块存在嵌套,比如某个模块下还有子模块,那么命名空间路径可能变成'user/profile/avatar'。这类字符串路径在多人协作时几乎无法避免手滑。我在一个中后台项目里就出现过因为删除了一个中间层模块,结果所有 dispatch 该路径的代码全部静默失败——Vuex 在 dispatch 不存在路径时不会直接报错(只是在控制台警告),这个定位成本非常高。Pinia 完全不支持模块嵌套,反而是一种更睿智的约定:只要 store 足够小、足够内聚,根本不需要嵌套。
8. Getter 的使用差异
8.1 Vuex 的 getters:依赖state的派生值
Vuex 的 getters 和 computed 的核心逻辑一样,都是根据 state 派生数据。定义方式:
getters: { fullName(state) { return `${state.firstName} ${state.lastName}` }, userMenu(state, getters) { // 可以访问同一个模块里的其他 getters return getters.fullName ? getMenu() : [] } }问题依然是类型:Vuex 的 getters 返回值类型在 store 里拿到的推算比较正常,但在组件里拿到 store.getters 上时,自动提示有时会失效。尤其当你用了模块和命名空间之后,STORE.getters 上的类型几乎约等于 any,需要自己手动写 interface 去描述。这种额外的心智负担在大型项目中非常难受。
8.2 Pinia 的 getters:就是 script 里的计算属性
Pinia 的 getter 在写法上可以做减法——如果 getter 不需要参数,直接写成箭头函数返回一个值即可;如果需要访问其他 store,则引入对应 store。
export const useUserStore = defineStore('user', { state: () => ({ firstName: '', lastName: '' }), getters: { fullName: (state) => `${state.firstName} ${state.lastName}`, // 如果需要用到其他 store menuWithPermission() { const authStore = useAuthStore() return this.menu.filter(item => authStore.hasPermission(item)) } } })在组件里使用时,getter 就跟 state 一样被解构出来,响应式同样需要storeToRefs包住。使用体验上的差异不大,但少了一堆类型声明和命名空间问题,确实爽。
9. 辅助函数和组合式 API 的写法差异
9.1 Vuex 的 Option API 时代很爽,组合式时代很别扭
Vuex 最舒服的使用场景其实是 Vue 2 / Option API 时代,配合mapState、mapGetters、mapMutations、mapActions四个辅助函数,写起来又快又简洁:
computed: { ...mapState(['userInfo', 'token']), ...mapGetters('user', ['isLoggedIn']) }, methods: { ...mapActions('user', ['fetchUserInfo']) }但到了 Vue 3 + Composition API 时代,辅助函数的组合式 API 版本支持就有点支离破碎。你需要createNamespacedHelpers手动生成指定命名空间的辅助函数集,看起来像是在绕路。我在项目里看到过不少团队直接用useStore+ 手动 computed 的方式,完全抛弃 map 系列辅助函数,代码虽然也能跑,但写起来明显没有 Vue 2 时代行云流水。
9.2 Pinia:没有辅助函数,也不需要
Pinia 没有实现 mapState、mapActions 这类辅助函数,因为它觉得组合式 API 下直接解构比字符串 map 更自然。用 Option API 写组件时,Pinia 也提供了一套 mapHelpers(如mapState、mapActions),但我个人建议还是尽量用 Composition API。
如果项目中还有 Vue 2 时代的老代码用 Option API,Pinia 提供的mapState需要额外传 store 实例:
import { mapState } from 'pinia' import { useUserStore } from '@/stores/user' export default { computed: { ...mapState(useUserStore, ['userInfo', 'token']) } }算是兼顾了老项目迁移的体验,但从新项目出发,我会强烈建议直接上 Composition API + 完整 store 解构,不需要 map 辅助函数。
9.3 组合式风格的 Pinia setup store
Pinia 还有另一种定义 store 的方式——setup store,写法跟组件里的 setup 函数一样,可以使用 Composition API 的函数:
export const useCounterStore = defineStore('counter', () => { const count = ref(0) const doubleCount = computed(() => count.value * 2) function increment() { count.value++ } return { count, doubleCount, increment } })这个写法的热更新和自动化测试支持更好,内部逻辑也更自由。如果面试官问到“Pinia 和 Vuex 在使用上的最大区别”,我会说:Vuex 是固定的、结构化的模板式开发,Pinia 是自由的函数式开发。前者统一、但僵硬;后者灵活、但需要开发者自己更有规范性意识。这个说法面试官普遍比较认可,因为它不是背 API,而是对编程模型的理解。
10. 开发调试体验的差异
10.1 Vuex 的 DevTools 历史悠久但链路长
Vuex 的 DevTools 配合 mutation 做时间旅行调试,确实是很经典的特性。但正因为 mutation 和 action 是两套机制,调试时追踪数据变更要同时看 commit 记录和 dispatch 记录,链路稍长。并且,因为 mutation 必须同步执行,异步值更新前后有时候看 DevTools 里的状态会有一点时间差,新手经常搞不清楚当前台展示的是 action 前还是 action 后的状态。
10.2 Pinia 的 DevTools 更直观
Pinia 同样有 DevTools,但它不需要追踪 commit,而是追踪 store 上的直接变更,所以“时间线”更加直观简洁。我日常调试的感受是:状态变化一目了然,当前值、上一次改动的 diff 都清清楚楚,不会有 action/mutation 两条线对不上号的困惑。而且 Pinia 的 DevTools 可以直接看到每个 store 的独立面板,不像 Vuex 永远是一个大 store 树,找数据时还得一层层展开。
10.3 热更新体验
这一点虽然不常有人提,但实际开发中特别影响心情。Vuex 的模块热替换需要手写store.hotUpdate逻辑,实现起来比较麻烦。而 Pinia 默认支持 HMR,编辑 store 时页面状态不用刷新保留原地热更新,我用 Vite + Pinia 做开发时,改 store 的代码几乎不会丢状态。Vuex + webpack 环境下要实现同等体验,插件和配置成本要高很多。面试时如果聊到开发效率,这个点可以让面试官眼前一亮。
11. 从 Vuex 迁移到 Pinia 的常见坑
如果你手里有 Vuex 老项目想迁移,下面的坑是我实际踩过的,列出来给大家参考。
11.1 模块合并的坑
Vuex 的多个模块迁移到 Pinia 时,直观的做法是每个模块拆成一个 store。但要注意模块之间的互相引用顺序——Pinia 中两个 store 可以互相 import,但初始化时不慎形成了循环依赖会导致 undefined。我在拆一个订单系统时遇到过这个问题:orderStore 引用了 userStore,userStore 又引用了 orderStore 里的一个 getter,结果运行时报setup未执行完成。解决方式是只在 action 里面使用useXxxStore(),不要在 setup store 的顶层就把别的 store 实例化。
11.2 命名空间调用全部要改写
Vuex 里所有dispatch('user/fetchUserInfo')、commit('user/SET_TOKEN', xx)都要改写成userStore.fetchUserInfo()和userStore.token = xx的直接调用。简单粗暴的方式是逐个替换,但要注意:原来在 action 里通过rootState读其他模块的状态,现在通通要在对应 action 的开头写const xxxStore = useXxxStore()然后先从它身上拿数据。这个工作量不小,建议用脚本先做一版全局替换,再逐个手改业务逻辑。
11.3 响应式解构的坑
迁移后最容易犯的错就是把 store 解构成普通变量。在 Vuex 时代store.state.userInfo是响应式的,因为 store 本身是 reactive;但 Pinia 里如果直接const { userInfo } = userStore,你得到的是一个当前快照,不会更新。必须用storeToRefs包一层。这个坑我在团队Code Review里看到过很多次,算是 Pinia 新手必踩的一关。
11.4 插件生态差异
Vuex 有大量老牌插件,比如 vuex-persistedstate 做持久化、vuex-router-sync 同步路由等。Pinia 对应的方案是 pinia-plugin-persistedstate,路由同步则更多转向以路由本身为准的心智模型。如果项目中重度依赖 Vuex 插件,迁移前要先盘一下插件清单,逐个找替代方案。这个点不算“使用上的区别”本身,但实际迁移过程中是最耗时间的部分。
12. 用起来记不住的 Key Takeaways
最后分享一下我个人在实际操作中的体会。
迁移项目的过程中,最明显的感受是——Vuex 培养的是“流程化”思维:先 dispatch,再 commit,再派发更新;而 Pinia 培养的是“内容化”思维:数据在哪,直接改它。这两种思维的切换,对新入行的同学尤其重要。如果直接学 Pinia 而没有经历过 Vuex 的约束期,你很可能对响应式解构、action 与 state 的关系理解不深;反过来,如果你一直用 Vuex,也容易习惯各种模板代码,而失去对状态管理本质“就是一套响应式数据 + 一组操作函数”的感知。
所以如果你正在准备面试,我不建议只背对比结论,最好能自己起两个 demo 项目,一个用 Vuex,一个用 Pinia,分别实现登录、权限、菜单三个常见场景,亲手做一遍记忆会非常牢固。至于选型建议,新项目一律优先 Pinia,尤其是 Vue 3 + TypeScript 项目,几乎不会有第二种更优选择;老项目如果维护成本可控,也建议逐步迁移到 Pinia,长期来看收益更大。
再补充一个小技巧:写 Pinia 的 store 时,尽量让每个 store 只关心一个业务领域,不要设计成万能 store。一开始把 user、permission、menu 混在一个 store 里,图一时省事,后面组件解构时就发现一个大对象各种互相依赖,调试起来反而比 Vuex 更痛苦。好的 Pinia 组织方式是“小而专”,像一个个轻量服务,需要谁就注入谁,这种组织思维的提升,才是它真正在“使用”层面拉开差距的地方。