WSの小屋

手写 Mini Pinia:100 行代码搞懂 Vue 状态管理本质

理解一个状态管理库最好的方式就是手写一个。本文用约 100 行 TypeScript 实现一个 Mini Pinia,涵盖 defineStore、响应式 state、getter、action、$patch$subscribe 等核心功能。读完本文你会对 Pinia 的内部机制有底气清晰的直觉。

目标功能

  • defineStore 支持 Setup Store 写法
  • ✅ 响应式 state(ref)和 getter(computed
  • ✅ action(普通函数,可异步)
  • $patch 批量修改
  • $subscribe 订阅 state 变化
  • ✅ 多 store 管理
  • ✅ Vue 插件 install

核心实现

import {
  ref, reactive, watch, computed, inject, effectScope,
  type Ref, type ComputedRef
} from 'vue'

// 全局 pinia 实例
let activePinia: Pinia | null = null

// store 定义函数的类型
type StoreDefinition<S, G, A> = (
  state: S,
  getters: G,
  actions: A
) => void

// Pinia 类
class Pinia {
  _s: Map<string, any> = new Map()  // store 集合
  state: Record<string, any> = reactive({})  // 全局状态对象

  use(plugin: (context: any) => void) {
    // 插件机制(简化版)
    this._plugins = this._plugins || []
    this._plugins.push(plugin)
  }

  _plugins: ((ctx: any) => void)[] = []

  install(app: any) {
    activePinia = this
    app.provide('pinia', this)
    app.config.globalProperties.$pinia = this
  }
}

function createPinia(): Pinia {
  const pinia = new Pinia()
  activePinia = pinia
  return pinia
}

// defineStore 核心实现
function defineStore<
  Id extends string,
  S extends Record<string, any>,
  G extends Record<string, any>,
  A extends Record<string, any>
>(
  id: Id,
  setup: () => S & G & A
) {
  // 返回 useStore 函数
  function useStore() {
    const pinia = activePinia || inject<Pinia>('pinia')
    if (!pinia) {
      throw new Error('[Mini Pinia] Pinia 实例未安装')
    }

    // 如果 store 已存在,直接返回(单例)
    if (pinia._s.has(id)) {
      return pinia._s.get(id)
    }

    // 创建 effectScope,确保所有响应式effect在store销毁时清理
    const scope = effectScope(true)

    let store: any

    scope.run(() => {
      // 执行 setup 函数,获取 state/getters/actions
      const setupResult = setup()

      // 遍历 setup 返回值,区分 ref/computed/普通函数
      const storeState: Record<string, Ref | ComputedRef> = {}
      const storeGetters: Record<string, ComputedRef> = {}
      const storeActions: Record<string, Function> = {}

      for (const [key, value] of Object.entries(setupResult)) {
        if (isRef(value) && !isComputed(value)) {
          storeState[key] = value as Ref
        } else if (isComputed(value)) {
          storeGetters[key] = value as ComputedRef
        } else if (typeof value === 'function') {
          storeActions[key] = value
        }
      }

      // 将 state 写入全局 pinia.state (用于 DevTools 和 $subscribe)
      pinia.state[id] = {}
      for (const [key, refValue] of Object.entries(storeState)) {
        pinia.state[id][key] = refValue.value
      }

      // 同步 state ref 和全局 state
      watch(
        () => pinia.state[id],
        (newState) => {
          for (const [key, refValue] of Object.entries(storeState)) {
            if (refValue.value !== newState[key]) {
              refValue.value = newState[key]
            }
          }
        },
        { deep: true }
      )

      // 构建代理对象
      store = reactive({})

      // state:通过 getter/setter 代理
      for (const [key, refValue] of Object.entries(storeState)) {
        Object.defineProperty(store, key, {
          get: () => refValue.value,
          set: (v: any) => { refValue.value = v },
          enumerable: true
        })
      }

      // getters:只读代理
      for (const [key, computedValue] of Object.entries(storeGetters)) {
        Object.defineProperty(store, key, {
          get: () => computedValue.value,
          enumerable: true
        })
      }

      // actions:直接挂载,绑定 this
      for (const [key, fn] of Object.entries(storeActions)) {
        store[key] = fn.bind(store)
      }

      // $patch 方法
      store.$patch = function (patch: Record<string, any> | ((state: any) => void)) {
        if (typeof patch === 'function') {
          // 函数式 patch
          patch(pinia.state[id])
          // 同步到 ref
          for (const [key, refValue] of Object.entries(storeState)) {
            refValue.value = pinia.state[id][key]
          }
        } else {
          // 对象式 patch
          for (const [key, value] of Object.entries(patch)) {
            if (key in storeState) {
              storeState[key].value = value
              pinia.state[id][key] = value
            } else {
              console.warn(`[Mini Pinia] Unknown state key: ${key}`)
            }
          }
        }
      }

      // $subscribe 方法
      store.$subscribe = function (callback: (mutation: any, state: any) => void) {
        return watch(
          () => pinia.state[id],
          (newState, oldState, onCleanup) => {
            callback(
              {
                storeId: id,
                type: 'patch',  // 简化
                payload: newState
              },
              newState
            )
          },
          { deep: true }
        )
      }

      // $reset 方法(Setup store 需调用方提供)
      store.$reset = function () {
        console.warn('[Mini Pinia] $reset needs manual implementation in setup store')
      }

      // $dispose 方法
      store.$dispose = function () {
        scope.stop()
        pinia._s.delete(id)
        delete pinia.state[id]
      }
    })

    // 执行插件
    pinia._plugins.forEach(plugin => {
      plugin({ store, pinia })
    })

    // 缓存 store
    pinia._s.set(id, store)

    return store
  }

  useStore.$id = id
  return useStore
}

辅助函数

function isRef(value: any): value is Ref {
  return value !== null && typeof value === 'object' && '__v_isRef' in value
}

function isComputed(value: any): value is ComputedRef {
  return isRef(value) && '__v_isReadonly' in value
}

导出 API

export { createPinia, defineStore, setActivePinia }

function setActivePinia(pinia: Pinia) {
  activePinia = pinia
}

实际使用

import { ref, computed } from 'vue'
import { createPinia, defineStore } from './mini-pinia'

// 定义 store
const useCounterStore = defineStore('counter', () => {
  // state
  const count = ref(0)
  const name = ref('Counter')

  // getters
  const double = computed(() => count.value * 2)
  const quadruple = computed(() => double.value * 2)

  // actions
  function increment() {
    count.value++
  }

  function incrementBy(n: number) {
    count.value += n
  }

  async function incrementAsync() {
    await new Promise(resolve => setTimeout(resolve, 100))
    count.value++
  }

  function $reset() {
    count.value = 0
    name.value = 'Counter'
  }

  return { count, name, double, quadruple, increment, incrementBy, incrementAsync, $reset }
})

// 跨 store 调用
const useLoggerStore = defineStore('logger', () => {
  const logs = ref<string[]>([])

  function log(message: string) {
    logs.value.push(`[${new Date().toISOString()}] ${message}`)
  }

  function logCount() {
    const counter = useCounterStore()
    log(`Current count: ${counter.count}`)
  }

  return { logs, log, logCount }
})

// 应用安装
import { createApp } from 'vue'
const app = createApp({ /* ... */ })
const pinia = createPinia()
app.use(pinia)

// 在组件中使用
const counterStore = useCounterStore()
const loggerStore = useLoggerStore()

// 读取 state
console.log(counterStore.count)       // 0
console.log(counterStore.double)       // 0

// 修改 state
counterStore.increment()
console.log(counterStore.count)       // 1
counterStore.count = 10                // 直接修改
console.log(counterStore.count)       // 10

// $patch 批量修改
counterStore.$patch({
  count: 100,
  name: 'MyCounter'
})
counterStore.$patch((state) => {
  state.count += 50
})
console.log(counterStore.count)       // 150

// 订阅变化
const unsubscribe = counterStore.$subscribe((mutation, state) => {
  console.log(`[${mutation.storeId}] State changed:`, state)
})
counterStore.increment()  // 触发订阅
unsubscribe()              // 取消订阅

// 跨 store 调用
loggerStore.logCount()    // [logger] Current count: 150

在组件中使用

<script setup lang="ts">
import { useCounterStore } from '@/stores/counter'
import { storeToRefs } from './mini-pinia'

const store = useCounterStore()

// 保持响应性解构
const { count, double } = storeToRefs(store)
// actions 直接解构
const { increment } = store
</script>

<template>
  <div>
    <p>Count: {{ count }}</p>
    <p>Double: {{ double }}</p>
    <button @click="increment">+1</button>
  </div>
</template>
// storeToRefs 实现
import { toRefs, isRef, isReactive } from 'vue'

function storeToRefs(store: any) {
  const refs: Record<string, any> = {}

  for (const key in store) {
    const value = store[key]
    if (isRef(value) || isReactive(value)) {
      refs[key] = toRef(store, key)
    } else {
      // computed 也通过 toRef 暴露
      refs[key] = toRef(store, key)
    }
  }

  return refs
}

与真实 Pinia 的对比

功能 Mini Pinia 真实 Pinia
defineStore (setup)
defineStore (options)
State 响应式 ✅ ref ✅ ref/reactive
Getters ✅ computed ✅ computed
Actions
$patch
$subscribe ✅ + events
$onAction
$reset ⚠️ 手动 ✅ 自动 (options store)
$dispose
插件系统 ⚠️ 简化版
SSR 支持
DevTools
storeToRefs
HMR 热更新

Pinia 核心设计思想

通过手写我们可以看到 Pinia 的几个关键设计:

1. effectScope:作用域隔离

Pinia 使用 effectScope 来创建 store 的响应式作用域。当 store 被销毁($dispose)时,scope 停止,所有内部的 watchcomputed 自动清理,防止内存泄漏。

const scope = effectScope(true)
scope.run(() => {
  // 所有 ref/computed/watch 都在这个 scope 内
  const count = ref(0)
  const double = computed(() => count.value * 2)
  watch(count, () => { /* ... */ })
})

// 销毁时自动清理
scope.stop()

2. 单例模式 + 全局注册

每个 store 只创建一次,缓存在 pinia._s Map 中。后续调用 useStore() 返回的是同一个实例。

3. 代理模式暴露 API

store 对象本身是一个 reactive 代理,通过 Object.defineProperty 将 ref/computed/function 统一暴露为 store.xxx。用户不需要关心 .value,直接用 store.count 访问。

这一点非常聪明——它让 store 的使用体验跟普通对象无异,同时保持了内部的响应式追踪。

总结

100 行代码实现的核心逻辑:

  1. createPinia:创建全局实例,管理所有 store
  2. defineStore:返回一个 useStore 函数,首次调用时执行 setup
  3. effectScope:隔离响应式作用域,支持销毁清理
  4. 代理对象:统一暴露 ref/getter/action 为 store.xxx
  5. $patch/$subscribe:提供工具方法

理解了这个骨架,再去看 Pinia 源码,就会发现核心就这么简单。Pinia 的优秀之处在于「用最简单的概念组合出最实用的 API」——这正是好的状态管理库该有的样子。

Comments | 0条评论