第 23 章

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
  }
}

这个包装机制使得 $onActionafteronError 回调能够无论同步还是异步 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' }) // 批量更新

章节小结

  1. Pinia 无 mutation 的设计哲学:Vue 3 响应式系统在底层追踪所有状态变化,Pinia 通过 $patch$subscribe 给 devtools 提供拦截点,无需强制要求 mutation 仪式。直接赋值(store.name = 'Alice')与 $patch 在功能上等价,区别只是 devtools 的可观察性。

  2. 单例机制通过 Map 实现pinia._s(Map<id, store>)确保同一 id 的 store 只创建一次。每次调用 useStore() 时先查 Map,命中则直接返回,不命中则创建并缓存。

  3. effectScope 是 store 生命周期的容器:每个 store 的响应式副作用(computed、watch)都注册在一个 detached effectScope 中。Store 的生命周期独立于使用它的组件——组件卸载不会影响 store,只有 store.$dispose() 才销毁。

  4. storeToRefs 只转换 state/getters,跳过 actionsstoreToRefs 遍历 store,对 ref 和 reactive 值使用 toRef 创建代理引用,对普通函数(actions)跳过。这使解构后的 state/getters 保持响应性,而 actions 可直接解构使用。

  5. Options Store 内部被转换为 Setup Store:Pinia 的核心是 createSetupStore;Options Store 是在其上的一层语法糖,将 state/getters/actions 转换成 setup 函数的等价形式。理解 Setup Store 就理解了整个 Pinia 的运行机制。

本章评分
4.8  / 5  (6 评分)

💬 留言讨论