D08 · Pinia vs Vuex
对应主课: L11 Pinia 状态管理 最后核对: 2026-09-23
1. 核心区别
本篇比较 Vue 3 下的 Vuex 4.1.0 与 Pinia 3.0.4。Vuex 3 对应 Vue 2;Pinia 3 已移除 Vue 2 支持,不能混用旧版兼容结论。Pinia 3 迁移说明
| 维度 | Vuex 4 | Pinia 3 |
|---|---|---|
| API 风格 | mutations + actions + getters | 只有 state + getters + actions |
| Mutations | ✅ 必须通过 mutation 修改 | ❌ 不存在,直接修改 state |
| TypeScript | State/注入键可显式类型化,字符串接口较难推断 | 函数与store属性推断更直接,边界仍需类型声明 |
| 模块化 | modules(嵌套 + 命名空间) | 多个独立 store(扁平) |
| DevTools | ✅ 支持 | ✅ 支持 |
| 产物体积 | 按具体构建测量 | 按具体构建测量,不用固定数字代表所有项目 |
| API 写法 | 可通过 useStore 与 Composition API 配合 | 支持 Setup Store、Option Store,也可用于 Options 组件 |
| Store 热更新 | 提供 hotUpdate 接口 | Vite 中使用 acceptHMRUpdate 接入 |
2. 同一功能对比
以下文件用于独立的 Vue 3.5 / Vite / TypeScript 比较项目,不迁移前面已工作的商城 store。安装 pinia@3.0.4 vuex@4.1.0,两者共用同一个本地异步数据源;这里没有外部请求,重点观察 state/getter/action 的接口:
// src/services/items.ts
export async function loadItems(): Promise<string[]> {
return ['Vue', 'Pinia']
}Vuex 4.1.0 的包 exports 没有声明类型入口。现代 moduleResolution: 'Bundler' 若因此找不到声明,在比较项目的 tsconfig.app.json 保留其他设置、为 vuex 增加明确的类型解析路径;不要把模块整体声明成 any:
{
"compilerOptions": {
"paths": { "vuex": ["./node_modules/vuex/types/index.d.ts"] }
}
}如果原来已有 paths(例如 @/*),将 vuex 项合并进去。这只帮助 TypeScript 解析声明,运行时仍导入 Vuex 包。
Vuex
// src/store/legacy-counter.ts
import { createStore, type Store } from 'vuex'
import type { InjectionKey } from 'vue'
import { loadItems } from '../services/items'
export interface CounterState { count: number; items: string[] }
export const legacyStoreKey: InjectionKey<Store<CounterState>> = Symbol('legacy-counter')
export const legacyStore = createStore<CounterState>({
state: () => ({ count: 0, items: [] }),
getters: {
doubleCount: state => state.count * 2,
itemCount: state => state.items.length,
},
mutations: {
INCREMENT(state) { state.count++ },
SET_ITEMS(state, items: string[]) { state.items = items },
},
actions: {
async fetchItems({ commit }) { commit('SET_ITEMS', await loadItems()) },
increment({ commit }) { commit('INCREMENT') },
},
})Mutation 处理函数要求同步;异步获取放在 action,拿到结果后 commit。Vuex 的 strict 模式可检查 mutation 外的修改,但“只通过 mutation 修改”首先是使用契约,不能理解为 JavaScript 语法禁止给属性赋值。
Pinia
// src/stores/counter.ts
import { ref, computed } from 'vue'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { loadItems } from '../services/items'
export const useCounterStore = defineStore('counter-compare', () => {
const count = ref(0)
const items = ref<string[]>([])
const doubleCount = computed(() => count.value * 2)
const itemCount = computed(() => items.value.length)
function increment() { count.value++ }
async function fetchItems() { items.value = await loadItems() }
return { count, items, doubleCount, itemCount, increment, fetchItems }
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))
}Pinia 允许直接修改 state,也可使用 $patch 或 action;复杂业务仍应集中在清晰的操作接口中。上面的 HMR 是显式接入,新增 store 时也要考虑这段代码。Pinia HMR
注册与使用
// src/main.ts(比较项目入口)
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { legacyStore, legacyStoreKey } from './store/legacy-counter'
import CompareApp from './CompareApp.vue'
createApp(CompareApp).use(legacyStore, legacyStoreKey).use(createPinia()).mount('#app')<!-- src/CompareApp.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useStore } from 'vuex'
import { storeToRefs } from 'pinia'
import { legacyStoreKey } from './store/legacy-counter'
import { useCounterStore } from './stores/counter'
const legacy = useStore(legacyStoreKey)
const counter = useCounterStore()
const { count, doubleCount, itemCount } = storeToRefs(counter)
const error = ref('')
async function loadBoth() {
error.value = ''
try { await Promise.all([legacy.dispatch('fetchItems'), counter.fetchItems()]) }
catch (cause) { error.value = cause instanceof Error ? cause.message : '加载失败' }
}
</script>
<template>
<h1>状态管理接口对比</h1>
<section>
<h2>Vuex</h2>
<p>{{ legacy.state.count }} / 两倍 {{ legacy.getters.doubleCount }} / 条目 {{ legacy.getters.itemCount }}</p>
<button @click="legacy.commit('INCREMENT')">Vuex 增加</button>
</section>
<section>
<h2>Pinia</h2>
<p>{{ count }} / 两倍 {{ doubleCount }} / 条目 {{ itemCount }}</p>
<button @click="counter.increment">Pinia 增加</button>
</section>
<button @click="loadBoth">加载两份列表</button>
<p v-if="error" role="alert">{{ error }}</p>
</template>两个计数器独立变化;加载后两边条目数都为 2。storeToRefs 保持解构后的 state/getter 连接,action 可以直接调用或解构。组件外调用 Pinia 的 useCounterStore 等函数时,必须保证 Pinia 已安装或显式传入该 Pinia 实例;SSR 要为每次请求创建独立实例,不能共享这份客户端示例的模块级状态。
3. 为什么去掉 Mutations
Vuex 用同步 mutation 记录状态变更;Pinia 也能记录 action 与直接状态修改,开发工具并不要求单独一层 mutation。去掉这一层以后:
- 减少了样板代码
- 普通操作通过导入的 store 函数和方法调用,减少字符串路径
- 同步与异步逻辑都可放在 action,但异步状态与错误仍要处理
4. 模块化对比
Vuex 模块可以 namespaced,调用例如 store.commit('user/SET_NAME', 'Vue');action context 提供当前模块 state 与 rootState。Pinia 用不同 id 定义 store,跨 store 操作可直接调用另一 store:
// src/stores/group-example.ts(独立的组织方式示例)
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useUserStore = defineStore('group-user', () => {
const name = ref('')
return { name }
})
export const useCartStore = defineStore('group-cart', () => {
const items = ref<string[]>([])
function describeCart() {
const user = useUserStore()
return { owner: user.name, items: [...items.value] }
}
return { items, describeCart }
})在已安装 Pinia 的上下文调用;函数内读取另一 store 使依赖位置明确。扁平 store 不等于没有依赖管理:两个 setup store 不能在初始化时无条件互相读取状态,形成循环初始化。真实商城结算仍调用 L25 后端,不靠客户端描述函数完成交易。组合 store
5. TypeScript 支持
Vuex 可以通过 createStore<CounterState> 与 InjectionKey<Store<CounterState>> 让 useStore 获得准确的 state 类型,§2 已展示完整接线;不能把一个没有类型参数的 useStore 作为“Vuex 只能 any”的依据。不过字符串 commit/dispatch/getters 的返回与载荷类型仍不如直接函数接口容易表达。Vuex TypeScript
Pinia 可从 ref、computed 和函数参数推断出 store 接口;空数组仍需 ref<string[]>([]),API 数据与联合状态仍要定义类型。Setup Store 应返回用于状态管理的 state,隐藏该状态可能破坏 SSR、devtools 或插件;普通函数内的临时局部变量则不必暴露。
6. 迁移建议
| 建议 | |
|---|---|
| 新项目 | ✅ 直接用 Pinia |
| Vue 3 老项目用 Vuex 4 | 按实际维护收益逐个模块迁移,不必一次重写 |
| Vue 2 项目 | Pinia 3 不支持;先评估 Vue 3 迁移,旧生态版本要单独核对维护范围 |
Pinia 是 Vue 官方推荐的状态管理方案;Vuex 官方说明其处于维护模式。迁移时可以暂时并存,但一个业务状态应有明确的权威来源,避免两份 store 互相同步。Pinia 简介、Vuex 状态说明
7. 总结
- Pinia 去掉了 mutations,简化了开发流程
- Pinia 用独立 store 替代嵌套 modules,更清晰
- Pinia 提供更直接的类型推断,并同时支持 Setup Store 与 Option Store
- Store HMR 需要按工具链接入;产物体积应以相同功能的实际构建为依据
- 新 Vue 3 项目通常选择 Pinia;旧项目迁移时核对 Vue、状态库与业务状态的归属