跳到主要内容

创建日期:2026-09-08 | 最近更新:2026-09-08 本篇是「黑盒实测 + 源码简读」:行为都经过前几篇实测,机制说明基于 pinia 4.0.3 的 dist/pinia.js 阅读。内部实现会随版本演进,重点在于看懂机制,别死记行号。

Pinia 原理:一个 setup 函数是怎么变成一个 store 的

一句话:Pinia ≈ 一个「按 id 懒创建 + 缓存」的注册表 + 一个把 setup 返回值织成响应式对象的工厂。看懂这条流水线,前面所有 API 的「为什么」就都串起来了。

1. 全景:store 的生命周期

defineStore('cart', options) ─┐
defineStore('cart', setup) ──┤

useCartStore() 第一次调用
┌──────────────────────────┴──────────────────────────┐
│ ① 找 pinia:组件内 inject(piniaSymbol),否则取 active │
│ pinia(没有就抛错,错文案就写着“no active Pinia…”)│
│ ② pinia._s[id] 有缓存? 有 → 直接返回(★ 单例来源) │
│ ③ 没有 → 跑插件(在 effectScope 里) │
│ ④ 把 state/getters/actions 织成一个响应式对象 │
│ ⑤ 缓存进 pinia._s[id],并登记 pinia.state.value[id] │
└──────────────────────────┬──────────────────────────┘

之后每次 useCartStore() 都返回同一个实例

两件事决定了 Pinia 的全部行为

  1. 懒创建 + 缓存defineStore 本身不建 store,它只返回一个 useStore 函数;真正实例化发生在第一次 useStore(),且结果按 id 缓存(pinia._s)→ 所以同一 pinia 里一个 id 只有一个实例,组件里调、路由守卫里调、工具模块里调,拿到的都是它;
  2. 把产物织成响应式对象:state 变 ref、getters 变 computed、actions 保留为函数,最后套一个响应式代理暴露给你。

2. 两条 defineStore 其实汇到同一条河

本系列前四篇分开了 options / setup 两种写法,但底层同一条流水线(源码调用栈里两者最终都走同一个 createSetupStore):

  • setup store:直接把你的 setup 返回值当“要织的对象”;
  • options store:先做一次“编译”——state() 的返回值逐键变成 refgetters 每个用 computed 包成派生,actions 保留为绑定好 this 的函数,然后也当作一份 setup 结果去织。

这就是为什么两种写法在模板/组件里的用法完全一样:它们到后面是同一种东西。

为什么织的过程要在 effectScope 里做?一次性的作用域让“这个 store 连带它创建的依赖(computed、订阅里的 watch)”能整组创建、整组销毁——store.$dispose()、组件卸载自动清理订阅都靠它。你在 setup 函数里 onScopeDispose 也能挂上“store 销毁时执行”的钩子,原因正在于此。

3. 响应性是怎么“天然”来的

Pinia 没有自己造一套响应式,它整个站在 Vue reactivity 之上

概念底层是什么
stateref(options 由 state() 工厂产出、每个键 toRef 后代理)
getterscomputed——所以“依赖没变就缓存、变了才重算”其实是 computed 语义
模板/组件里 store.count 能读能写store 是 reactive 代理,把 $state 里的 ref 解包暴露
storeToRefs逐个属性 toRef,跳过函数(actions)
$subscribe对 state 的 watch交给 Vue 调度器——所以默认下一拍才回调、flush:'sync' 才同步(篇 3实测过)
MutationType 能区分 direct / patch$patch 不是魔法,它只是“走一条能标记类型的路去改 state”

一个黑盒角度更妙的验证:改 state 必须改“属性”而不是“整个换掉 store”——因为响应性长在 store 实例(代理)上,你把 $state 整体赋个新对象,代理并不认。$patch 对象形式之所以是浅合并,也因为它内部就是“把对象里的键逐个赋给 $state”。

4. $reset 为什么只清 state

$reset 的实现思路很朴素:state() 工厂再产一份初始 state 覆盖回去。所以:

  • options store:$reset() = “把 $state 恢复成 state() 的返回值”——state 是“可再生的”,getters/actions 本来就是函数(不存在“初始值”)→ 自然只还原 state;
  • setup store:没有 state() 工厂,$reset 语义不清 → 官方建议手动写一个 reset() action。

5. 插件为什么非要 app.use 之后才跑

源码里 createPinia() 返回的实例带 _p(插件数组)和 toBeInstalled 两处:

  • pinia.use(plugin)已经 install 过 → 直接推进 _p;没 install → 先暂存 toBeInstalled
  • app.use(pinia) 触发 install():把暂存队列倒进 _p,逐个跑,顺便装 devtools 插件。

这正是篇 3实测的:headless 里只 setActivePinia、不 app.use(pinia)pluginCalls=0。原因就是插件要等 install——install 是插件知道自己拿到 app/pinia 的唯一时机。这也是为什么别指望“纯 store 逻辑”在没装 app 的环境里自动跑插件。

6. setup store 的 $state 里装的其实是 ref

(v4 的一个实现细节,容易踩。)setup store 返回的 ref 们,进入 store 后 $state 是对它们的一个响应式封装:你 store.$state.nickname 表层访问到解包后的值(字符串),但用 toRaw 扒开能看到里面其实躺着 Ref 本体(篇 2实测)。推论:

  • 别对 $state 整体赋值(比如 $state = toRaw(...)),会破坏代理;
  • 序列化/传给别处时用 store.$state(表层)没问题;
  • 想给 setup store 造一个“初始值/重置”语义,靠手写 reset action,别指望 $reset

7. 一个类比收尾

可以把 Pinia 想成 Vue 版的“模块单例服务”

  • createPinia() ≈ 创建容器(还能挂插件);
  • defineStore(id, …) ≈ 注册一个“怎么造单例”的配方;
  • useStore()惰性取单例:第一次跑配方并缓存,之后永远返回同一个;
  • 配方内容 = Vue reactivity(ref/computed)织成的响应式对象。

于是「为什么 state 是函数」「为什么单例」「为什么组件/守卫/工具里拿到的是同一个」「为什么 $subscribe 有调度时机」——全是这几个机制的推论,而不是需要死记的规则。

动手

  1. 打断点 / console.log(pinia._s) 看一个 store 创建前后的缓存变化,验证懒创建与单例;
  2. toRaw(store.$state) 对比 setup / options 两种 store,看内部结构差异;
  3. 试试 store.$dispose() 后旧引用还能不能正常读写,体会 effectScope 的整组销毁。

自测

  1. defineStoreuseStore() 各自做了什么?单例是怎么来的?
  2. options store 和 setup store 为什么到最后是“同一条河”?
  3. $subscribe 为什么默认异步?MutationType 为什么能区分三种改法?
  4. $reset 为什么只清 state?setup store 怎么“重置”?
  5. 插件为什么必须等 app.use(pinia) 才执行?toBeInstalled 是什么?

系列完。回顾一下你走过的路:入门三件套组合式与多 store解构·订阅·插件进阶工程 → 原理。