Pinia 源码解析:响应式状态管理是如何工作的
痛点:为什么你的状态管理总是"差一点"
三年前的一个深夜,同事在群里发了一张截图------线上交易页面上用户余额显示延迟了 2 秒才更新。"看到余额扣了,但页面没变,我多点了几下支付按钮..."当天晚上我们紧急回滚了用 reactive 包裹普通对象的临时方案。
这可不是个例。在复杂应用中,状态共享、响应式追踪、模块拆分这三个问题总有一个会咬你。Vuex 太重、Composition API 太底层,直到 Pinia 出现,它用不到 3KB 的体积解决了这一切------defineStore 一个方法就搞定了类型推导、DevTools 集成和 SSR 安全。
我花了两个周末把 Pinia v2 的核心源码通读了一遍。本文不会逐行翻译代码,而是聚焦一个问题:从 defineStore 一行调用到组件重新渲染,中间到底发生了什么?
一、Pinia 的核心架构:一张图看懂
在阅读源码前,先建立一个宏观认知。
Pinia 的代码结构非常扁平,核心只涉及几个关键模块:
| 模块 | 文件 | 职责 |
|---|---|---|
| rootStore | rootStore.ts |
全局单例,管理所有 store 实例 |
| store | store.ts |
单个 store 的创建和代理逻辑 |
| idMapping | 内建 Map | $id → store 的映射表,用于跨组件复用 |
| subscriptions | 内建数组 | 插件和 $subscribe 的回调队列 |
| piniaSymbol | rootStore.ts |
inject/provide 的唯一标识符 |
整个流程可以抽象成三个阶段:
- 注册阶段 :
createPinia()创建全局实例,通过app.use(pinia)注入到 Vue 应用中 - 定义阶段 :
defineStore(id, options)返回一个工厂函数(useStore),这一步只是定义,不创建实例 - 实例化阶段 :组件中调用
useStore(),Pinia 检查缓存 → 未命中则创建 store → 注入响应式 → 返回代理对象
下面这段代码模拟了 Pinia 最核心的实例化流程,帮你建立直觉:
typescript
// Pinia 内部 store 实例化的简化模型
const storeMap = new Map<string, any>(); // 全局 store 缓存
function createStore(id: string, setup: () => Record<string, unknown>) {
// 缓存命中直接返回,这是 SSR 安全的关键
if (storeMap.has(id)) return storeMap.get(id);
// 执行 setup 函数,拿到原始状态
const rawState = setup();
// 为每个属性创建独立的 ref(而不是一个大 reactive)
// 这样做的好处:解构后依然保持响应式
const state = Object.keys(rawState).reduce((acc, key) => {
acc[key] = ref(rawState[key]);
return acc;
}, {} as Record<string, Ref>);
// 核心:reactive + effectScope,确保自动回收
const scope = effectScope();
scope.run(() => {
// 每个 store 内的 computed/watch 都挂在独立 scope 下
});
// 包装成代理对象,拦截 .$patch .$reset 等方法
const store = new Proxy(state, {
get(target, key, receiver) {
// $id / $state / $patch / $subscribe 等内建属性
if (key === '$id') return id;
if (key === '$state') return toRaw(target);
if (key === '$patch') return patchFn;
// 普通属性 → 自动 unwrap ref
const value = Reflect.get(target, key, receiver);
return isRef(value) ? value.value : value;
},
set(target, key, value, receiver) {
// 直接赋值也是响应式的
const existing = target[key];
if (isRef(existing) && !isRef(value)) {
existing.value = value;
return true;
}
return Reflect.set(target, key, value, receiver);
},
});
storeMap.set(id, store);
return store;
}
关键设计 :每个状态字段是独立的
ref,而非一个大reactive对象。这意味着storeToRefs(useCounterStore())返回的是原生 ref,自然支持解构且不失响应式------这不是什么魔法,只是数据结构选型正确而已。
二、defineStore 的两种面孔:Options Store vs Setup Store
Pinia 最巧妙的设计之一是 defineStore 同时支持两种写法,而且内部统一转换为 Setup Store 处理。
Options Store 写法
typescript
// 我们熟悉的写法
export const useCounterStore = defineStore('counter', {
state: () => ({ count: 0 }),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() { this.count++ },
},
});
Setup Store 写法
typescript
// 自由度更高的写法
export const useCounterStore = defineStore('counter', () => {
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
function increment() { count.value++; }
return { count, doubleCount, increment };
});
Pinia 内部做了什么? 打开源码(src/store.ts),Options Store 会在创建前被转换:
typescript
// Pinia v2.1.x 源码关键逻辑(简化)
function createSetupStore(id, setup, options, pinia) {
// 如果是 Options Store,先包装成 Setup Store
if (isOptionsStore) {
setup = () => {
// 将 options state 转为 refs
const localState = toRefs(reactive(options.state?.()));
// getters → computed
const getters = Object.entries(options.getters || {})
.reduce((acc, [key, fn]) => {
acc[key] = computed(() => fn.call(store, store));
return acc;
}, {});
// actions 保持原样,但绑定 this
const actions = options.actions || {};
return { ...localState, ...getters, ...actions };
};
}
// 后续统一走 Setup Store 流程
// ...创建 scope, 挂载 state, 应用插件
}
这意味着什么? 你可以在一个 store 里混用两种风格的需求:需要类型推导就用 Setup Store,需要简单直观就用 Options Store------它们本质上是同一个东西的不同入口。
三、响应式系统的三根支柱:effectScope、toRefs 与 $patch
理解了架构之后,我们来拆解三个让 Pinia 响应式"看起来好用"的关键实现。
3.1 effectScope:让 store 拥有生命周期
这是 Pinia 最容易被忽略、却最重要的设计。每个 store 创建时会调用 Vue 3 的 effectScope(),所有 computed、watch、watchEffect 都挂在这个 scope 下。
typescript
// Pinia 源码中 effectScope 的使用
const scope = effectScope(true);
scope.run(() => {
for (const key in setupStore) {
const prop = setupStore[key];
if (isRef(prop) && !isComputed(prop)) {
// ref 按原样挂载
pinia.state.value[id][key] = prop;
} else if (isComputed(prop)) {
// computed 也挂在这个 scope 下
pinia.state.value[id][key] = prop;
}
}
});
为什么需要独立 scope? Vue 3 的 effectScope 提供了一个"批量回收"机制。当 $dispose() 被调用时,store 内的所有响应式副作用一次性清理------不需要手动 unwatch,不会内存泄漏。这是 Vuex 做不到的。
3.2 toRefs:解构不失响应式的秘密
typescript
// 你写下的代码
const { count, doubleCount } = storeToRefs(useCounterStore());
背后就是 toRefs:
typescript
// storeToRefs 的源码本质
function storeToRefs(store) {
const result = {};
for (const key in store.$state) {
result[key] = toRef(store, key); // 创建指向 store 属性的 ref
}
// getters 也转为只读 ref
return result;
}
因为 Pinia 内部 state 的所有属性本身就是 ref,toRefs 只是创建了指向同一个值的引用,不是深拷贝。这也解释了为什么 storeToRefs 只能解构 state 和 getters,不能解构 actions------actions 不是 ref。
3.3 $patch:批量更新与 DevTools 的桥梁
$patch 是 Pinia 中一个容易被低估的 API。它的实现揭示了一个重要的设计:每一次状态变更有且仅有一个入口。
typescript
// $patch 的简化实现
function $patch(partialStateOrMutator) {
let subscriptionMutation: SubscriptionCallbackMutation;
if (typeof partialStateOrMutator === 'function') {
// 函数式 patch:在同一个 tick 内完成所有修改
partialStateOrMutator(store);
subscriptionMutation = {
type: MutationType.patchFunction,
storeId: id,
events: [], // DevTools 中展示的变更记录
};
} else {
// 对象式 patch:逐字段合并
subscriptionMutation = {
type: MutationType.patchObject,
storeId: id,
payload: partialStateOrMutator,
};
// 直接赋值触发 setter → 通知订阅者
Object.assign(store, partialStateOrMutator);
}
// 通知所有 $subscribe 回调 + DevTools
triggerSubscriptions(subscriptions, subscriptionMutation, store.$state);
}
这意味着:不管你在哪里修改 state,最终都会经过同一个通知管道。DevTools 的时光旅行、
$subscribe的变更日志,都依赖这个单一入口。
四、插件的三个钩子时机:比你想象的更灵活
Pinia 的插件系统设计得非常克制------只暴露三个钩子,但足以覆盖 90% 的场景。
typescript
// 一个同时展示三个钩子的插件示例:为每个 store 注入请求锁
function requestLockPlugin({ store, pinia }) {
// ① 创建时:注入新属性
const pendingRequests = reactive(new Set<string>());
store.$pending = (key: string) => pendingRequests.has(key);
// ② 创建时:包装 actions,实现防重复请求
const originalActions: Record<string, Function> = {};
for (const key in store) {
if (typeof store[key] === 'function' && !key.startsWith('$')) {
originalActions[key] = store[key];
store[key] = async function (...args: unknown[]) {
if (pendingRequests.has(key)) {
console.warn(`[Pinia] Action "${key}" is already in flight`);
return;
}
pendingRequests.add(key);
try {
return await originalActions[key].apply(this, args);
} finally {
pendingRequests.delete(key);
}
};
}
}
// ③ 状态变化时:订阅变更做持久化
store.$subscribe((mutation, state) => {
localStorage.setItem(`store_${store.$id}`, JSON.stringify(state));
});
}
三个钩子的执行顺序:
scss
createPinia()
↓
pinia.use(pluginA) ← 注册插件(按顺序执行)
pinia.use(pluginB)
↓
app.use(pinia)
↓
useSomeStore() ← 触发 store 创建
↓
pluginA({ store, pinia }) ← 依次执行每个插件的工厂函数
pluginB({ store, pinia })
↓
store.$subscribe(cb) ← 用户的订阅回调
↓
store.$patch({...}) ← 每次 patch 触发所有订阅
五、SSR 安全:同一请求内共享 Store 实例
Pinia 的 SSR 安全不是靠黑魔法,而是靠一个精心设计的生命周期钩子。
Nuxt 3 默认使用 Pinia 做状态管理,核心就是这几行代码:
typescript
// Pinia SSR 的核心流程(简化)
import { setActivePinia, createPinia } from 'pinia';
// 每个请求创建一个新的 pinia 实例
export function createApp() {
const app = createSSRApp(App);
const pinia = createPinia();
app.use(pinia);
return { app, pinia };
}
// 服务端渲染时
// 1. createApp() → 创建独立的 pinia 实例
// 2. 渲染过程中 useStore() 创建的所有 store 挂在 pinia 下
// 3. pinia.state.value → 序列化为 JSON → 注入到 HTML
// 4. 客户端 hydrate 时,pinia.state.value = 服务端序列化的 JSON
// 5. 客户端 useStore() 时发现 state 已有值 → 跳过 setup → 直接复用
关键代码在 Pinia 源码中的 createSetupStore 函数:
typescript
// state 初始化时检查是否已有服务端数据
if (pinia.state.value[id]) {
// 服务端已有数据 → 直接合并,不重新执行 setup
mergeReactiveObjects(pinia.state.value[id], setupStore);
} else {
// 客户端首次创建 → 正常执行 setup
pinia.state.value[id] = setupStore;
}
这就是为什么在 SSR 场景下,服务端发起的 API 请求结果不会在客户端重复执行------不是缓存了请求结果,而是缓存了整个 store 的快照。
总结
通读 Pinia 源码后,我认为它成功的关键不在于哪个具体的技术选型,而在于它对"状态管理到底在管什么"这个问题有清晰的定义。
五个值得带走的关键认知:
-
每个字段都是独立 ref :这不是实现细节,而是一个架构决策------它让解构、
storeToRefs、TypeScript 类型推导全部天然成立。如果你在造轮子,先想清楚状态的粒度。 -
effectScope 是生命周期的基石:独立的 scope 让 store 销毁时自动清理所有副作用。这不是 Pinia 发明的,但它用得最彻底------每一个 store 实例都有自己的 scope。
-
** patch是唯一的写入入口∗∗:不管你怎么修改state,最终都走'patch
的通知管道。DevTools 时光旅行、$subscribe` 变更日志都依赖这个约定。在自己的项目里,也要给状态变更定义一个统一的入口。 -
插件 = 三个生命周期钩子 :store 创建时注入属性、action 包装、
$subscribe订阅变更------三个钩子覆盖了持久化、权限检查、请求锁等常见场景,没有暴露内部 API,安全且够用。 -
SSR 安全 = 设计而非补丁 :Pinia 从第一天就考虑了 SSR------
pinia.state.value是可序列化的对象树,服务端渲染后注入 HTML,客户端 hydrate 时直接复用。这不是后加的补丁,而是从 store 缓存机制到 state 结构的系统性设计。