Vue 3 设计与实现

第 15 章 状态管理:Pinia 内核

作者 杨艺韬 · 10,845 字

第 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、refcomputed——过了几个月,Setup Store 的写法看起来就不再陌生,这时候再迁移就水到渠成。迁移不是一次性跳跃,是一段旅程——Pinia 为这段旅程铺好了路。

15.2.3 Options vs Setup Store 选择建议

维度Options StoreSetup 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 StoreSetup 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)或 reactivestate(参与序列化和 DevTools)
  • functionaction(被 wrapAction 包装以支持 $onAction)
  • computedgetter(不做特殊处理,computed 本身就是响应式的)

这种”通过运行时类型自动推断语义”的设计很优雅——开发者不需要显式区分 state/getter/action,Pinia 自己看类型就知道。

“看类型就知道”背后的哲学是什么?——是”让结构本身承载语义”。Options Store 时代,用户必须把状态手动分类填进三个 key:stategettersactions。这是一种”声明式分类”——你写在哪里,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.logtry/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() 包裹,这意味着:

  1. 解构不丢失响应性(对于 reactive 对象的属性,但 ref 类型的 state 解构后仍需 .value
  2. 模板中直接使用无需 .value
  3. 所有方法的 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_batchedUpdatesflushSync 也在做同一件事。Pinia 的 $patch 把这个思路延续到了订阅层:如果一个订阅者只关心”变完了什么样”,就没必要让它对中间状态做反应。

这里有一个非常值得品味的小细节:nextTick().then(() => { if (activeListener === myListenerId) { isListening = true } })——为什么恢复监听要放到 nextTick 里,还要用 Symbol 做令牌比较?**因为 patch可能在一帧里被连续调用多次。如果第二次patch 可能在一帧里被连续调用多次**。如果第二次 patch 发生在第一次 patchnextTick还没到时,两次patch 的 nextTick 还没到时,两次 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 的所有属性,只保留 refreactive 类型(即 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 映射关系

VuexPinia
Vuex.Store({ modules })多个 defineStore
statestate(Options)或 ref(Setup)
mutations删除,并入 actions
actionsactions
gettersgetters
commit('mut', payload)store.mut(payload)$patch
dispatch('action', payload)store.action(payload)
mapState, mapGettersstoreToRefs(store)

15.10.2 迁移步骤

  1. 同时并存createPinia()new Vuex.Store() 可以共存,允许分模块渐进迁移
  2. 一个模块一个 Store:把 Vuex 的每个 module 转为一个 Pinia store
  3. 先转 Options Store:心智压力小
  4. 合并 mutation 到 action:把每个 mutation 变成 action 里的一行代码
  5. 观察组件mapState 换成 storeToRefsmapActions 换成解构
  6. 全部迁移完成后拆掉 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 使用水平已经超过大多数教程写手。

反模式问题正确做法
每个组件一个 StoreStore 数量爆炸按业务域组织 store
Store 里塞 UI 状态(弹窗开关等)污染全局组件本地 ref/reactive
Store 互相循环依赖(顶层调用)初始化 hangaction/getter 内延迟调用
直接改 store.$state 绕过 action失去可追溯性用 action 或 $patch
Setup Store 里没 return state外部拿不到必须 return 所有想暴露的
SSR 时序列化函数/MapJSON 丢数据只序列化 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)

这个决定有深远影响:

  1. 模板友好{{ store.count }} 无需 .value
  2. 自动追踪:在 computed/watch 中访问 store.count 自动建立依赖
  3. 解构需谨慎const { count } = store 会丢失响应性(因此需要 storeToRefs

15.13 小结

Pinia 的内核设计可以浓缩为一句话:用最少的抽象层,最大限度地利用 Vue 已有的响应系统

机制实现设计理念
状态容器effectScope + ref({})复用 Vue 响应系统
Store 单例Map + 延迟创建按需初始化,零浪费
Options → SetuptoRefs + computed 转换统一内核,两种语法
批量更新$patch 暂停/恢复监听减少不必要的触发
Action 增强wrapAction + AOP 钩子可观察的状态变更
插件系统创建时遍历调用每个 Store 独立扩展
SSRstate 序列化/反序列化利用全局 state 树

Pinia 证明了一个道理:好的库不是添加新概念,而是将已有概念组合到极致。它没有发明新的响应式原语,没有引入新的调度机制,只是将 refreactivecomputedeffectScopeprovide/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.tspackages/pinia/src/store.ts:这两个文件加起来不到 1000 行,是本章所有讨论的原始材料。如果你只看一份源码作为 Vue 3 风格的代表,我强烈推荐 Pinia——它比 Vue 核心更容易完整读完,而且处处体现”借响应式系统的台子”的美感。
  • Pinia 官方文档 What is a StoreCore 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 把导航状态注入到组件树中。如果本章你读得舒服,下一章会让你感到”模式已熟,细节是飞跃”。

思考题

  1. 为什么 Pinia 选择 effectScope(true)(detached)而不是普通的 effectScope?如果使用非 detached 的 scope,会发生什么?

  2. storeToRefs 返回的 ref 与直接在 Store 中定义的 ref 是同一个对象吗?修改 storeToRefs 返回的值会影响 Store 吗?请设计实验验证。

  3. 在一个大型应用中,有 50 个 Store,但首屏只用到其中 5 个。Pinia 的延迟初始化策略如何帮助优化首屏性能?如果改为”启动时全部初始化”,会有多大的性能差异?

  4. 设计一个 Pinia 插件,实现”乐观更新”功能:action 执行前先更新 UI,如果 action 失败则自动回滚到之前的状态。