创建日期: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 的全部行为:
- 懒创建 + 缓存:
defineStore本身不建 store,它只返回一个useStore函数;真正实例化发生在第一次useStore(),且结果按 id 缓存(pinia._s)→ 所以同一 pinia 里一个 id 只有一个实例,组件里调、路由守卫里调、工具模块里调,拿到的都是它; - 把产物织成响应式对象:state 变 ref、getters 变 computed、actions 保留为函数,最后套一个响应式代理暴露给你。
2. 两条 defineStore 其实汇到同一条河
本系列前四篇分开了 options / setup 两种写法,但底层同一条流水线(源码调用栈里两者最终都走同一个 createSetupStore):
- setup store:直接把你的 setup 返回值当“要织的对象”;
- options store:先做一次“编译”——
state()的返回值逐键变成ref,getters每个用computed包成派生,actions保留为绑定好this的函数,然后也当作一份 setup 结果去织。
这就是为什么两种写法在模板/组件里的用法完全一样:它们到后面是同一种东西。
为什么织的过程要在 effectScope 里做?一次性的作用域让“这个 store 连带它创建的依赖(computed、订阅里的 watch)”能整组创建、整组销毁——store.$dispose()、组件卸载自动清理订阅都靠它。你在 setup 函数里 onScopeDispose 也能挂上“store 销毁时执行”的钩子,原因正在于此。
3. 响应性是怎么“天然”来的
Pinia 没有自己造一套响应式,它整个站在 Vue reactivity 之上:
| 概念 | 底层是什么 |
|---|---|
| state | ref(options 由 state() 工厂产出、每个键 toRef 后代理) |
| getters | computed——所以“依赖没变就缓存、变了才重算”其实是 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 有调度时机」——全是这几个机制的推论,而不是需要死记的规则。
动手
- 打断点 /
console.log(pinia._s)看一个 store 创建前后的缓存变化,验证懒创建与单例; toRaw(store.$state)对比 setup / options 两种 store,看内部结构差异;- 试试
store.$dispose()后旧引用还能不能正常读写,体会 effectScope 的整组销毁。
自测
defineStore与useStore()各自做了什么?单例是怎么来的?- options store 和 setup store 为什么到最后是“同一条河”?
$subscribe为什么默认异步?MutationType为什么能区分三种改法?$reset为什么只清 state?setup store 怎么“重置”?- 插件为什么必须等
app.use(pinia)才执行?toBeInstalled是什么?
系列完。回顾一下你走过的路:入门 → 三件套 → 组合式与多 store → 解构·订阅·插件 → 进阶工程 → 原理。