手写Pinia源码(下篇)- 实现Pinia

前言

上一篇文章中,我们基本了解了Pinia的使用,本篇我们来手写实现Pinia的源码。

github仓库

正文

前置知识

在实现Pinia源码时,需要对Vue3的组合式API有一定的了解:
refcomputedwatchreactiveisRefisReactivetoRefsprovideinjecteffectScope
这里,我详细说明一下effectScope:

EffectScope 就是类似一个组件的生命周期开始和结束,对应 EffectScope 的 runstop

当你在组件 setup 里使用响应式api时候,会进行依赖收集,也就是这些变量被这个组件使用了,产生的 computed 等 watcher 需要在组件销毁时也销毁。这些是自动的。
当你不在 setup 里,而在ES模块或什么地方直接使用响应式api时候,销毁这些 watcher 需要手动销毁。 为了方便,搞一个类似组件的依赖收集,也就是作用域包裹起来,直接销毁作用域,就可以销毁作用域执行期间创建的所有 watcher 了。
示例如下:

const scope = effectScope()

scope.run(() => {
  const doubled = computed(() => counter.value * 2)

  watch(doubled, () => console.log(doubled.value))

  watchEffect(() => console.log('Count: ', doubled.value))
})

// 处理掉当前作用域内的所有 effect
scope.stop()

本文仅是实现pinia的功能,用的是anyscript。仅支持vue3。

createPinia、defineStore的基本实现

工具类: utils.ts

export function isObject(val: unknown): val is Record<string, any> {
  return typeof val === 'object' && val !== null
}
export function isFunction(val: unknown) {
  return typeof val === 'function'
}
export function isString(val: unknown): val is string {
  return typeof val === 'string'
}

存放一些全局数据:rootStore.ts

export const piniaSymbol = Symbol()
// 全局的pinia实例
export let activePinia
// 设置全局的pinia实例
export const setActivePinia = pinia => (activePinia = pinia)

createPinia.ts

import { effectScope, ref } from 'vue'
import { piniaSymbol, setActivePinia } from './rootStore'

export function createPinia() {
  const scope = effectScope()
  // 存放每个store的state
  const state = scope.run(() => ref({}))
  const pinia = {
    // 存放所有的store, 以store的id作为键值
    _s: new Map(),
    install(app) {
      // 这里允许在组件外调用useStore
      setActivePinia(pinia)
      // 注入pinia实例,让所有store都可以访问到pinia
      app.provide(piniaSymbol, pinia)
    },
    state
  }
  return pinia
}

store.ts

import { setActivePinia } from 'pinia'
import { computed, effectScope, getCurrentInstance, inject, reactive, toRefs } from 'vue'
import { activePinia, piniaSymbol } from './rootStore'
import { isString, isFunction } from './utils'

export function defineStore(idOrOptions, setup) {
  let id
  let options
  if (isString(idOrOptions)) {
    id = idOrOptions
    options = setup
  } else {
    id = idOrOptions.id
    options = idOrOptions
  }
  function useStore() {
    const instance = getCurrentInstance()
    let pinia: any = instance && inject(piniaSymbol)
    if (pinia) {
      setActivePinia(pinia)
    }
    // 这里activePinia肯定不为空,因为至少在安装pinia插件时已经设置过值了
    pinia = activePinia!

    if (!pinia._s.has(id)) {
      // 第一次使用该store,则创建映射关系, Options Store
      createOptionsStore(id, options, pinia)
    }
    const store = pinia?._s.get(id)
    return store
  }

  return useStore
}

function createOptionsStore($id, options, pinia) {
  const { state, getters, actions } = options
  // store自己的scope,pinia._e是全局的scope
  let scope
  // 每个store都是一个响应式对象
  const store = reactive<any>({})

  // 对用户传入的state,getters,actions进行处理
  function setup() {
    // pinia.state是一个ref,给当前store的state赋值
    pinia.state.value[$id] = state ? state() : {}
    const localState = toRefs(pinia.state.value[$id])
    // getters
    const gettersArgs = Object.keys(getters || {}).reduce((computedGetters, name) => {
      computedGetters[name] = computed(() => {
        return getters[name].call(store, store)
      })
      return computedGetters
    }, {})
    return Object.assign(localState, actions, gettersArgs)
  }

  const setupStore = pinia._e.run(() => {
    scope = effectScope()
    return scope.run(() => setup())
  })

  function wrapAction(name, action) {
    return function () {
      const args = Array.from(arguments)

      // 确保this指向store
      return action.apply(store, args)
    }
  }

  for (let key in setupStore) {
    const prop = setupStore[key]
    if (isFunction(prop)) {
      setupStore[key] = wrapAction(key, prop)
    }
  }
  store.$id = $id

  pinia._s.set($id, store)
  Object.assign(store, setupStore)
  return store
}

