Pinia 源码解析:响应式状态管理是如何工作的

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 的唯一标识符

整个流程可以抽象成三个阶段:

  1. 注册阶段createPinia() 创建全局实例,通过 app.use(pinia) 注入到 Vue 应用中
  2. 定义阶段defineStore(id, options) 返回一个工厂函数(useStore),这一步只是定义,不创建实例
  3. 实例化阶段 :组件中调用 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(),所有 computedwatchwatchEffect 都挂在这个 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 的所有属性本身就是 reftoRefs 只是创建了指向同一个值的引用,不是深拷贝。这也解释了为什么 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 源码后,我认为它成功的关键不在于哪个具体的技术选型,而在于它对"状态管理到底在管什么"这个问题有清晰的定义

五个值得带走的关键认知:

  1. 每个字段都是独立 ref :这不是实现细节,而是一个架构决策------它让解构、storeToRefs、TypeScript 类型推导全部天然成立。如果你在造轮子,先想清楚状态的粒度。

  2. effectScope 是生命周期的基石:独立的 scope 让 store 销毁时自动清理所有副作用。这不是 Pinia 发明的,但它用得最彻底------每一个 store 实例都有自己的 scope。

  3. ** patch是唯一的写入入口∗∗:不管你怎么修改state,最终都走'patch 是唯一的写入入口**:不管你怎么修改 state,最终都走 ` patch是唯一的写入入口∗∗:不管你怎么修改state,最终都走'patch 的通知管道。DevTools 时光旅行、$subscribe` 变更日志都依赖这个约定。在自己的项目里,也要给状态变更定义一个统一的入口。

  4. 插件 = 三个生命周期钩子 :store 创建时注入属性、action 包装、$subscribe 订阅变更------三个钩子覆盖了持久化、权限检查、请求锁等常见场景,没有暴露内部 API,安全且够用。

  5. SSR 安全 = 设计而非补丁 :Pinia 从第一天就考虑了 SSR------pinia.state.value 是可序列化的对象树,服务端渲染后注入 HTML,客户端 hydrate 时直接复用。这不是后加的补丁,而是从 store 缓存机制到 state 结构的系统性设计。

相关推荐
starrysky8101 天前
拆开 Hermes Agent 的引擎盖:八大子系统、37 个模块,一张地图讲清楚——底层系列开篇
angular.js
巴勒个啦3 天前
esbuild 插件实战:5个真实场景带你自定义构建流水线
前端·angular.js
李浚泽3 天前
Angular9 NG-ZORRO 9 复选框组合最佳实践
angular.js
starrysky8105 天前
AI 助手调试踩坑:5 轮瞎猜定位 4s budget 兜底路径(含 Hindsight 反思账本使用指南)
angular.js
LiuJun2Son5 天前
Angular 快速入门:服务和依赖注入
前端·javascript·angular.js
weixin_li152********6 天前
《Angular 中优雅地处理枚举值:Map + *ngIf as 替代多次 *ngIf》
javascript·vue.js·angular.js
LiuJun2Son7 天前
Angular 快速入门:从零搭建你的第一个应用
前端·javascript·angular.js
starrysky8109 天前
Hindsight 记忆系统 recall 接口 60 秒不返回?——5 层根因诊断 + bge-m3 切换 + 9419 条数据重建 + 本地 100ms 召回完整实战
angular.js
starrysky81010 天前
你的记忆系统在腐烂:Hindsight consolidation机制解剖——从去重原理到生产配置
angular.js