Pinia 源码解析:defineStore 内部机制与响应式 store 的创建
Pinia 没有 mutation 不是一个设计取舍,而是一个明确的工程立场:在 Vue 3 中,mutation 的存在意义从来不是"修改状态",而是"让 devtools 知道哪些修改发生了"。Vue 3 的响应式系统已经在底层追踪所有变化,Pinia 只需要给 devtools 一个合适的拦截点。这个理解颠覆了你对状态管理"为什么需要这么多仪式感"的认知。
Level 1 · 你需要知道的(1-3年经验)
Pinia 的两种 store 定义方式
Options Store(类似 Vuex,有结构感):
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
id: 0,
name: '',
role: 'viewer' as 'admin' | 'editor' | 'viewer',
permissions: [] as string[]
}),
getters: {
isAdmin: (state) => state.role === 'admin',
displayName: (state) => state.name || '未登录',
// getter 可以使用其他 getter
canEdit: (state) => state.role !== 'viewer'
},
actions: {
async login(username: string, password: string) {
const user = await api.login(username, password)
this.id = user.id
this.name = user.name
this.role = user.role
this.permissions = user.permissions
},
logout() {
this.$reset() // 重置到初始 state
}
}
})
Setup Store(灵活,与 Composable 风格一致):
export const useUserStore = defineStore('user', () => {
// state:ref/reactive
const id = ref(0)
const name = ref('')
const role = ref<'admin' | 'editor' | 'viewer'>('viewer')
const permissions = ref<string[]>([])
// getters:computed
const isAdmin = computed(() => role.value === 'admin')
const displayName = computed(() => name.value || '未登录')
// actions:普通函数
async function login(username: string, password: string) {
const user = await api.login(username, password)
id.value = user.id
name.value = user.name
role.value = user.role
permissions.value = user.permissions
}
function logout() {
id.value = 0
name.value = ''
role.value = 'viewer'
permissions.value = []
}
// 需要显式返回(与 Composable 相同)
return { id, name, role, permissions, isAdmin, displayName, login, logout }
})
在组件中使用 store
<script setup>
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
const store = useUserStore()
// 方法一:直接访问(适合 actions,不会失去响应性)
store.login(username, password)
// 方法二:storeToRefs 解构(适合 state/getters)
const { name, isAdmin, displayName } = storeToRefs(store)
// name, isAdmin 是 Ref,解构后保持响应性
// ❌ 直接解构 state/getters 失去响应性
const { name, isAdmin } = store // 普通值,不响应
</script>
$patch:批量更新
const store = useUserStore()
// 对象形式:浅合并
store.$patch({ name: 'Alice', role: 'admin' })
// 函数形式:允许复杂操作(推荐用于数组操作)
store.$patch((state) => {
state.permissions.push('write')
state.permissions = state.permissions.filter(p => p !== 'read')
})
函数形式的优势:在单次 $patch 调用中完成所有修改,devtools 记录为一次操作。
$reset()、$subscribe()、$onAction()
// 重置到初始状态(仅 Options Store 支持)
store.$reset()
// 监听 state 变化
const unsubscribe = store.$subscribe((mutation, state) => {
console.log(mutation.type) // 'direct' | 'patch object' | 'patch function'
console.log(mutation.storeId) // 'user'
// 可以在这里做持久化(存 localStorage)
localStorage.setItem('user', JSON.stringify(state))
})
unsubscribe() // 取消订阅
// 监听 action 调用
store.$onAction(({ name, args, after, onError }) => {
console.log(`Action ${name} called with`, args)
after((result) => console.log('Action succeeded:', result))
onError((error) => console.error('Action failed:', error))
})
Level 2 · 它是怎么运行的(3-5年经验)
defineStore 的内部机制
defineStore 返回一个 Composable 函数(useStore),而不是立即创建 store。每次调用这个函数时,内部逻辑如下:
useUserStore() 被调用
│
▼
获取当前的 pinia 实例
(通过 inject(piniaSymbol) 从 appContext 获取)
│
▼
检查 pinia._s(Map<id, store>)
是否已有 id='user' 的 store?
├── YES → 直接返回缓存的 store 实例
└── NO → 创建新的 store 实例
│
├─ Options Store:createOptionsStore()
└─ Setup Store:createSetupStore()
单例机制:pinia._s 是一个 Map,key 是 store id,value 是 store 实例。同一个 id 只创建一次。这就是为什么在不同组件调用 useUserStore() 返回的是同一个对象。
Options Store 的内部转换
Options Store 内部被转换成 Setup Store:
// 简化版 createOptionsStore 实现
function createOptionsStore(id, options, pinia) {
const { state, actions, getters } = options
// state() 的返回值作为 setup 的初始状态
const initialState = state?.() ?? {}
// 转换成 setup 函数
function setup() {
// 1. state → reactive
const storeState = pinia.state.value[id] = reactive(initialState)
// 2. getters → computed
const storeGetters = {}
for (const name in getters) {
storeGetters[name] = computed(() => {
return getters[name].call(store, storeState)
})
}
// 3. actions → 绑定 this 的函数
const storeActions = {}
for (const name in actions) {
storeActions[name] = function(...args) {
return actions[name].apply(store, args)
}
}
return { ...storeState, ...storeGetters, ...storeActions }
}
return createSetupStore(id, setup, pinia)
}
Setup Store 的核心创建过程
// 简化版 createSetupStore
function createSetupStore(id, setup, pinia) {
// 用 effectScope 管理所有响应式副作用
const scope = effectScope(true)
// 在 scope 内执行 setup,捕获所有创建的 effect
const setupStore = scope.run(() => setup())
// 构建 store 对象
const store = reactive({
_id: id,
_scope: scope,
$patch: patchMethod,
$subscribe: subscribeMethod,
$onAction: onActionMethod,
$dispose: () => scope.stop(), // 清理所有副作用
...setupStore // 展开 setup 返回的内容
})
// 注册到 pinia._s
pinia._s.set(id, store)
return store
}
effectScope:store 的生命周期容器
app.provide(piniaSymbol, pinia)
│
▼
useUserStore() 首次调用
│
▼
scope = effectScope(true) ← detached,不依附于任何组件
│
▼
scope.run(() => {
// setup 内创建的所有 ref, computed, watch
// 都注册在 scope 的 effect 列表中
const id = ref(0) ← 注册到 scope
const isAdmin = computed(...) ← 注册到 scope
return { id, isAdmin, ... }
})
│
▼
store._scope = scope
// store 不会因为使用它的组件卸载而销毁
// 只有显式调用 store.$dispose() 才销毁
为什么用 detached effectScope:store 的生命周期独立于组件。如果 store 绑定到组件的 effectScope,组件卸载时 store 就会被销毁——这是错误的,因为其他组件可能还在使用同一个 store。
$patch 的实现机制
// 两种 $patch 形式的实现
function $patch(partialStateOrMutator) {
// 开启批量更新(暂停触发 watcher)
isListening = false
isSyncListening = false
if (isFunction(partialStateOrMutator)) {
// 函数形式:将 store 传给用户函数
partialStateOrMutator(store)
} else {
// 对象形式:浅合并
mergeReactiveObjects(store, partialStateOrMutator)
}
// 恢复监听
isListening = true
isSyncListening = true
// 触发一次订阅通知($subscribe 回调)
triggerSubscriptions(
subscriptions,
{ type: isFunction(partialStateOrMutator)
? MutationType.patchFunction
: MutationType.patchObject,
storeId: $id,
},
store
)
}
关键:$patch 在修改期间暂停 watcher 触发,所有修改完成后统一触发一次,避免中间状态的 watcher 调用。
storeToRefs 的实现原理
// storeToRefs 的简化实现
export function storeToRefs<SS extends StoreGeneric>(store: SS) {
const refs = {}
for (const key in store) {
const value = store[key]
if (isRef(value) || isReactive(value)) {
// state 和 getters:转换为 ref
refs[key] = toRef(store, key)
}
// actions 是普通函数,跳过(不需要转为 ref)
}
return refs
}
toRef(store, key) 创建一个计算引用,读取时从 store[key] 获取,写入时设置 store[key]。这样解构后的 ref 始终与 store 状态同步。
store 创建与访问流程图
应用初始化:
createPinia() → pinia 实例
├─ pinia.state = ref({}) ← 所有 store 的 state 集合
├─ pinia._s = new Map() ← store 实例缓存
└─ pinia._e = effectScope() ← 根 effectScope
app.use(pinia)
└─ app.provide(piniaSymbol, pinia)
组件中 useUserStore():
inject(piniaSymbol) → pinia
pinia._s.has('user')? → NO
createSetupStore('user', setup, pinia)
├─ scope = effectScope(true)
├─ setupStore = scope.run(setup)
├─ store = reactive({ ...setupStore, $patch, ... })
└─ pinia._s.set('user', store)
→ 返回 store
再次调用 useUserStore():
inject(piniaSymbol) → pinia
pinia._s.has('user')? → YES
→ 直接返回 pinia._s.get('user')(同一个实例)
Level 3 · 设计文档与源码(资深开发者)
pinia 的核心数据结构
// packages/pinia/src/createPinia.ts
export interface Pinia {
install: (app: App) => void
use: (plugin: PiniaPlugin) => Pinia
_p: PiniaPlugin[] // 插件列表
_a: App // 绑定的 app 实例
_e: EffectScope // 根 effectScope
_s: Map<string, StoreGeneric> // store 缓存 Map
state: Ref<Record<string, StateTree>> // 所有 store state 的集合
}
pinia.state 是所有 store state 的根容器——这使得 SSR hydration 和状态快照成为可能:
// SSR:在服务端生成状态快照
const pinia = createPinia()
// ...处理请求
const snapshot = JSON.stringify(pinia.state.value)
// 客户端:注入快照,避免重新请求
const pinia = createPinia()
pinia.state.value = JSON.parse(snapshot)
defineStore 的完整签名分析
// packages/pinia/src/store.ts
export function defineStore<
Id extends string,
S extends StateTree = {},
G extends GettersTree<S> = {},
A = {}
>(
id: Id,
options: DefineStoreOptions<Id, S, G, A>
): StoreDefinition<Id, S, G, A>
export function defineStore<Id extends string, SS>(
id: Id,
storeSetup: () => SS,
options?: DefineSetupStoreOptions<Id, _GettersTree<SS>, _ActionsTree>
): StoreDefinition<Id, _GettersTree<SS>, _ActionsTree, SS>
Setup Store 的泛型参数 SS 是 setup 函数的返回类型,Pinia 从中自动区分 state(ref/reactive)、getters(computed)和 actions(function)。
$onAction 的拦截机制
$onAction 在 store 创建时包装所有 actions:
// packages/pinia/src/store.ts (简化)
for (const actionName in setupStore) {
if (typeof setupStore[actionName] === 'function') {
// 包装每个 action
setupStore[actionName] = wrapAction(actionName, setupStore[actionName])
}
}
function wrapAction(name, action) {
return function(...args) {
const afterCallbackList: ((resolvedReturn: any) => void)[] = []
const onErrorCallbackList: ((error: unknown) => void)[] = []
// 通知所有 $onAction 订阅者
triggerSubscriptions(actionSubscriptions, {
args,
name,
store,
after: (callback) => afterCallbackList.push(callback),
onError: (callback) => onErrorCallbackList.push(callback),
})
let ret: unknown
try {
ret = action.apply(store, args)
} catch (error) {
triggerSubscriptions(onErrorCallbackList, error)
throw error
}
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
}
}
这个包装机制使得 $onAction 的 after 和 onError 回调能够无论同步还是异步 action 都正常工作。
Pinia 插件系统
// 插件接口:在 store 创建时调用
export interface PiniaPlugin {
(context: PiniaPluginContext): Partial<PiniaCustomProperties> | void
}
// 创建一个持久化插件示例
function persistPlugin(context: PiniaPluginContext) {
const { store, options } = context
// 从 localStorage 恢复状态
const saved = localStorage.getItem(store.$id)
if (saved) {
store.$patch(JSON.parse(saved))
}
// 监听变化,保存到 localStorage
store.$subscribe(() => {
localStorage.setItem(store.$id, JSON.stringify(store.$state))
})
}
// 注册插件
pinia.use(persistPlugin)
插件在每个 store 创建后立即调用,可以访问完整的 store 实例,添加属性、监听变化、包装 actions 等。
HMR(热模块替换)支持
Pinia 支持开发时 store 的热替换:
// 在 defineStore 中指定 acceptHMR
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
return { count }
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot))
}
HMR 时,Pinia 用新的 store 定义替换缓存中的 store,同时保留当前 state(避免状态丢失)。
Level 4 · 边界与陷阱(全体适用)
陷阱 1:在 setup 之外使用 store
Pinia store 通过 inject(piniaSymbol) 获取 pinia 实例,这依赖 Vue 的依赖注入系统:
// ❌ 在组件 setup 之外使用(如路由守卫、axios 拦截器中)
router.beforeEach((to) => {
const store = useUserStore() // 错误:没有活跃的 Vue app 上下文
})
// ✅ 解决方案一:传递 pinia 实例
import { createPinia } from 'pinia'
const pinia = createPinia()
router.beforeEach((to) => {
const store = useUserStore(pinia) // 显式传入 pinia
})
// ✅ 解决方案二:在 setup 中获取 store,通过闭包使用
// (见下方路由守卫模式)
最佳实践:将 pinia 实例导出,在需要时传入 useStore(pinia)。
陷阱 2:直接解构 store 导致失去响应性
// ❌ 错误:解构 store 得到普通值
const { name, isAdmin } = useUserStore()
// name 和 isAdmin 是当前时刻的值,store 变化后不会更新
// ✅ 正确一:storeToRefs 解构
const { name, isAdmin } = storeToRefs(useUserStore())
// ✅ 正确二:保持整个 store 引用
const store = useUserStore()
// 使用 store.name, store.isAdmin(响应式)
注意:actions 可以直接解构——它们是普通函数,不需要响应性:
const { name } = storeToRefs(store) // ref,响应式
const { login, logout } = store // 普通函数,直接解构 OK
陷阱 3:在 Setup Store 中使用 $reset()
$reset() 只适用于 Options Store。Setup Store 没有初始 state 快照,无法自动重置:
// ❌ Setup Store:$reset() 会报错或不可用
export const useMyStore = defineStore('my', () => {
const count = ref(0)
return { count }
})
const store = useMyStore()
store.$reset() // TypeError: store.$reset is not a function
// ✅ 在 Setup Store 中手动实现 reset
export const useMyStore = defineStore('my', () => {
const count = ref(0)
function reset() {
count.value = 0 // 手动重置到初始值
}
return { count, reset }
})
陷阱 4:store 中的 this 在 Options Store 中指向 store proxy
export const useUserStore = defineStore('user', {
state: () => ({ name: '' }),
actions: {
async fetchAndUpdate() {
const data = await api.getUser()
this.name = data.name // this 指向 store proxy,OK
},
// ❌ 箭头函数:this 是 undefined 或外部 this
arrowAction: async () => {
this.name = 'test' // TypeError: Cannot read property 'name' of undefined
}
}
})
Options Store 的 actions 必须使用普通函数(不能是箭头函数),以确保 this 正确绑定到 store 实例。
陷阱 5:跨 store 引用与循环依赖
// store A 引用 store B
export const useCartStore = defineStore('cart', {
getters: {
// ✅ 在 getter 内部使用另一个 store:可以,但需要注意循环依赖
total: (state) => {
const priceStore = usePriceStore() // 在 getter 函数内调用
return state.items.reduce((sum, item) => {
return sum + item.quantity * priceStore.getPrice(item.id)
}, 0)
}
}
})
// ❌ 循环依赖:A 引用 B,B 引用 A
// 运行时不会崩溃(因为是在函数内延迟调用),但逻辑难以维护
陷阱 6:pinia.state 不是响应式深层属性的容器
pinia.state 是对所有 store state 的 ref 封装,但直接访问 pinia.state.value 会绕过 store 的封装:
// ❌ 直接修改 pinia.state 绕过所有追踪
pinia.state.value.user.name = 'hacked'
// 这会修改值,但不会触发 $subscribe 或 devtools 记录
// ✅ 始终通过 store 对象修改
const store = useUserStore()
store.name = 'Alice' // 通过 proxy,有追踪
store.$patch({ name: 'Alice' }) // 批量更新
章节小结
-
Pinia 无 mutation 的设计哲学:Vue 3 响应式系统在底层追踪所有状态变化,Pinia 通过
$patch和$subscribe给 devtools 提供拦截点,无需强制要求 mutation 仪式。直接赋值(store.name = 'Alice')与$patch在功能上等价,区别只是 devtools 的可观察性。 -
单例机制通过 Map 实现:
pinia._s(Map<id, store>)确保同一 id 的 store 只创建一次。每次调用useStore()时先查 Map,命中则直接返回,不命中则创建并缓存。 -
effectScope 是 store 生命周期的容器:每个 store 的响应式副作用(computed、watch)都注册在一个 detached effectScope 中。Store 的生命周期独立于使用它的组件——组件卸载不会影响 store,只有
store.$dispose()才销毁。 -
storeToRefs 只转换 state/getters,跳过 actions:
storeToRefs遍历 store,对 ref 和 reactive 值使用toRef创建代理引用,对普通函数(actions)跳过。这使解构后的 state/getters 保持响应性,而 actions 可直接解构使用。 -
Options Store 内部被转换为 Setup Store:Pinia 的核心是
createSetupStore;Options Store 是在其上的一层语法糖,将state/getters/actions转换成 setup 函数的等价形式。理解 Setup Store 就理解了整个 Pinia 的运行机制。