defineStore实现Setup Store

Setup Store 和Options Store的区别是SetupStore是用户直接传入的。我们修改如下:
store.ts

import { setActivePinia } from 'pinia'
import { computed, effectScope, getCurrentInstance, inject, isReactive, isRef, reactive,toRefs } from 'vue'
import { activePinia, piniaSymbol } from './rootStore'
import { isString, isFunction } from './utils'
function isComputed(v) {
  return !!(isRef(v) && (v as any).effect)
}
export function defineStore(idOrOptions, setup) {
  const isSetupStore = typeof setup === 'function'
  let id
  let options
  if (isString(idOrOptions)) {
    id = idOrOptions
    options = setup
  } else {
    id = idOrOptions.id
    options = idOrOptions
  }
  function useStore() {
    const instance = getCurrentInstance()
    let pinia: any = instance && inject(piniaSymbol)
    if (pinia) {
      setActivePinia(pinia)
    }
    // 这里activePinia肯定不为空,因为至少在安装pinia插件时已经设置过值了
    pinia = activePinia!

    if (!pinia._s.has(id)) {
      // 第一次使用该store,则创建映射关系
      if (isSetupStore) {
        createSetupStore(id, options, pinia)
      } else {
        // Options Store
        createOptionsStore(id, options, pinia)
      }
    }
    const store = pinia?._s.get(id)
    return store
  }

  return useStore
}
function createSetupStore($id, setup, pinia, isOptions = false) {
  // store自己的scope,pinia._e是全局的scope
  let scope
  // 每个store都是一个响应式对象
  const store = reactive<any>({})

  // 对于setup api 没有初始化状态
  const initalState = pinia.state.value[$id]
  if (!initalState && !isOptions) {
    pinia.state.value[$id] = {}
  }
  const setupStore = pinia._e.run(() => {
    scope = effectScope()
    return scope.run(() => setup())
  })

  function wrapAction(name, action) {
    return function () {
      const args = Array.from(arguments)

      // 确保this指向store
      return action.apply(store, args)
    }
  }

  for (let key in setupStore) {
    const prop = setupStore[key]
    if (isFunction(prop)) {
      setupStore[key] = wrapAction(key, prop)
    }
    // 处理setup store,把ref、reactive放入到state中
    if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) {
      // 是ref或reactive,并且setup store
      if (!isOptions) {
        pinia.state.value[$id][key] = prop
      }
    }
  }
  store.$id = $id

  pinia._s.set($id, store)
  Object.assign(store, setupStore)
  return store
}
function createOptionsStore($id, options, pinia) {
  const { state, getters, actions } = options

  // 对用户传入的state,getters,actions进行处理
  function setup() {
    // pinia.state是一个ref,给当前store的state赋值
    pinia.state.value[$id] = state ? state() : {}
    const localState = toRefs(pinia.state.value[$id])
    // getters
    const gettersArgs = Object.keys(getters || {}).reduce((computedGetters, name) => {
      computedGetters[name] = computed(() => {
        let store = pinia._s.get($id)
        return getters[name].call(store, store)
      })
      return computedGetters
    }, {})
    return Object.assign(localState, actions, gettersArgs)
  }

  return createSetupStore($id, setup, pinia, true)
}

store.$patch

store.ts

