Vue 3 设计与实现
第 15 章 状态管理:Pinia 内核
第 15 章 状态管理:Pinia 内核
本章要点
- Pinia 的架构哲学:从 Vuex 的 mutation/action/getter 简化为 state/action/getter
- createPinia 的实现:一个 effectScope + 一个 reactive Map 构成的轻量容器
- defineStore 的两种风格:Options Store 与 Setup Store 的内部统一
- Store 的创建与激活:延迟初始化、单例保证与 Pinia 实例绑定
- 响应式状态管理:$patch 的智能合并与批量更新优化
- Store 间的交互:如何在一个 Store 中使用另一个 Store
- SSR 状态序列化:服务端如何注入初始状态到客户端
- Pinia 插件系统:扩展每个 Store 的能力
- Vuex → Pinia 迁移指南与 7 种常见反模式
15.0 从 Vuex 到 Pinia:一次减法的胜利
2021 年,Vue 作者尤雨溪公开宣布将 Pinia 作为 Vue 官方推荐的状态管理库,取代 Vuex 4 成为 Vue 3 生态的标准。这个消息在当时引起轩然大波——Vuex 经历了三代演进,拥有庞大的用户基数和成熟的工具链。为什么要换?
答案写在 Pinia 的架构里:它把 Vuex 中的 mutation 彻底删掉了。
Vuex 的 mutation 是一个长期争议点。它强制”state 变更必须在 mutation 中同步完成”的规则,本意是为了可追溯,但实际使用中:
- 大部分 mutation 只是简单的字段赋值,沦为 boilerplate
- async 逻辑必须走 action,action 又必须 commit mutation,两跳调用栈让人抓狂
- TypeScript 支持靠类型推导插件”打补丁”,始终做不到原生体验
Pinia 的决定很激进:mutation 这层抽象本身没有带来足够价值,删掉它。结果是:
- 心智模型减少一半(state + action + getter)
- TypeScript 支持开箱即用
- 代码量减少 30-50%
- 性能更好(少一层调用)
这是”减法式设计”的力量——不是堆更多特性,而是识别出哪些特性是累赘,砍掉。本章就带你看清,一个高质量的状态管理库能做到多小、多优雅。
减法的勇气,来自对真实需求的深刻理解
很多同学以为”简单 = 功能少”,于是觉得 Pinia 是”砍掉东西的 Vuex”。这个判断只对了一半——Pinia 砍掉的不是功能,而是多余的仪式感。Vuex 的 mutation 最初被引入,是出于一个合理的担忧:异步 action 里穿插同步 state 修改,调试工具很难还原出”每一步变了什么”的时间线。但是 Vue 3 的响应式系统配合 devtools 已经能做到对每次 state 写入都记录一条 timeline 条目——mutation 这一层”人工显式标记”反而变成了多余。这是软件工程里很经典的”曾经必要,现在过时”的现象:一个机制之所以被设计出来是为了补偿某个缺失的能力,当那个缺失的能力被更底层的方式补全后,机制本身就应该被删掉。识别出”应该被删掉”的机制,比引入一个新机制需要更高的判断力——因为后者有短期功劳,前者只有长期价值。
如果把本章和第 14 章连着读,你会看到一条清晰的线:Vue 的响应式系统 + 组件 DI + effectScope 作用域,已经为”跨组件共享状态”这件事提供了足够的原语。Pinia 的工作不是”做了什么新东西”,而是”把这些原语编排成一个让用户用得顺手的库”。理解这一点,你就明白为什么 Pinia 源码能做到不到 2000 行——不是作者写代码魔法,而是底层做够了,上层就轻了。
如果说依赖注入是 Vue 生态的”神经网络”,那么 Pinia 就是建立在这个网络之上的”大脑”。作为 Vue 官方推荐的状态管理库,Pinia 用不到 2000 行核心代码实现了一个类型安全、支持 SSR、可扩展的全局状态方案。
Vuex 曾是 Vue 2 时代的标配,但它的 mutation 机制饱受争议——mutation 和 action 的边界模糊,mutation 必须同步的限制常常被开发者绕过。Pinia 的回答是:去掉 mutation,让 action 统一处理所有状态变更。这不是简化,而是认识到 mutation 这层抽象并没有带来足够的价值。
15.1 createPinia:状态容器的诞生
15.1.1 源码解析
// packages/pinia/src/createPinia.ts
export function createPinia(): Pinia {
const scope = effectScope(true)
const state = scope.run<Ref<Record<string, StateTree>>>(() =>
ref<Record<string, StateTree>>({})
)!
let _p: Pinia['_p'] = []
let toBeInstalled: PiniaPlugin[] = []
const pinia: Pinia = markRaw({
install(app: App) {
setActivePinia(pinia)
pinia._a = app
app.provide(piniaSymbol, pinia)
app.config.globalProperties.$pinia = pinia
toBeInstalled.forEach(plugin => _p.push(plugin))
toBeInstalled = []
},
use(plugin) {
if (!this._a) {
toBeInstalled.push(plugin)
} else {
_p.push(plugin)
}
return this
},
_p,
_a: null,
_e: scope,
_s: new Map<string, StoreGeneric>(),
state,
})
return pinia
}
这段代码信息量极大,逐层拆解:
effectScope(true):独立的副作用作用域
const scope = effectScope(true)
effectScope(true) 创建一个分离的(detached)作用域。true 参数意味着这个作用域不会被父作用域(组件的 setup)收集。为什么?因为 Pinia 的生命周期是应用级的,不应该跟随任何组件的卸载而销毁。
想清楚 true 这个布尔参数背后的设计逻辑,你就摸到了 effectScope 的精髓:作用域是一棵树。默认情况下,effectScope() 会被最近的外层作用域收集为子节点,外层销毁时内层一并销毁——这对组件里的”一组计算属性打包管理”非常合适。但 Pinia 不是组件的一部分,它的根作用域应该与任何具体组件解耦。true 就是那把剪刀——把它从默认收集链里剪下来,变成”孤立的根”。这个小设计直接对应一个大问题:如果 Pinia scope 被某个组件收集了,那个组件卸载时 Pinia 会整体销毁,所有 store 的 computed 都会失效——在多页面应用(尤其是 hydration + 动态路由切换场景)里,这会变成极难排查的偶发性 bug。effectScope(true) 这一行就是为了彻底堵住这类可能性。这是典型的”一个参数值一百行文档”的设计——不注释、不解释,直接用语义正确的参数把问题消除在设计层面。
state:全局状态树
const state = scope.run(() => ref({}))
所有 Store 的状态都存放在这个单一的 ref 中。key 是 Store 的 id,value 是该 Store 的 state。这使得:
- DevTools 集成:一个入口就能看到所有状态
- SSR 序列化:
JSON.stringify(pinia.state.value)即可导出所有状态 - 时间旅行调试:替换整个 state 就能回到任意时间点
_s:Store 实例注册表
_s: new Map<string, StoreGeneric>()
所有已创建的 Store 实例都注册在这里。这保证了 Store 的单例语义——同一个 id 的 Store 只会创建一次。
graph TD
A["createPinia()"] --> B["effectScope(true)"]
A --> C["ref({}) — 全局 state"]
A --> D["Map — Store 注册表"]
A --> E["install(app)"]
E --> F["app.provide(piniaSymbol, pinia)"]
E --> G["app.config.globalProperties.$pinia"]
style A fill:#42b883,color:#fff
style F fill:#35495e,color:#fff
15.1.2 markRaw 的妙用
const pinia: Pinia = markRaw({ ... })
Pinia 实例被 markRaw 标记为永不响应式。为什么?因为 Pinia 对象本身不需要被追踪——它是一个容器,不是状态。如果 Pinia 对象变成响应式的,它内部的 _s(Map)、_p(插件数组)等都会被深度代理,造成不必要的性能开销。
这是一个”正确的剪枝”——响应式系统的代理不是免费的,对不需要响应式的对象标记 raw 是性能优化的重要手段。
把这个决策和第 4、5 章的响应式哲学联系起来看会更透彻:响应式追踪是有代价的——每次属性访问都要走一遍 Proxy 的 get trap,每次写入都要触发对依赖的遍历通知。对那些语义上本就不应该变化的对象(容器、注册表、配置对象),让它们进入响应式系统是纯粹的浪费。Vue 3 为这类场景提供了一组”响应式退出”API:markRaw(一次性标记)、toRaw(取出原始对象)、shallowRef / shallowReactive(只代理一层)。Pinia 对 markRaw 的使用是这组 API 最典型的应用场景。当你在自己代码里发现”某个对象被塞到响应式对象里只是因为要共享引用,它的内部从来不变”时,记得用 markRaw 标记一下——一次标记可能省下每次访问的数十纳秒,在高频访问路径上会累积成可观的性能收益。
15.1.3 _a: null 背后的延迟安装
注意 Pinia 实例创建时 _a(app)是 null。直到 install(app) 被调用后才被赋值。这让 Pinia 的创建和挂载可以分离:
// 在应用配置阶段
const pinia = createPinia()
pinia.use(persistPlugin) // 此时 _a 还是 null,插件进 toBeInstalled 队列
// 在应用启动阶段
const app = createApp(App)
app.use(pinia) // 此时 install() 被调用,_a 赋值,队列里的插件才真正安装
这就是 toBeInstalled 队列的作用——把插件注册和插件生效解耦。
这种”先收集、后统一处理”的模式在整个 Vue 3 源码里反复出现——调度器的三级队列(第 12 章)、异步组件的 hook 收集(Suspense 部分)、路由的导航守卫链……都是同一套思路。当一个操作的触发时机和执行时机必须分离时,用队列做缓冲——简单、正确、可读。如果你在自己项目里遇到”注册早了,实际资源还没准备好;注册晚了,用户没机会配置”这类两难问题,toBeInstalled 是一个可以照抄的模板。
15.2 defineStore:两种风格,一种内核
15.2.1 Options Store
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
name: 'Counter'
}),
getters: {
doubleCount: (state) => state.count * 2
},
actions: {
increment() {
this.count++
}
}
})
15.2.2 Setup Store
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const name = ref('Counter')
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, name, doubleCount, increment }
})
两种写法产生完全一样的 Store。但内部实现路径不同——Options Store 会被转换为等价的 Setup Store。
为什么要保留两种写法?——表面上是为了兼顾不同偏好的用户,往深一层看是为了降低迁移成本。Pinia 2021 年发布时,Vue 2 时代的大量项目使用 Vuex,而 Vuex 的写法就是典型的 { state, getters, mutations, actions } 对象配置——Options Store 与之在视觉上几乎无缝衔接,一个老 Vuex 用户第一次看 Options Store 代码时,心智切换成本接近零。这是典型的”老人先安置下来,再慢慢展示新东西”的迁移策略。一旦用户用 Options Store 跑起来,在日常使用中自然会接触 Composition API、ref、computed——过了几个月,Setup Store 的写法看起来就不再陌生,这时候再迁移就水到渠成。迁移不是一次性跳跃,是一段旅程——Pinia 为这段旅程铺好了路。
15.2.3 Options vs Setup Store 选择建议
| 维度 | Options Store | Setup Store |
|---|---|---|
| 代码风格 | 对象配置(类似 Vue 2) | 函数式(类似 Composition API) |
| TypeScript 支持 | 良好 | 极佳 |
| 跨 Store 调用 | 需要注意 this 指向 | 直接调用 useXxx() 更自然 |
| 使用 Vue 组合式函数 | 不支持(需绕弯) | 原生支持 |
hydrate 选项 | 支持 | 不支持(需手动) |
| 老项目迁移 | 更熟悉 | 心智切换成本 |
建议:新项目一律用 Setup Store。老 Vuex 项目迁移初期可以先用 Options Store,熟悉后切 Setup Store。
15.2.4 defineStore 的实现
export function defineStore(
idOrOptions: any,
setup?: any,
setupOptions?: any
): StoreDefinition {
let id: string
let options: DefineStoreOptions | DefineSetupStoreOptions
// 重载解析
const isSetupStore = typeof setup === 'function'
if (typeof idOrOptions === 'string') {
id = idOrOptions
options = isSetupStore ? setupOptions : setup
} else {
options = idOrOptions
id = idOrOptions.id
}
function useStore(pinia?: Pinia | null, hot?: StoreGeneric): StoreGeneric {
// 获取当前组件实例
const hasContext = hasInjectionContext()
pinia = pinia || (hasContext ? inject(piniaSymbol, null) : null)
if (pinia) setActivePinia(pinia)
pinia = activePinia!
// 单例检查
if (!pinia._s.has(id)) {
// 首次使用,创建 Store
if (isSetupStore) {
createSetupStore(id, setup, options, pinia)
} else {
createOptionsStore(id, options as any, pinia)
}
}
const store: StoreGeneric = pinia._s.get(id)!
return store as any
}
useStore.$id = id
return useStore as any
}
注意 defineStore 返回的不是 Store 本身,而是一个 useStore 函数。Store 的创建是延迟的——只有在组件中第一次调用 useStore() 时才会真正创建。
这又是第 14 章反复讨论过的”创建 → 使用”两段式的体现:定义发生在模块加载时,实例化发生在第一次使用时。这样做有三个收益。第一,不会在 app 启动时就一次性创建所有 store——首屏用不到的 store 不会参与启动开销,后面讲 SSR 性能时我们会再回到这一点。第二,defineStore 可以在纯 JS 文件(不是组件)里调用,不依赖 Vue 的 setup 上下文——这让它可以和其他工具链(测试 mock、devtools 配置、服务端运行)更自由地搭配。第三,useStore() 作为函数,天然符合 Vue 3 的组合式 API 约定——在组件里和其他 useXxx 放在一起调用,风格一致,认知负担最小。好的设计通常不是一个点的巧妙,而是一串小决策合起来形成的合力。
sequenceDiagram
participant D as defineStore
participant U as useStore()
participant P as Pinia
participant S as Store
D->>U: 返回 useStore 函数
Note over U: 组件中首次调用
U->>P: pinia._s.has(id)?
P-->>U: false
U->>S: createSetupStore / createOptionsStore
S->>P: pinia._s.set(id, store)
U-->>U: return store
Note over U: 后续调用
U->>P: pinia._s.has(id)?
P-->>U: true(返回已有实例)
15.3 createOptionsStore:Options 到 Setup 的转换
function createOptionsStore<Id extends string>(
id: Id,
options: DefineStoreOptions<Id, any, any, any>,
pinia: Pinia
): Store<Id> {
const { state, actions, getters } = options
const initialState: StateTree | undefined = pinia.state.value[id]
let store: Store<Id>
function setup() {
if (!initialState) {
// 首次创建
pinia.state.value[id] = state ? state() : {}
}
// 将 state 的每个属性转换为 ref(toRefs 保持响应性连接)
const localState = toRefs(pinia.state.value[id])
return Object.assign(
localState,
actions,
// 将 getters 转换为 computed
Object.keys(getters || {}).reduce((computedGetters, name) => {
computedGetters[name] = markRaw(
computed(() => {
setActivePinia(pinia)
const store = pinia._s.get(id)!
return getters![name].call(store, store)
})
)
return computedGetters
}, {} as Record<string, ComputedRef>)
)
}
store = createSetupStore(id, setup, options, pinia, true)
return store as any
}
转换规则清晰:
| Options Store | → | Setup Store |
|---|---|---|
state() 返回值 | → | toRefs(pinia.state.value[id]) |
getters.xxx(state) | → | computed(() => getter(store)) |
actions.xxx() | → | 直接保留(this 绑定到 store) |
这就是为什么 Pinia 文档说”Options Store 只是 Setup Store 的语法糖”。
15.4 createSetupStore:Store 的真正诞生地
这是 Pinia 中最复杂的函数,约 400 行代码。我们分段解析核心逻辑:
15.4.1 第一步:在 effectScope 中运行 setup
function createSetupStore<Id extends string>(
$id: Id,
setup: () => any,
options: any,
pinia: Pinia,
isOptionsAPI?: boolean
): Store<Id> {
let scope!: EffectScope
const setupStore = pinia._e.run(() => {
scope = effectScope()
return scope.run(() => setup())
})!
// ...
}
双层 effectScope:外层是 Pinia 的全局作用域(pinia._e),内层是这个 Store 自己的作用域。当 Store 被 $dispose() 时,只需停止内层作用域,不影响其他 Store。
15.4.2 第二步:分类 setup 的返回值
for (const key in setupStore) {
const prop = setupStore[key]
if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) {
// 是 ref 或 reactive → 它是 state
if (!isOptionsAPI) {
pinia.state.value[$id][key] = prop
}
} else if (typeof prop === 'function') {
// 是函数 → 它是 action
const actionValue = wrapAction(key, prop)
setupStore[key] = actionValue
}
// computed 既不是 state 也不是 action → 它是 getter
}
Pinia 通过类型检测自动分类 setup 返回的属性:
ref(非 computed)或reactive→ state(参与序列化和 DevTools)function→ action(被 wrapAction 包装以支持 $onAction)computed→ getter(不做特殊处理,computed 本身就是响应式的)
这种”通过运行时类型自动推断语义”的设计很优雅——开发者不需要显式区分 state/getter/action,Pinia 自己看类型就知道。
“看类型就知道”背后的哲学是什么?——是”让结构本身承载语义”。Options Store 时代,用户必须把状态手动分类填进三个 key:state、getters、actions。这是一种”声明式分类”——你写在哪里,Pinia 就把它当什么。Setup Store 改成了”推断式分类”——你用什么 API 创建,Pinia 就把它当什么。ref/reactive 是响应式状态,天然属于 state;computed 是派生值,天然属于 getter;普通 function 不参与响应式,天然属于 action。分类不是人为贴标签得到的,而是”每个原语本身就带着自己的标签”。
这种设计的好处是少了一份类型到位置的映射。用户不会再遇到”我 computed 写在 getters 里还是 actions 里?“的迷茫,也不会有”这个 ref 是要作为 state 还是作为内部变量?“的纠结——这种疑惑在 Options Store 里每天都会发生。当 API 设计得足够好时,错误的写法会让代码看起来别扭,用户凭直觉就会绕开。Setup Store 做到了这一点。这是 Vue 3 组合式 API 整体设计哲学的延续——把分类的责任交给语言层(类型、引用标识),而不是框架层(位置约定)。
15.4.3 第三步:wrapAction —— Action 的增强
function wrapAction(name: string, action: (...args: any[]) => any) {
return function (this: any, ...args: any[]) {
setActivePinia(pinia)
const afterCallbackList: Array<(resolvedReturn: any) => any> = []
const onErrorCallbackList: Array<(error: unknown) => unknown> = []
function after(callback: typeof afterCallbackList[0]) {
afterCallbackList.push(callback)
}
function onError(callback: typeof onErrorCallbackList[0]) {
onErrorCallbackList.push(callback)
}
// 触发 $onAction 订阅者
triggerSubscriptions(actionSubscriptions, {
args,
name,
store,
after,
onError,
})
let ret: any
try {
ret = action.apply(this && this.$id === $id ? this : store, args)
} catch (error) {
triggerSubscriptions(onErrorCallbackList, error)
throw error
}
// 支持异步 action
if (ret instanceof Promise) {
return ret
.then((value) => {
triggerSubscriptions(afterCallbackList, value)
return value
})
.catch((error) => {
triggerSubscriptions(onErrorCallbackList, error)
return Promise.reject(error)
})
}
triggerSubscriptions(afterCallbackList, ret)
return ret
}
}
wrapAction 为每个 action 添加了 AOP(面向切面)能力:
const unsubscribe = store.$onAction(({ name, args, after, onError }) => {
const startTime = Date.now()
console.log(`Action ${name} started with args:`, args)
after((result) => {
console.log(`Action ${name} finished in ${Date.now() - startTime}ms`)
})
onError((error) => {
console.error(`Action ${name} failed:`, error)
})
})
这对日志记录、性能监控、错误追踪等场景极为有用。
AOP(Aspect-Oriented Programming)在状态管理里的价值
“日志记录 / 性能监控 / 错误追踪”这三件事的共同特点是:它们和业务本身无关,但要求对所有业务逻辑都生效。如果没有 AOP 机制,你只能在每个 action 的开头和结尾手动加 console.log、try/catch——这种代码会把业务意图和横切关注点揉成一团,PR diff 里 80% 的行是日志代码,20% 是实际逻辑,评审体验极差。
$onAction 的实现方式简单到令人意外——就是一个订阅者数组 + 包装函数在 action 调用前后触发回调。但它解决的问题是工程级别的:它让你可以在一个集中位置声明”所有 action 都要记录耗时”这种规则,而不用改任何业务 action。这种”把横切关注点从业务代码里剥离”的能力,在 Spring、NestJS 这类企业框架里通过注解和代理类实现,代价是巨大的运行时复杂度。Pinia 用 JavaScript 的闭包和数组做到了同样的效果,代码不到 50 行。这再次印证了本章反复强调的观点:合适的底层原语 + 节制的封装,能打败任何堆砌出来的”完整方案”。
15.4.4 第四步:构建 Store 对象
const partialStore = {
_p: pinia,
$id,
$onAction: addSubscription.bind(null, actionSubscriptions),
$patch,
$reset,
$subscribe(callback, options = {}) {
const removeSubscription = addSubscription(
subscriptions,
callback,
options.detached
)
const stopWatcher = scope.run(() =>
watch(
() => pinia.state.value[$id] as StateTree,
(state) => {
if (options.flush === 'sync' ? isSyncListening : isListening) {
callback({ storeId: $id, type: MutationType.direct }, state)
}
},
{ deep: true, flush: options.flush || 'pre' }
)
)!
return () => {
removeSubscription()
stopWatcher()
}
},
$dispose() {
scope.stop()
subscriptions = []
actionSubscriptions = []
pinia._s.delete($id)
},
}
const store: Store<Id> = reactive(partialStore) as unknown as Store<Id>
pinia._s.set($id, store as StoreGeneric)
Store 最终被 reactive() 包裹,这意味着:
- 解构不丢失响应性(对于 reactive 对象的属性,但 ref 类型的 state 解构后仍需
.value) - 模板中直接使用无需
.value - 所有方法的
this自动指向 store 本身
15.5 $patch:智能批量更新
15.5.1 对象形式
store.$patch({
count: store.count + 1,
name: 'Updated'
})
15.5.2 函数形式
store.$patch((state) => {
state.items.push({ id: Date.now(), name: 'New Item' })
state.count++
state.hasChanged = true
})
15.5.3 两种形式的适用场景
| 形式 | 适合场景 |
|---|---|
| 对象形式 | 浅层字段的批量赋值 |
| 函数形式 | 数组操作、嵌套对象、条件性修改 |
一般情况下函数形式更强大——它不仅仅是”合并”,而是可以执行任意逻辑。
15.5.4 实现解析
function $patch(
partialStateOrMutator: _DeepPartial<StateTree> | ((state: StateTree) => void)
): void {
let subscriptionMutation: SubscriptionCallbackMutation<any>
// 暂停 $subscribe 的触发(批量更新优化)
isListening = false
isSyncListening = false
if (typeof partialStateOrMutator === 'function') {
partialStateOrMutator(pinia.state.value[$id] as StateTree)
subscriptionMutation = {
type: MutationType.patchFunction,
storeId: $id,
events: debuggerEvents as DebuggerEvent[],
}
} else {
// 深度合并
mergeReactiveObjects(pinia.state.value[$id], partialStateOrMutator)
subscriptionMutation = {
type: MutationType.patchObject,
storeId: $id,
payload: partialStateOrMutator,
events: debuggerEvents as DebuggerEvent[],
}
}
// 恢复监听
const myListenerId = (activeListener = Symbol())
nextTick().then(() => {
if (activeListener === myListenerId) {
isListening = true
}
})
isSyncListening = true
// 手动触发一次 $subscribe 回调
triggerSubscriptions(subscriptions, subscriptionMutation, pinia.state.value[$id])
}
关键优化:暂停→变更→恢复→手动触发。在 $patch 期间,所有 $subscribe 的 watcher 被暂停,避免多次属性修改触发多次回调。所有变更完成后,手动触发一次订阅回调。这是经典的”批量更新”模式。
“批量更新”是前端领域一个永恒的主题——第 12 章讲 Vue 调度器时讨论过它(合并多次 effect 触发为一次 render),第 11 章讲 patch 时讨论过它(合并多个 DOM 操作为一次 commit),React 的 unstable_batchedUpdates、flushSync 也在做同一件事。Pinia 的 $patch 把这个思路延续到了订阅层:如果一个订阅者只关心”变完了什么样”,就没必要让它对中间状态做反应。
这里有一个非常值得品味的小细节:nextTick().then(() => { if (activeListener === myListenerId) { isListening = true } })——为什么恢复监听要放到 nextTick 里,还要用 Symbol 做令牌比较?**因为 patch 发生在第一次 patch 应该合并成一次订阅通知。令牌机制就是为此——只有最后一次 $patch 的令牌能顺利把监听打开,之前几次的 nextTick 回调因为令牌已变而被忽略。这是一种非常精巧的”覆盖式重入保护”——代码只有三行,但防掉了一整类”连续 patch 触发过多订阅”的性能问题。这种级别的细节是判断一个库”生产就绪”的重要标志——同样的 API 表面能不能正确处理高频调用、重入、race condition,差距往往就在这种看似微不足道的几行里。
15.6 storeToRefs:安全解构
看到这一节的小标题叫”安全解构”——是不是有一种奇怪的违和感?“解构为什么会有安全不安全”?这背后藏着 JavaScript 的一个老问题:解构本质上是取值,而 reactive 对象的属性取值会返回 unwrap 后的”当前值”而不是 ref 本身——一旦拿到值,就和原来的响应式源脱钩了。这个问题不是 Pinia 特有的,所有基于 reactive 的 API 都会遇到(第 4 章讨论 ref/reactive 时已经埋下过这个伏笔)。storeToRefs 是 Pinia 对这个通用问题给出的专用解法。
直接解构 Store 会丢失响应性:
const store = useCounterStore()
const { count } = store // ❌ count 是普通数字,不是 ref
Pinia 提供 storeToRefs 解决这个问题:
import { storeToRefs } from 'pinia'
const store = useCounterStore()
const { count, name } = storeToRefs(store) // ✅ count 和 name 是 ref
其实现原理:
export function storeToRefs<SS extends StoreGeneric>(
store: SS
): StoreToRefs<SS> {
store = toRaw(store)
const refs = {} as StoreToRefs<SS>
for (const key in store) {
const value = store[key]
if (isRef(value) || isReactive(value)) {
refs[key] = toRef(store, key) as any
}
}
return refs
}
它遍历 Store 的所有属性,只保留 ref 和 reactive 类型(即 state 和 getter),跳过函数(action)。toRef(store, key) 创建的 ref 保持与原 Store 的响应性连接。
实战小技巧:action 不放进 storeToRefs,因为函数不需要”响应式”,直接解构 store 访问就行:
const store = useCounterStore()
const { count, doubleCount } = storeToRefs(store) // state + getter
const { increment, decrement } = store // action 直接解构
15.7 Store 间的交互
15.7.1 在 Action 中使用其他 Store
const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const isLoggedIn = computed(() => user.value !== null)
return { user, isLoggedIn }
})
const useCartStore = defineStore('cart', () => {
const items = ref<CartItem[]>([])
const authStore = useAuthStore() // ✅ 直接调用
async function checkout() {
if (!authStore.isLoggedIn) {
throw new Error('Please login first')
}
// ...
}
return { items, checkout }
})
这是可行的,因为 useStore 内部通过 activePinia 找到 Pinia 实例,而不是依赖组件上下文。但需要注意循环依赖:如果 A Store 在顶层(setup 函数体中)使用 B Store,同时 B Store 也在顶层使用 A Store,就会导致无限循环。
15.7.2 循环依赖的解决
解决方法是将跨 Store 调用放在 action 或 getter 内部(延迟执行):
const useA = defineStore('a', () => {
const data = ref(0)
function doSomething() {
// ✅ 延迟调用,在 action 执行时 B 已经创建完毕
const b = useB()
b.otherAction()
}
return { data, doSomething }
})
15.7.3 Store 依赖关系图示
graph TB
A[useAuthStore] --> C[全局鉴权状态]
B[useCartStore] -->|checkout 依赖| A
D[useOrderStore] -->|创建订单依赖| A
D -->|读购物车| B
E[useNotificationStore] -.->|所有 action onError 发通知| A
E -.-> B
E -.-> D
style A fill:#10b981,color:#fff,stroke:none
style E fill:#f59e0b,color:#fff,stroke:none
这种依赖图画出来,能帮你识别”谁应该在谁之前初始化”、“哪些可以横切”等架构问题。
Store 依赖关系的绘制方法我推荐给所有中型以上项目的团队:每个季度花半天,把当前所有 store 画成一张有向图——store 节点、action/getter 到其他 store 的调用作为边。这张图可以直接从代码里通过静态分析工具(如 ts-morph)自动生成。每次画完你会发现一些反直觉的依赖:比如一个核心的 user store 意外依赖了一个 UI 层的 toast store(说明鉴权逻辑里混进了弹 toast 的调用),或者两个看似无关的 store 之间藏着长长的间接依赖链。这些发现不会被单元测试捕获,也不会被 code review 发现——它们只在依赖图这种高维视角下显现。健康的架构不是”没有依赖”,是”依赖是明的”——把它们画出来,让团队所有人能看到,问题就好办。
15.8 Pinia 插件系统
插件系统是判断一个库”是否具有生态潜力”的关键指标——一个库自己做完所有事,看起来”功能齐全”,但外部贡献者没法扩展;一个库把核心留下、扩展点开放,外部能长出一片生态。Redux 有 redux-thunk、redux-saga、redux-persist、redux-devtools,每一个都由不同团队贡献;Vue Router 有 vite-plugin-pages 等社区自动化工具。Pinia 的插件系统就是为了让 Pinia 也能走这条路——官方只做核心,持久化、日志、权限、乐观更新这些”每个团队都需要但写法各异”的能力交给社区去发挥。这一节的几个实战插件都是真实在生产项目里跑过的——你读完可以直接挑一个复用到自己项目里,也可以把它们作为”Pinia 插件应该长什么样”的参考模板。
15.8.1 插件接口
pinia.use(({ store, app, pinia, options }) => {
// store:当前 Store 实例
// app:Vue 应用实例
// pinia:Pinia 实例
// options:defineStore 的原始选项
})
15.8.2 插件的调用时机
每当一个新的 Store 被创建时,所有已注册的插件都会被调用:
// createSetupStore 内部
pinia._p.forEach((extender) => {
const extensions = scope.run(() =>
extender({ store, app: pinia._a, pinia, options })
)
if (extensions) {
// 插件返回的对象会被合并到 store 中
Object.assign(store, extensions)
// 如果返回了 ref,也需要同步到 state
Object.keys(extensions).forEach((key) => {
if (isRef(extensions[key])) {
pinia.state.value[$id][key] = extensions[key]
}
})
}
})
15.8.3 实战:持久化插件
function piniaPersistedState(options?: {
key?: string
storage?: Storage
paths?: string[]
}): PiniaPlugin {
const storage = options?.storage ?? localStorage
return ({ store }) => {
const storeKey = options?.key ?? store.$id
// 恢复状态
const savedState = storage.getItem(storeKey)
if (savedState) {
store.$patch(JSON.parse(savedState))
}
// 监听变化并持久化
store.$subscribe((mutation, state) => {
const toSave = options?.paths
? options.paths.reduce((acc, path) => {
acc[path] = state[path]
return acc
}, {} as Record<string, any>)
: state
storage.setItem(storeKey, JSON.stringify(toSave))
})
}
}
// 使用
pinia.use(piniaPersistedState({ storage: sessionStorage }))
15.8.4 实战:日志插件
function piniaLogger(): PiniaPlugin {
return ({ store }) => {
store.$onAction(({ name, args, after, onError }) => {
const start = performance.now()
console.group(`[Pinia] ${store.$id}.${name}`)
console.log('Args:', args)
after((result) => {
console.log('Result:', result)
console.log(`Duration: ${(performance.now() - start).toFixed(2)}ms`)
console.groupEnd()
})
onError((error) => {
console.error('Error:', error)
console.groupEnd()
})
})
}
}
15.8.5 实战:乐观更新插件
一个更复杂的例子——乐观更新:action 执行前先更新 UI,失败时自动回滚:
function piniaOptimistic(): PiniaPlugin {
return ({ store }) => {
store.$onAction(({ name, args, after, onError }) => {
if (!name.startsWith('optimistic')) return
// 在 action 开始前保存 snapshot
const snapshot = JSON.parse(JSON.stringify(store.$state))
onError((error) => {
// 失败时回滚
store.$patch(snapshot)
console.warn(`[Optimistic] ${name} failed, rolled back`, error)
})
})
}
}
使用:
const useTodoStore = defineStore('todo', () => {
const todos = ref<Todo[]>([])
async function optimisticAddTodo(text: string) {
const tempTodo = { id: Date.now(), text, synced: false }
todos.value.push(tempTodo) // 先本地添加
const saved = await api.addTodo(text) // 可能失败
// 如果失败,插件自动回滚;如果成功,用真实 id 替换
Object.assign(tempTodo, saved, { synced: true })
}
return { todos, optimisticAddTodo }
})
15.9 SSR 状态序列化
SSR(Server-Side Rendering)是现代 Web 应用的重要能力——首屏不再是空白 HTML + JS bundle 慢慢 hydrate,而是直接返回渲染好的页面、搜索引擎和首次访问的用户都更开心。但是 SSR 带来一个新问题:服务端已经跑过一轮状态变化的应用,怎么让客户端的 Vue 从”同一个状态起点”继续?Pinia 对这个问题的解决方案既朴素又漂亮——把整棵 state 树序列化到 HTML 里,客户端反序列化后一次性赋值给 pinia.state.value 就行。这个方案能成立的前提是 Pinia 把所有 store 的 state 都集中放在一个全局 ref 里(15.1.1 节讲过)——如果 state 分散在各个 store 对象里,序列化就会涉及”找全所有 store 的 state”这种繁琐工作。架构决定了 SSR 的难易——Pinia 第一天就把全局 state 设计成集中存储,半年后 SSR 需求上来时几乎不用改代码。这是”正确的一开始”的长期红利。
Pinia 对 SSR 的支持极为简洁。核心思路:服务端渲染完成后,将 pinia.state.value 序列化到 HTML 中,客户端激活时恢复。
15.9.1 服务端
// server.ts
const pinia = createPinia()
const app = createSSRApp(App)
app.use(pinia)
// 渲染应用(会触发 Store 的创建和数据获取)
const html = await renderToString(app)
// 序列化状态
const piniaState = JSON.stringify(pinia.state.value)
const fullHtml = html.replace(
'</body>',
`<script>window.__PINIA_STATE__=${piniaState}</script></body>`
)
15.9.2 客户端
// client.ts
const pinia = createPinia()
const app = createApp(App)
app.use(pinia)
// 恢复状态
if (window.__PINIA_STATE__) {
pinia.state.value = JSON.parse(window.__PINIA_STATE__)
}
app.mount('#app')
当 pinia.state.value 被直接赋值时,之后创建的 Store 会检查 pinia.state.value[$id] 是否已存在:
// createOptionsStore 中
const initialState = pinia.state.value[$id]
if (!initialState) {
pinia.state.value[$id] = state ? state() : {}
}
如果已存在(来自 SSR),就复用 SSR 的状态,跳过 state() 初始化。
15.9.3 SSR 安全考量
序列化状态注入 HTML 时,要防 XSS:
// ❌ 危险:用户可控的 state 可能包含 </script>
html.replace('</body>', `<script>window.__PINIA__=${JSON.stringify(state)}</script></body>`)
// ✅ 安全:用专门的序列化工具
import serialize from 'serialize-javascript'
html.replace('</body>', `<script>window.__PINIA__=${serialize(state, { isJSON: true })}</script></body>`)
serialize-javascript 会 escape <、> 等字符,防止注入。
这是”所有 SSR 框架都踩过的坑”——Next.js、Nuxt、Remix 无一例外都在文档里反复强调”不要用 JSON.stringify 直接往 HTML 里拼 state”。原因在于 </script> 这种字符串如果出现在用户输入里(比如评论、富文本),经过 JSON.stringify 后依然是 </script>,浏览器解析 HTML 时会提前关闭 script 标签,剩下的 JSON 就会被当成 HTML 输出,甚至被当成可执行的脚本内容——这就是典型的 XSS 路径。serialize-javascript 这类库会把 < 替换成 \u003c,这样”HTML 解析器看不出这是标签,但 JS 解析器能还原成 <“,两边都满意。这个知识点在 SSR 上线检查清单里排在最靠前的位置——没有之一。如果你的项目会接入 Pinia SSR,一定要在 code review 里把这条列为红线。
15.10 Vuex → Pinia 迁移指南
“要不要从 Vuex 切 Pinia”是 2022-2023 年大量 Vue 2 项目升级 Vue 3 时面临的头号问题。我的建议很简单:如果你升级到了 Vue 3,就顺便切 Pinia。理由有三个。第一,Vuex 4 虽然兼容 Vue 3,但 TypeScript 支持仍然需要靠 decorator / 插件打补丁,在 Vue 3 的整体类型体验里显得格格不入;Pinia 则是原生类型安全。第二,Pinia 的代码量通常比等价的 Vuex 少 30-50%,迁移后维护成本下降明显。第三,尤雨溪本人已经公开说 Vuex 4 之后不会有重大更新了——继续押注 Vuex 没有长期价值。“要不要迁”和”怎么迁”是两个问题,前者结论明确,后者才是技术挑战。本节讲的是后者。
15.10.1 映射关系
| Vuex | Pinia |
|---|---|
Vuex.Store({ modules }) | 多个 defineStore |
state | state(Options)或 ref(Setup) |
mutations | 删除,并入 actions |
actions | actions |
getters | getters |
commit('mut', payload) | store.mut(payload) 或 $patch |
dispatch('action', payload) | store.action(payload) |
mapState, mapGetters | storeToRefs(store) |
15.10.2 迁移步骤
- 同时并存:
createPinia()和new Vuex.Store()可以共存,允许分模块渐进迁移 - 一个模块一个 Store:把 Vuex 的每个 module 转为一个 Pinia store
- 先转 Options Store:心智压力小
- 合并 mutation 到 action:把每个 mutation 变成 action 里的一行代码
- 观察组件:
mapState换成storeToRefs,mapActions换成解构 - 全部迁移完成后拆掉 Vuex
典型迁移前后:
// Vuex
commit('setUser', user) // mutation
dispatch('fetchUser', id) // action
// Pinia
userStore.user = user // 或 userStore.$patch({ user })
await userStore.fetchUser(id)
15.11 Pinia 的 7 种反模式
与第 14 章的反模式表异曲同工——这张表里的每一条都是在真实团队里踩过的坑。我特别想强调其中两条:第一条”每个组件一个 Store”——很多同学把 store 当”更强的 ref”用,结果一个中型项目有 200 多个 store。Pinia 不会限制 store 数量,但 devtools 里选择 store 的下拉框会长到屏幕装不下,code search 里 defineStore 一找一百个结果,新同事根本不知道从哪儿看起。按业务域划分 store 的颗粒度,比按组件划分大一个数量级——用户模块一个 store、商品一个、购物车一个、通知一个,整个项目 10-30 个是健康范围。
第二条”Store 里塞 UI 状态”——弹窗开关、下拉菜单 hover、当前选中 tab 这类”只跟一个组件有关”的状态,塞进全局 store 会让状态面板变成垃圾场,还会带来”不同页面打开相同组件应该不应该共享这个开关状态”这类边界模糊的问题。Store 只放”真正跨组件共享的业务状态”——这一条记住了,你的 Pinia 使用水平已经超过大多数教程写手。
| 反模式 | 问题 | 正确做法 |
|---|---|---|
| 每个组件一个 Store | Store 数量爆炸 | 按业务域组织 store |
| Store 里塞 UI 状态(弹窗开关等) | 污染全局 | 组件本地 ref/reactive |
| Store 互相循环依赖(顶层调用) | 初始化 hang | action/getter 内延迟调用 |
直接改 store.$state 绕过 action | 失去可追溯性 | 用 action 或 $patch |
| Setup Store 里没 return state | 外部拿不到 | 必须 return 所有想暴露的 |
| SSR 时序列化函数/Map | JSON 丢数据 | 只序列化 POJO state |
| 忘记 storeToRefs 直接解构 | 响应式丢失 | 习惯性用 storeToRefs |
15.12 与 Vue 响应系统的深度集成
前面的章节讲了 Pinia 的各种”上层语义”——store 的创建、插件、SSR、$patch。这些所有设计能成立,都依赖于 Pinia 与 Vue 响应系统的深度耦合。本节把这层耦合讲透,帮你理解 Pinia 为什么能做到”用 2000 行代码解决 Vuex 用 5000 行代码才能解决的问题”。
15.12.1 effectScope 的价值
effectScope 在 Vue 3 里是一个极少被业务代码直接调用、但几乎所有高阶库都重度依赖的 API。它的作用一句话:把一组 reactive effect(computed / watch / watchEffect)打包起来,可以统一启动、统一停止。听上去简单,但这个能力是 Pinia、VueUse、Vue Router 能”优雅地管理生命周期”的根本。没有 effectScope,Pinia 要实现 $dispose 就得自己维护一张”我创建过哪些 watch”的表,在 dispose 时挨个调用 stop;有了 effectScope,一行 scope.stop() 搞定所有。这再次呼应第 14 章讲的”借语言的台子”主题——Pinia 没有造自己的副作用管理器,而是让 Vue 的 effectScope 帮它做这件事。
Pinia 大量使用 effectScope 管理副作用:
// Pinia 级别的 scope
const scope = effectScope(true) // detached,不随组件销毁
// Store 级别的 scope
pinia._e.run(() => {
scope = effectScope()
return scope.run(() => setup())
})
// Store 销毁时
$dispose() {
scope.stop() // 停止所有 computed、watch 等副作用
}
graph TD
A["Pinia effectScope(应用级)"] --> B["Store A effectScope"]
A --> C["Store B effectScope"]
A --> D["Store C effectScope"]
B --> E["computed / watch / ..."]
C --> F["computed / watch / ..."]
D --> G["computed / watch / ..."]
style A fill:#e74c3c,color:#fff
style B fill:#42b883,color:#fff
style C fill:#42b883,color:#fff
style D fill:#42b883,color:#fff
15.12.2 为什么 Store 是 reactive 的
Pinia 把最后构造好的 store 对象包了一层 reactive()——这个决策在源码里只有一行,但后果非常长远。让我们想象一下如果不用 reactive 会怎么样:store 将会是一个普通对象,内部的 ref 和 computed 暴露出来后,模板里就得写 {{ store.count.value }},computed 访问也要走 .value。用户体验立刻降低一个档次——“为什么我从组件里用 ref 不用 .value,但是从 store 拿的 ref 就要 .value?“这种不一致会让新手踩半年坑。
Pinia 用一个 reactive() 包装解决了这个问题:reactive 对属性的自动 unwrap 机制让 store.count 直接返回底层值(而不是 ref 本身),模板和 JS 代码都不用写 .value。代价是什么?代价是解构会丢响应式——这就是 storeToRefs 存在的原因。Pinia 用”模板友好”换”解构需要辅助函数”,从使用频率看是正确的取舍——模板里读 store 是每次渲染都发生的事,解构只在 setup 顶部发生一次。一个正向决策几乎总会带来一些反向副作用;好的 API 设计就是让副作用限制在尽量少的地方,并提供一个清晰的”副作用逃生通道”——storeToRefs 正是这样一个通道。
const store = reactive(partialStore)
这个决定有深远影响:
- 模板友好:
{{ store.count }}无需.value - 自动追踪:在 computed/watch 中访问
store.count自动建立依赖 - 解构需谨慎:
const { count } = store会丢失响应性(因此需要storeToRefs)
15.13 小结
Pinia 的内核设计可以浓缩为一句话:用最少的抽象层,最大限度地利用 Vue 已有的响应系统。
| 机制 | 实现 | 设计理念 |
|---|---|---|
| 状态容器 | effectScope + ref({}) | 复用 Vue 响应系统 |
| Store 单例 | Map + 延迟创建 | 按需初始化,零浪费 |
| Options → Setup | toRefs + computed 转换 | 统一内核,两种语法 |
| 批量更新 | $patch 暂停/恢复监听 | 减少不必要的触发 |
| Action 增强 | wrapAction + AOP 钩子 | 可观察的状态变更 |
| 插件系统 | 创建时遍历调用 | 每个 Store 独立扩展 |
| SSR | state 序列化/反序列化 | 利用全局 state 树 |
Pinia 证明了一个道理:好的库不是添加新概念,而是将已有概念组合到极致。它没有发明新的响应式原语,没有引入新的调度机制,只是将 ref、reactive、computed、effectScope、provide/inject 这些 Vue 核心 API 编排成了一个优雅的状态管理方案。
对比 Redux 和 Zustand,你会更看清这个选择的珍贵
React 生态的状态管理走过完全相反的路径:Redux 引入了自己的发布-订阅体系、自己的中间件机制、自己的时间旅行调试器——几乎是把一套完整的响应式系统在状态管理库内部重新实现了一遍。后来的 Zustand、Jotai、Recoil 本质上都在”造各自的响应式小系统”。这不是作者没水平——这是 React 本身没有原生响应式原语,状态管理库不得不自己造轮子。
Vue 生态之所以有 Pinia 这种”极简而强大”的库,根本原因在于 Vue 自己把响应式做到了足够好。第 4、5 章里那套 ref/reactive/effect/effectScope 一旦坚实,上层库就可以”白嫖”——只需要做编排、不需要造底层。这也解释了一个很多人好奇的现象:为什么 Vue 生态没有像 React 那样出现十几个状态管理库各自称王?答案是”没那么多需求让库去满足”——Vue 的响应式已经解决了大部分问题,Pinia 只要把最后一公里(全局 scope、SSR 序列化、devtools 集成、插件 AOP)做好就够了。底层足够强,上层就不会陷入军备竞赛——这对框架作者是一个值得反复琢磨的启示。
一句话记忆:
Pinia = 一个全局 ref + 一张 Store 地图 + 一个 effectScope 大草坪——在 Vue 的响应式花园里种出来的状态管理树。
一章收尾,一个大视角
如果你是从头按顺序读到这里的,应该已经积累了一个很重要的直觉:Vue 3 的所有”上层便利”都可以被追溯到几条底层原语的正确组合。第 4、5 章讲了响应式原语(ref / reactive / effect),第 9 章讲了响应式哲学,第 10 章讲了组件系统,第 12 章讲了调度器,第 14 章讲了 DI——这一章是把所有这些”底层零件”组装成一个面向业务的完整解决方案的示范。
Pinia 在 Vue 3 生态里扮演的角色,某种程度上可以和 Vue Router(下一章)类比。它们都遵循同一种模式:由一个 createXxx 工厂生产核心对象,通过 app.use 安装,通过 inject 在组件里消费。你掌握了 Pinia 的内核,再读 Vue Router 会有一种强烈的”又见故人”感——不是因为两个库抄了对方,而是因为它们都踩在 Vue 3 原语之上,遵循同一套自然的组合规律。
延伸阅读
- Pinia 源码
packages/pinia/src/createPinia.ts、packages/pinia/src/store.ts:这两个文件加起来不到 1000 行,是本章所有讨论的原始材料。如果你只看一份源码作为 Vue 3 风格的代表,我强烈推荐 Pinia——它比 Vue 核心更容易完整读完,而且处处体现”借响应式系统的台子”的美感。 - Pinia 官方文档 What is a Store 和 Core Concepts > Setup Stores 章节:文档作者是 Pinia 作者 Eduardo San Martin Morote 本人,对 setup vs options 的取舍、对 store 生命周期的解释是第一手信息。
- 尤雨溪 2021 年在 Vue Conf 的演讲 The Future of Vue State Management:官方宣布 Pinia 成为推荐方案的原始场合,对”为什么不再推 Vuex”有他本人的权威说法。
- Redux 文档 Style Guide > Essentials:对比阅读你会发现,Redux 不得不引入”纯 reducer / action creator / thunk middleware”这一整套约定来实现 Pinia 用 setup store 直接就能做到的事——这是响应式与非响应式两种世界对于状态管理问题的不同答案。
- VueUse 库:是”把响应式系统 + effectScope + 组合式函数”这套思路推到极致的社区项目。读它的源码能让你对本书前面几章讲过的原语建立更立体的感受。
下一章是 Vue Router——你会再一次看到”createRouter + app.use + useRouter”的三段式,也会看到它如何通过 DI 把导航状态注入到组件树中。如果本章你读得舒服,下一章会让你感到”模式已熟,细节是飞跃”。
思考题
-
为什么 Pinia 选择
effectScope(true)(detached)而不是普通的 effectScope?如果使用非 detached 的 scope,会发生什么? -
storeToRefs返回的 ref 与直接在 Store 中定义的 ref 是同一个对象吗?修改storeToRefs返回的值会影响 Store 吗?请设计实验验证。 -
在一个大型应用中,有 50 个 Store,但首屏只用到其中 5 个。Pinia 的延迟初始化策略如何帮助优化首屏性能?如果改为”启动时全部初始化”,会有多大的性能差异?
-
设计一个 Pinia 插件,实现”乐观更新”功能:action 执行前先更新 UI,如果 action 失败则自动回滚到之前的状态。