菠萝是什么?
从 Vuex 到 Pinia,理解现代状态管理的进化之路
为什么需要状态管理?
const count = ref(0)
const double = computed(() => count.value * 2)
function increment() { count.value++ }
// 其他组件无法访问这个 count!
创建一个计数值并初始化为 0
计算属性:每当 count 变化时,double 自动更新
定义一个方法来增加计数值
但这些数据只在这个组件内可见,其他组件无法共享
在 Vue 组件中,我们经常需要管理状态。但当组件变多时,状态管理就会变得混乱——组件 A 的数据组件 B 无法直接访问,必须通过 props 和 emit 层层传递。这就是 Pinia 的用武之地。
Pinia 的诞生
为什么叫 Pinia?
Pinia 是西班牙语 "piña"(菠萝)的英文发音。菠萝是由许多小花组成的复合果实,就像 Pinia 是由多个独立但又相互关联的 store 组成。
Pinia 是 Vuex 的继任者,专为 Vue 3 设计,提供了更简洁、更类型安全的 API。
Vuex vs Pinia:核心差异
如果你用过 Vuex,下面的对比能帮你快速理解 Pinia 带来了哪些改进:
不再有 Mutations
Vuex 要求通过 mutation 修改 state,actions 再调 mutation。Pinia 直接在 action 中修改 state,代码量减少 30%。
模块化天生支持
Vuex 需要 modules + namespaced 来拆分 store。Pinia 每个 defineStore 就是一个独立模块,无需配置。
TypeScript 原生支持
Vuex 的类型推断一直是痛点。Pinia 从设计之初就考虑了类型安全,自动推断无需手写类型。
const store = {
state: { count: 0 },
mutations: {
SET_COUNT(state, val) { state.count = val }
},
actions: {
increment({ commit, state }) {
commit('SET_COUNT', state.count + 1)
}
}
}
Vuex 要求定义 state、mutations、actions 三层结构
mutation 是唯一能修改 state 的地方
action 必须通过 commit 调用 mutation,多一层间接性
这种模式虽然严格,但代码冗余,学习曲线陡峭
如果你有现有的 Vuex 项目,不需要一夜之间全部迁移到 Pinia。两者可以共存——在 Vue 应用中同时注册 Vuex 和 Pinia,逐步替换即可。新功能用 Pinia,老代码保持不变。
Pinia 的生态系统
Pinia 不仅是一个状态管理库,它还拥有完整的开发者生态。了解这些工具能让你在实际项目中如虎添翼。
Vue DevTools 集成
Pinia 与 Vue DevTools 深度集成,你可以在 DevTools 中查看所有 store 的状态树、追踪 action 调用历史、甚至进行时间旅行调试——回到任何一个历史状态查看当时的数据快照。这是排查状态相关 bug 的利器。
Nuxt 模块 @pinia/nuxt
如果你使用 Nuxt 3,Pinia 提供了官方模块 @pinia/nuxt,自动完成 Pinia 的注册和导入,支持 SSR 场景下的状态水合(hydration),让你在服务端渲染项目中无缝使用 Pinia。
持久化插件
社区提供的 pinia-plugin-persistedstate 插件可以自动将 store 状态保存到 localStorage 或 sessionStorage,页面刷新后状态不会丢失。支持选择性地持久化特定 store 或特定字段。
状态管理的历史演进
前端状态管理经历了漫长的演进。最早的 jQuery 时代,状态直接散落在 DOM 中,用全局变量维护。2015 年 Redux 随 React 诞生,引入了单向数据流和纯函数 Reducer 的理念,虽然严谨但样板代码过多。Vuex 沿袭了 Redux 的思路,增加了 mutation 层来保证可追踪性,但也带来了更多的模板代码和更高的学习成本。Pinia 的出现标志着状态管理进入了"轻量化加类型安全"的新时代——它保留了单向数据流的核心优势,同时去除了不必要的仪式感。Pinia 证明了一件事:好的架构不一定意味着复杂的代码。当你理解了这段历史,就能更深刻地理解 Pinia 每一个设计决策背后的思考——为什么去掉 mutation,为什么默认支持 TypeScript,为什么用 Setup Store 作为底层统一形式。
并非所有场景都需要 Pinia。如果你的应用只有几个组件,状态传递不超过两层,直接用 Vue 的 props 和 emit 就够了。引入 Pinia 的时机是:当多个不相关的组件需要访问同一份数据、当组件层级太深导致 props 逐层传递变得难以维护、当你的应用需要在页面切换时保持某些状态不被销毁。常见的使用场景包括:全局用户信息(登录状态、权限)、购物车数据、主题配置、通知消息队列等。记住一个原则:Pinia 是解决跨组件状态共享问题的工具,不要为了用而用。
互动小测验
Pinia 和 Vuex 的关系是什么?
以下哪项是 Pinia 相比 Vuex 的核心优势?
Pinia 的核心角色
认识 Store、State、Getters 和 Actions 之间的关系
Store:Pinia 的核心
export const useUserStore = defineStore('user', {
state: () => ({ name: 'Eduardo' }),
getters: { uppercaseName: (state) => state.name.toUpperCase() },
actions: { updateName(newName) { this.name = newName } }
})
定义一个名为 'user' 的 store
状态:存储一个名为 name 的属性
getter:将 name 转换为大写
action:提供更新 name 的方法
Store 是 Pinia 的核心概念,它是一个包含状态、getter 和 action 的容器。
State、Getters 和 Actions
State 是什么?
State 是 store 中存储的数据,类似于组件中的 data。不同之处在于,state 是全局共享的。
Getters 是什么?
Getters 是基于 state 的计算属性,类似于组件中的 computed。
Actions 是什么?
Actions 是 store 中的方法,用于修改 state 或执行异步操作,类似于组件中的 methods。
在组件中使用 Store
定义好 store 之后,最关键的是在组件中正确使用它。Pinia 提供了多种方式来访问和修改状态。
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const userStore = useUserStore()
// 响应式解构——保持响应性
const { name, uppercaseName } = storeToRefs(userStore)
// 方法可以直接解构
const { updateName } = userStore
// 也可以直接修改 state
userStore.$patch({ name: 'Ana' })
从 store 文件导入 useUserStore
导入 storeToRefs 工具函数
调用 useUserStore() 获取 store 实例
用 storeToRefs 解构——这样解构出来的值仍然保持响应式
如果直接解构(const { name } = store),响应式会丢失
方法不需要 storeToRefs,直接解构即可
用 $patch 批量修改多个 state 字段
很多初学者直接用 const { name } = store 解构,发现数据不会更新。这是因为解构操作会把响应式引用"展开"成普通值。必须使用 storeToRefs 才能保持响应性——这就像你不能把一个活的水母从水里拿出来还能指望它活着一样。
Getters 的高级用法
Getters 不只能做简单的数据转换,它还能接受参数、组合其他 getter、甚至缓存计算结果。
export const useProductStore = defineStore('products', {
state: () => ({
products: [],
filter: 'all'
}),
getters: {
// 基础 getter:计算总数
totalCount: (state) => state.products.length,
// 带参数的 getter:通过返回函数实现
getById: (state) => {
return (id) => state.products.find(p => p.id === id)
},
// 组合其他 getter
filteredProducts(state) {
if (state.filter === 'active') {
return state.products.filter(p => p.active)
}
return state.products
}
}
})
定义一个产品 store,包含产品列表和过滤器状态
基础 getter:直接返回产品总数
带参数的 getter:返回一个函数,调用时传入 id 来查找特定产品
注意:带参数的 getter 不会被缓存,每次调用都会重新计算
组合 getter:根据 filter 的值返回不同的过滤结果
Pinia 的 getter 默认带有缓存——只有当它依赖的 state 发生变化时才会重新计算。这意味着如果一个 getter 被多个组件引用,它只会计算一次,所有组件共享同一个计算结果。但如果你用返回函数的方式(getById 那种写法),缓存就会失效,因为每次调用都会创建新的函数执行环境。在性能敏感的场景中,你应该尽量使用不带参数的 getter,通过组合多个 getter 来实现复杂的查询逻辑,而不是用一个巨型函数接受各种参数。理解缓存机制是写出高性能 Pinia 代码的关键。
Action 的异步模式
Action 天然支持 async 和 await,这让异步操作变得非常直观。但在实际项目中,你需要注意几个关键点:第一,action 中的错误应该被 try-catch 包裹,而不是让错误默默消失。第二,当多个组件可能同时触发同一个异步 action 时(比如页面加载时多个组件都去获取用户信息),应该使用去重机制避免重复请求。第三,长时间运行的异步操作应该提供 loading 状态,让用户知道系统正在工作。一个常见的模式是在 state 中维护 loading 和 error 字段,在 action 中正确地设置它们。
Pinia 为每个 store 自动提供 $reset 方法,它能把 state 恢复到初始值。这在用户退出登录、切换账号等场景中特别有用。但要注意一个细节:如果你使用 Setup Store(函数式写法),Pinia 默认不提供 $reset 方法,因为函数式写法中 state 的初始值不像 Option Store 那样有明确的初始函数。解决方案是在 Setup Store 中手动定义一个 reset action,或者使用 Pinia 插件来统一添加 reset 功能。理解这个差异能帮你避免迁移到 Setup Store 时遇到的坑。
异步 Action 模式
使用 async/await 处理 API 调用,配合 loading 和 error 状态管理,确保用户获得良好的加载体验和错误提示。
错误处理模式
在 action 中用 try-catch 包裹异步操作,将错误信息写入 error 状态字段,组件根据 error 状态显示错误提示。
请求去重模式
当多个组件可能同时触发同一请求时,通过 Promise 缓存实现去重——第一个请求完成前,后续调用复用同一个 Promise。
互动小测验
Actions 的主要作用是什么?
为什么从 store 解构数据时需要使用 storeToRefs?
状态怎么流动
数据在 createPinia → defineStore → 组件之间如何流转
三个关键角色
想象一个图书馆系统——管理员(createPinia)负责总控,书架(Store)存放数据,读者(Component)来查阅和修改。
createPinia 在做什么?
这段代码来自 packages/pinia/src/createPinia.ts——它是整个 Pinia 的起点。
export function createPinia(): Pinia {
const scope = effectScope(true)
const state = scope.run(() =>
ref<Record<string, StateTree>>({})
)!
const pinia: Pinia = markRaw({
install(app: App) {
app.provide(piniaSymbol, pinia)
},
_s: new Map<string, StoreGeneric>(),
state,
})
return pinia
}
创建一个作用域,用来统一管理所有 store 的响应式副作用
在这个作用域里创建一个全局 state 对象——所有 store 的数据都存在这里
用 markRaw 标记 pinia 对象本身不需要被 Vue 追踪变化
install 方法:Vue 调用 app.use(pinia) 时会执行这里
通过 provide 把 pinia 注入到整个应用——任何组件都能访问到它
_s 是一个 Map,存放所有已注册的 store 实例
state 是全局状态树,key 是 store id,value 是该 store 的 state
返回 pinia 实例
当组件开始聊天……
想象你的 Vue 组件们在一个群聊里讨论怎么从 store 拿数据。
Store 之间的组合
在真实项目中,不同 store 之间经常需要互相引用。Pinia 让这种组合变得自然且直观。
import { useCartStore } './cart'
export const useCheckoutStore = defineStore('checkout', () => {
const cart = useCartStore()
const total = computed(() =>
cart.items.reduce(
(sum, item) => sum + item.price * item.qty,
0
)
)
async function checkout() {
await apiSubmitOrder({ items: cart.items, total })
cart.clear()
}
return { total, checkout }
})
在 checkout store 中导入 cart store
获取 cart store 实例
计算购物车总价:遍历每个商品,价格乘以数量再求和
结账函数:提交订单到后端
成功后清空购物车
把计算属性和方法暴露出去
如果 store A 引用了 store B,而 store B 又引用了 store A,就会形成循环依赖。解决方案:把共享逻辑抽取到第三个 store,或者使用函数内部延迟引用(在 action 函数体内调用 useXxxStore(),而不是在 store 定义的顶层调用)。
SSR 场景下的 Pinia
在 Nuxt 或其他 SSR 框架中使用 Pinia 时,需要特别注意状态的隔离问题。在服务端,多个用户共享同一个 Node.js 进程,如果 store 实例被复用,用户 A 的数据就可能泄露给用户 B。解决方案是:在每次请求中创建全新的 Pinia 实例,确保状态隔离。Nuxt 的 @pinia/nuxt 模块已经自动处理了这个问题,但如果你自己搭建 SSR 环境,就需要手动管理 Pinia 实例的生命周期。理解这个原理能帮你避免在生产环境中出现难以排查的数据串漏 bug。
在真实项目中,路由切换往往需要触发状态更新。比如从商品列表页跳转到详情页时,需要根据路由参数加载对应的商品数据。最佳实践是在组件的 setup 函数或路由守卫中调用 store 的 action,而不是在 action 内部直接依赖路由。这样做的好处是保持 store 的纯净性——store 不应该知道路由的存在,它只负责管理数据。如果需要根据路由参数初始化状态,应该在组件层面桥接路由和 store,让数据流向更加清晰可追踪。
随着项目规模增长,store 的数量会越来越多。推荐的组织方式是按业务领域划分——每个领域一个目录,包含该领域的所有 store 和相关类型定义。例如 stores/ 目录下可以有 user/、cart/、product/ 等子目录。每个 store 文件保持小而聚焦,如果一个 store 超过 200 行,就应该考虑拆分。对于跨领域的共享逻辑,使用 composable 函数(组合式函数)而不是把所有东西都塞进 store。这种组织方式让团队成员可以独立开发不同领域,减少合并冲突。
按领域划分 Store
将 store 按业务领域组织到不同目录,每个 store 专注一个领域。避免创建一个巨型 store 管理所有状态。
Composable 桥接模式
使用 composable 函数桥接 store 和组件,封装复杂的状态逻辑,让组件代码保持简洁。
Store 测试策略
单独测试 store 的 action 和 getter,使用 createPinia() 创建测试专用实例,隔离测试环境。
巩固一下
当多个组件需要共享同一份用户数据时,Pinia 把这些数据存在哪里?
Pinia 的巧妙设计
探索 defineStore、$patch、订阅机制和 storeToRefs 背后的智慧
Option Store vs Setup Store
Pinia 提供了两种定义 Store 的风格——就像写文章可以用提纲式(Options)或自由散文式(Setup)。
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0, name: 'Pinia' }),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() { this.count++ },
},
})
用 Options 风格定义一个叫 counter 的 store
state:初始化两个数据字段
getter:根据 count 计算双倍值
action:提供修改 count 的方法
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('Pinia')
const doubleCount = computed(() => count.value * 2)
function increment() { count.value++ }
return { count, name, doubleCount, increment }
})
用 Setup 风格——就像写 Vue 3 的 setup() 函数
ref() 定义响应式数据(相当于 state)
computed() 定义计算属性(相当于 getters)
普通函数就是 action
最后把所有东西 return 出去
createOptionsStore 函数,它把 state/getters/actions 拆开后再调用 createSetupStore。所以 Setup Store 才是 Pinia 的"母语"。
SSR 与 Pinia:服务端渲染注意事项
如果你的项目使用 Nuxt 或手动配置 SSR,需要注意 Pinia 在服务端的特殊行为。在 SSR 环境中,每个请求都会创建一个新的 Pinia 实例,避免不同用户共享状态。
// server.js (Express)
app.get('*', async (req, res) => {
const pinia = createPinia()
const app = createSSRApp(App)
app.use(pinia)
// 预取数据到 store
const userStore = useUserStore(pinia)
await userStore.fetchUser()
const html = await renderToString(app)
res.send(html)
})
每个 HTTP 请求都要创建新的 Pinia 实例
创建 Vue 应用并注册 Pinia
注意:useUserStore 必须传入 pinia 参数
在渲染之前预取数据
渲染 HTML 并返回给客户端
最典型的错误是在模块顶层调用 useStore()——这会在模块加载时执行,导致所有用户共享同一个 store 实例。正确做法是在 setup() 函数或 data() 钩子内部调用。
$patch:批量修改的利器
想象你在整理房间——与其一件一件挪动家具,不如一次性规划好全部位置。$patch 就是这个"一次性规划"。
store.$patch((state) => {
state.items.push(' newItem')
state.hasChanged = true
})
用函数式 $patch 批量修改 state
往数组里添加一个新元素
同时标记 hasChanged 为 true
订阅机制:$subscribe
当 state 发生变化时,你想收到通知?用 store.$subscribe()。Pinia 源码中用 watch 深度监听整个 state,每当有变化就触发所有注册的回调函数。这就像在图书馆里注册了"新书到馆通知"——有变化,你第一时间知道。
storeToRefs:解构的魔法
如果你直接解构 store,响应式会丢失。storeToRefs 就是来解决这个问题的。
export function storeToRefs<SS>(store: SS) {
const rawStore = toRaw(store)
const refs = {} as StoreToRefs<SS>
for (const key in rawStore) {
const value = rawStore[key]
if (value.effect) {
refs[key] = computed({
get: () => store[key],
set: (val) => { store[key] = val }
})
} else if (isRef(value) || isReactive(value)) {
refs[key] = toRef(store, key)
}
}
return refs
}
拿到 store 的原始对象(去掉 Vue 的代理层)
创建一个空对象来存放转换后的 ref
遍历 store 的每个属性
如果属性有 effect(是 computed getter),包装成可写的 computed
getter:从 store 读取最新值
setter:写入时直接修改 store
如果是 ref 或 reactive,用 toRef 保持引用
返回所有转换后的响应式引用
value.effect 的判断——这是检测一个值是不是 computed 的巧妙方式。Vue 没有提供 isComputed() API,所以 Pinia 通过检查内部是否有 effect 属性来判断。这是阅读优秀源码才能学到的实战技巧。
插件系统:扩展 Pinia 的能力
Pinia 的插件系统让你可以在每个 store 创建时注入通用逻辑——比如持久化、日志、权限检查等。
function piniaPluginPersist({ store }) {
const saved = localStorage.getItem(store.$id)
if (saved) store.$patch(JSON.parse(saved))
store.$subscribe((mutation, state) => {
localStorage.setItem(store.$id, JSON.stringify(state))
})
}
const pinia = createPinia()
pinia.use(piniaPluginPersist)
定义一个 Pinia 插件函数
从 localStorage 读取该 store 之前保存的数据
如果有数据,用它来恢复 store 的状态
订阅 store 的变化
每当 state 变化,自动保存到 localStorage
创建 Pinia 实例并注册插件
Pinia 插件可以:(1) 给每个 store 添加新属性或方法;(2) 监听 store 变化做额外处理;(3) 在 store 初始化时执行逻辑。常见用途包括持久化、路由同步、错误上报、DevTools 集成等。社区有 pinia-plugin-persistedstate 等成熟插件可直接使用。
$patch 的两种调用方式
$patch 有两种调用方式。第一种是对象式,直接传入一个包含要修改字段的对象,适合简单的属性替换。第二种是函数式,传入一个接收 state 的函数,在函数体内自由修改,适合复杂的数组操作或条件修改。关键区别在于:对象式会触发一次响应式更新,函数式也只触发一次,但函数式可以执行任意复杂的逻辑。当你需要同时修改多个字段并且它们之间有依赖关系时,函数式是唯一选择。此外,使用函数式 $patch 修改数组时,可以直接使用 push、splice 等方法,而对象式无法做到这一点。
Pinia 提供了 $onAction 方法来监听所有 action 的调用。你可以用它来实现日志记录、性能分析和错误追踪。$onAction 接受一个回调函数,其中包含 action 的名称、参数、执行结果和执行时间。结合 $subscribe 监听状态变化,你可以构建完整的审计日志。在实际项目中,建议在开发环境启用这些监听,生产环境只保留错误追踪,避免性能开销。另外,Vue DevTools 的 Pinia 面板可以让你查看每次 action 调用前后的状态变化,是调试时不可或缺的工具。
Action 日志插件
使用 $onAction 在每次 action 调用时记录日志,包括参数、返回值和执行时间,方便调试和性能分析。
状态快照模式
通过 $subscribe 监听变化,在关键节点保存状态快照到数组,实现自定义的撤销和重做功能。
SSR 水合模式
在服务端将 Pinia 状态序列化到 HTML 中,客户端通过 $patch 恢复状态,避免重复请求。