// 合并两个对象
function mergeReactiveObject(target, state) {
  for (let key in state) {
    let oldValue = target[key]
    let newValue = state[key]
    // 都是对象,需要递归合并
    if (isObject(oldValue) && isObject(newValue)) {
      target[key] = mergeReactiveObject(oldValue, newValue)
    } else {
      target[key] = newValue
    }
  }
}
function createSetupStore($id, setup, pinia, isOptions = false) {
  // store自己的scope,pinia._e是全局的scope
  let scope
  // $patch,可能传入一个对象或函数
  function $patch(partialStateOrMutation) {
    if (isFunction(partialStateOrMutation)) {
        partialStateOrMutation(pinia.state.value[$id])
    } else {
        // 用新的对象合并原来的状态
        mergeReactiveObject(pinia.state.value[$id], partialStateOrMutation)
    }
  }
  const partialStore = {
    $patch
  }

  // 每个store都是一个响应式对象
  const store = reactive<any>(partialStore)

  // 对于setup api 没有初始化状态
  const initalState = pinia.state.value[$id]
  if (!initalState && !isOptions) {
    pinia.state.value[$id] = {}
  }
  const setupStore = pinia._e.run(() => {
    scope = effectScope()
    return scope.run(() => setup())
  })

  function wrapAction(name, action) {
    return function () {
      const args = Array.from(arguments)

      // 确保this指向store
      return action.apply(store, args)
    }
  }

  for (let key in setupStore) {
    const prop = setupStore[key]
    if (isFunction(prop)) {
      setupStore[key] = wrapAction(key, prop)
    }
    // 处理setup store,把ref、reactive放入到state中
    if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) {
      // 是ref或reactive,并且setup store
      if (!isOptions) {
        pinia.state.value[$id][key] = prop
      }
    }
  }
  store.$id = $id

  pinia._s.set($id, store)
  Object.assign(store, setupStore)
  return store
}

store.$reset

注意:store.$reset仅支持Options store

function createOptionsStore($id, options, pinia) {
  const { state, getters, actions } = options

  // 对用户传入的state,getters,actions进行处理
  function setup() {
    // pinia.state是一个ref,给当前store的state赋值
    pinia.state.value[$id] = state ? state() : {}
    const localState = toRefs(pinia.state.value[$id])
    // getters
    const gettersArgs = Object.keys(getters || {}).reduce((computedGetters, name) => {
      computedGetters[name] = computed(() => {
        let store = pinia._s.get($id)
        return getters[name].call(store, store)
      })
      return computedGetters
    }, {})
    return Object.assign(localState, actions, gettersArgs)
  }
  const store = createSetupStore($id, setup, pinia, true)
  store.$reset = function () {
    const newState = state ? state() : {}
    store.$patch(state => {
      Object.assign(state, newState)
    })
  }
  return store
}

store.$subscribe

原理就是利用watch

$subscribe(callback, options = {}) {
  scope.run(() => {
    watch(
      pinia.state.value[$id],
      state => {
        callback({ storeId: $id }, state)
      },
      options
    )
  })
}

store.$onAction

发布订阅模式
subscribe.ts

export function addSubscription(subscriptions, callback) {
  subscriptions.push(callback)

  const removeSubscription = () => {
    const idx = subscriptions.indexOf(callback)
    if (idx > -1) {
      subscriptions.splice(idx, 1)
    }
  }
  return removeSubscription
}
export function triggerSubscriptions(subscriptions, ...args) {
  subscriptions.slice().forEach(cb => cb(...args))
}

store.ts

function createSetupStore($id, setup, pinia, isOptions = false) {
  // store自己的scope,pinia._e是全局的scope
  let scope
  // $patch,可能传入一个对象或函数
  function $patch(partialStateOrMutation) {
    // 函数
    if (isFunction(partialStateOrMutation)) {
      partialStateOrMutation(pinia.state.value[$id])
    } else {
      // 用新的对象合并原来的状态
      mergeReactiveObject(pinia.state.value[$id], partialStateOrMutation)
    }
  }
  let actionSubscriptions = []
  const partialStore = {
    $patch,
    $subscribe(callback, options = {}) {
      scope.run(() => {
        watch(
          pinia.state.value[$id],
          state => {
            callback({ storeId: $id }, state)
          },
          options
        )
      })
    },
    $onAction: addSubscription.bind(null, actionSubscriptions),
    $dispose() {
      // 清除响应式
      scope.stop()
      // 清除订阅
      actionSubscriptions = []
      // 删除store
      pinia._s.delete($id)
    }
  }

  // 每个store都是一个响应式对象
  const store = reactive<any>(partialStore)

  // 对于setup api 没有初始化状态
  const initalState = pinia.state.value[$id]
  if (!initalState && !isOptions) {
    pinia.state.value[$id] = {}
  }
  const setupStore = pinia._e.run(() => {
    scope = effectScope()
    return scope.run(() => setup())
  })

  function wrapAction(name, action) {
    return function () {
      const afterCallbackList: any[] = []
      const onErrorCallckList: any[] = []
      function after(callbck) {
        afterCallbackList.push(callbck)
      }
      function onError(callbck) {
        onErrorCallckList.push(callbck)
      }
      triggerSubscriptions(actionSubscriptions, { after, onError })
      const args = Array.from(arguments)
      let ret
      try {
        // 确保this指向store
        ret = action.apply(store, args)
      } catch (e) {
        triggerSubscriptions(onErrorCallckList, e)
      }
      if (ret instanceof Promise) {
        return ret
          .then(value => {
            triggerSubscriptions(afterCallbackList, value)
            return value
          })
          .catch(e => {
            triggerSubscriptions(onErrorCallckList, e)
          })
      }
      triggerSubscriptions(afterCallbackList, ret)
      return ret
    }
  }

  for (let key in setupStore) {
    const prop = setupStore[key]
    if (isFunction(prop)) {
      setupStore[key] = wrapAction(key, prop)
    }
    // 处理setup store,把ref、reactive放入到state中
    if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) {
      // 是ref或reactive,并且setup store
      if (!isOptions) {
        pinia.state.value[$id][key] = prop
      }
    }
  }
  store.$id = $id

  pinia._s.set($id, store)
  Object.assign(store, setupStore)
  return store
}

store.$dispose

$dispose() {
    // 清除响应式
    scope.stop()
    // 清除订阅
    actionSubscriptions = []
    // 删除store
    pinia._s.delete($id)
}

store.$state

Object.defineProperty(store, '$state', {
    get: () => pinia.state.value[$id],
    set: state => {
      $patch($state => Object.assign($state, state))
    }
})

storeToRefs

将store解构时,会失去响应式,需要storeToRefs使得解构后任保持响应式
storeToRefs.ts

import { isReactive, isRef, toRaw, toRef } from 'vue'

export function storeToRefs(store) {
  store = toRaw(store)
  const refs = {}
  for (let key in store) {
    let value = store[key]
    if (isRef(value) || isReactive(value)) {
      refs[key] = toRef(store, key)
    }
  }
  return refs
}

插件

createPinia.ts

import { App, effectScope, type Ref, ref } from 'vue'
import { Pinia, piniaSymbol } from './rootStore'
import { StateTree } from './types'

export function createPinia() {
  const scope = effectScope()
  const state = scope.run<Ref<Record<string, StateTree>>>(() => ref<Record<string, StateTree>>({}))!
  const _p: any[] = []
  const pinia: Pinia = {
    use(callback) {
      _p.push(callback)
      // 可以链式调用
      return this
    },
    // 存放所有store
    _s: new Map(),
    install(app: App) {
      app.provide(piniaSymbol, pinia)
      app.config.globalProperties.$pinia = pinia
    },
    _e: scope,
    _p,
    state
  }
  return pinia
}

store.tscreateSetupStore方法

// 插件
pinia._p.forEach(extender => {
    // 将插件的返回值作为store的属性
    Object.assign(
      store,
      scope.run(() => extender({ store }))
    )
})

使用插件
main.ts

import { createApp } from 'vue'
import { createPinia } from '@/pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()
// 一个简单的持久化插件
pinia.use(({ store }) => {
  let local = localStorage.getItem(store.$id + 'PINIA_STATE')
  if (local) {
    store.$state = JSON.parse(local)
  }

  store.$subscribe(({ storeId: id }, state) => {
    localStorage.setItem(id + 'PINIA_STATE', JSON.stringify(state))
  })
})
app.use(pinia)
app.mount('#app')

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,029评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,238评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,576评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,214评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,324评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,392评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,416评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,196评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,631评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,919评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,090评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,767评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,410评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,090评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,328评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,952评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,979评论 2 351

推荐阅读更多精彩内容