Pinia(Vuex5.0 ?) 源码分析(一)

4,808 阅读5分钟

前言

最近翻看vue的rfcs提案时,忽然看到vuex5.0的提案,看到社区也有很多的探索讲解,于是我想给大家来点干货,顺便记录下我学习pinia的过程。

image.png 5.0的提案非常具有新鲜感,对比vuex4具有很大的改进

  • 支持options api and composition api
  • 没有mutations
  • 没有嵌套的模块
  • 更好typescript支持
  • 自动化的代码差分

于是我fork的一份代码,为了充分的理解pinia的流程,我在examples文件夹下使用webpack搭建了一个本地服务进行代码调试,欢迎大家clonedebug

备注:pinia version: 2.0

官网:pinia.esm.dev/

我的git地址:github.com/chris-zhu/l…

以下直接进行源码拆分讲解,api部分参考官网

入口 createPinia

假设你阅读或使用过pinia,我们知道pinia的入口是通过createPinia创建的Pinia实例,当我们在vue中使用时,使用app.use(pinia)加载pinia

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

const pinia = createPinia()
const app = createApp(App).use(pinia)
app.mount('#app')

看下源码实现

export function createPinia(): Pinia {
  const state = ref({})
  let localApp: App | undefined
  let _p: Pinia['_p'] = []
  const toBeInstalled: PiniaStorePlugin[] = []

  // 这是当前的pinia实例
  const pinia: Pinia = markRaw({
    install(app: App) { // pinia 通过vue的插件机制,暴露对外的install方法
      pinia._a = localApp = app
      app.provide(piniaSymbol, pinia) // 通过provide提供pinia实例,供后续使用
      app.config.globalProperties.$pinia = pinia // 暴露全局属性 $pinia
      if (IS_CLIENT) {
        setActivePinia(pinia) // 设置当前活跃的 pinia
      }
      toBeInstalled.forEach((plugin) => _p.push(plugin)) // 加载pinia插件
    },

    use(plugin) { // 这是pinia暴露的插件用法
      if (!localApp) {
        toBeInstalled.push(plugin) // 将插件存入[],待初始化的时候使用
      } else {
        _p.push(plugin)
      }
      return this
    },

    _p,
  
    _a: localApp!, // app 实例

    state, // 所有状态
  })

  return pinia
}

详细注释已标明

可以看到 pinia 实例拥有state = ref({}) 这其实是所有的state的集合,后面会在init的时候,将其他模块的state挂载到pinia

其实pinia也更好的集成了 vue devtools

if (__DEV__ && IS_CLIENT) {
    pinia.use(devtoolsPlugin)
}

image.png

定义store defineStore

我们回顾一下 defineStore Api,可以看到,使用defineStore需要传入一个options配置,定义每一个store

import { defineStore } from 'pinia'

// useStore could be anything like useUser, useCart
export const useStore = defineStore({
  // unique id of the store across your application
  id: 'storeId',
})

我们来看下源码是如何写的呢?

image.png

可以清晰的看到,defineStore 简单的返回定义好的useStore,并标记唯一$id

我们看看内部的useStore是如何处理传入的options

个人见解:我将useStore分为4部分处理,下面逐一讲解

初始化形参pinia

粘贴部分代码讲解

const currentInstance = getCurrentInstance()
const shouldProvide = currentInstance && !pinia

pinia =
  (__TEST__ && activePinia && activePinia._testing ? null : pinia) ||
  (currentInstance && inject(piniaSymbol))
if (shouldProvide) {
    provide(storeAndDescriptor[2], store)
}

首先通过vuegetCurrentInstance拿到当前的vue实例,并判断形参的pinia是否存在,以后须判断是否需要向children提供当前的store

这里提前讲有点懵,可以先略过,稍后再回顾

pinia 如果没有则会通过inject获取,因为在 app.use的时候,install方法内已经提供了

设置activePinia

if (pinia) setActivePinia(pinia)
pinia = getActivePinia()

主要是设置当前活跃的是哪个pinia实例,当有多个pinia实例时,方便获取当前活跃的pinia实例

export let activePinia: Pinia | undefined

export const setActivePinia = (pinia: Pinia | undefined) =>
  (activePinia = pinia)

export const getActivePinia = () => {
  if (__DEV__ && !activePinia) {
    warn(
      `[🍍]: getActivePinia was called with no active Pinia. Did you forget to install pinia?\n\n` +
        `const pinia = createPinia()\n` +
        `app.use(pinia)\n\n` +
        `This will fail in production.`
    )
  }

  return activePinia!
}


添加store缓存

export const storesMap = new WeakMap<
  Pinia,
  Map<
    string,
    [
      StoreWithState<string, StateTree>,
      StateDescriptor<StateTree>,
      InjectionKey<Store>
    ]
  >
>()

首先会导入一个storesMap,它的数据结构为WeakMapkey时一个pinia实例,value是一个Map结构

let storeCache = storesMap.get(pinia)
if (!storeCache) storesMap.set(pinia, (storeCache = new Map()))

先通过pinia作为keystore的缓存,如果缓存不存在,那么便设置一个新的Map

let storeAndDescriptor = storeCache.get(id)

let store: Store<Id, S, G, A>

if (!storeAndDescriptor) {
  // 下面传入的参数:{options.id, options.state, 还记得pinia实例的state吗shi?是个Ref对象} 
  storeAndDescriptor = initStore(id, state, pinia.state.value[id])
  storeCache.set(id, storeAndDescriptor)
  
  ...
} else {
  ...
}

可以清晰看到,storeCahe通过id获取store的缓存与store的一些描述符(storeAndDescriptor

当我们没有获取到storeCahe时,会进行initStore的操作,并且可以看出initStore的返回结果,就是我们想要的storeAndDescriptor,并重新添加到缓存里面

initStore

先看看 initStore 的参数与返回值

function initStore<
  Id extends string,
  S extends StateTree,
  G extends GettersTree<S>,
  A /* extends ActionsTree */
>(
  $id: Id, // 每一个模块的id
  buildState: () => S = () => ({} as S), // 模块的state
  initialState?: S | undefined // pinia实例下`{id}`的状态
): [
    StoreWithState<Id, S, G, A>,
    { get: () => S; set: (newValue: S) => void },
    InjectionKey<Store>
  ]{
  ... someCode
  }

形参在注释中标注,而我们可以看到返回值这一块,它将返回一个数组格式,

  • StoreWithState这是交给外部使用的store实例
  • 第二位其实是state的属性描述符
  • 这是一个需要provide提供的InjectionKey

然后看程序主体这一块

const pinia = getActivePinia()
pinia.state.value[$id] = initialState || buildState()

拿到当前活跃的pinia, 将模块的state通过id挂在到pinia.state下面

image.png

后面是定义了一些变量,有我们经常使用的patch函数,然后是一些依赖的收集与触发,我们留到下一章再讲

const storeWithState: StoreWithState<Id, S, G, A> = {
    $id,
    _p: pinia,
    _as: actionSubscriptions as unknown as StoreOnActionListener[],

    // $state is added underneath

    $patch,
    $subscribe,
    $onAction,
    $reset,
  } as StoreWithState<Id, S, G, A>

const injectionSymbol = __DEV__
    ? Symbol(`PiniaStore(${$id})`)
    : /* istanbul ignore next */
    Symbol()

我们可以看到storeWithState的完整形态,它包含了一些属性与方法暴露给外部使用

而我们的injectionSymbol是一个包含$idSymbol类型

return [
   storeWithState,
   {
     get: () => pinia.state.value[$id] as S,
     set: (newState: S) => {
       isListening = false
       pinia.state.value[$id] = newState
       isListening = true
     },
   },
   injectionSymbol,
 ]

值得注意的是,数组的第二位是我们descriptor,主要是对state的获取与设置,因为我们可以通过在pinia实例上通过id拿到模块的state

最后返回用数组包装的数据。initStore结束

buildStoreToUse

回到defineStore的过程,当我们initStore结束,拿到storeAndDescriptor,会进行一个设置缓存的动作(上面有提到)

那么store到底是什么数据格式呢,其实还是要通过buildStoreToUse包装一下

store = buildStoreToUse<
    Id,
    S,
    G,
    // @ts-expect-error: A without extends
    A
  >(
    storeAndDescriptor[0], // storeWithState
    storeAndDescriptor[1], // descriptor
    id, // options.id
    getters, // options.getters
    actions, // options.actions
    options
  )

那我们来看看是如何包装的把

getters

首先拿到当前活跃的pinia实例

const pinia = getActivePinia()

const computedGetters: StoreWithGetters<G> = {} as StoreWithGetters<G>
for (const getterName in getters) {
    computedGetters[getterName] = computed(() => {
      setActivePinia(pinia)
      return getters[getterName].call(store, store)
    }) as StoreWithGetters<G>[typeof getterName]
}

可以看到,使用了for in循环处理我们的配置的getters,同时,getterskey缓存到了computedGetters里面,并且使用computed包裹,实现了真正的计算属性。对getters通过call绑定thisstore,并传入storegetters

actions

const wrappedActions: StoreWithActions<A> = {} as StoreWithActions<A>
for (const actionName in actions) {
    wrappedActions[actionName] = function (this: Store<Id, S, G, A>) {
      setActivePinia(pinia)
      const args = Array.from(arguments) as Parameters<A[typeof actionName]>
      const localStore = this || store // 兼容箭头函数处理

      let afterCallback: (
        resolvedReturn: UnwrapPromise<ReturnType<A[typeof actionName]>>
      ) => void = noop
      let onErrorCallback: (error: unknown) => void = noop
      function after(callback: typeof afterCallback) {
        afterCallback = callback
      }
      function onError(callback: typeof onErrorCallback) {
        onErrorCallback = callback
      }

      partialStore._as.forEach((callback) => {
        callback({ args, name: actionName, store: localStore, after, onError })
      })

      let ret: ReturnType<A[typeof actionName]>
      try {
        ret = actions[actionName].apply(localStore, args as unknown as any[])
        Promise.resolve(ret).then(afterCallback).catch(onErrorCallback)
      } catch (error) {
        onErrorCallback(error)
        throw error
      }

      return ret
    } as StoreWithActions<A>[typeof actionName]
}

同样看到使用for in循环处理我们的actionsactionskey处理到了wrappedActions里面, 当我们触发action时,首先会设置最新的pinia实例。定义了一个localStore,并对其做了一个兼容处理,当时action为箭头函数时,localStore会指向store

partialStore._as.forEach((callback) => {
    callback({ args, name: actionName, store: localStore, after, onError })
})

然后action的触发会对收集到的依赖进行发布

let ret: ReturnType<A[typeof actionName]>
try {
    ret = actions[actionName].apply(localStore, args as unknown as any[])
    Promise.resolve(ret).then(afterCallback).catch(onErrorCallback)
} catch (error) {
    onErrorCallback(error)
    throw error
}
return ret

可以看到action的触发通过apply的方式传参,绑定thislocalStore,同时它讲arguments数组化作为payload传入第二个参数,这意味着,在我们的actions中,可以有多个payload进行传递

我们actions执行的结果保存到ret中,再用promise包裹一层,最后我们返回原始的ret值。其实在这里我们可以看到,Pinia实现了将actions同步和异步的共同处理。对比vuexactions是处理异步任务的配置项,返回结果用promise包装。而Pinia则是直接返回actions的返回值,通过promise进行事件循环的微任务执行,达到异步的处理。

store

const store: Store<Id, S, G, A> = reactive(
    assign(
      __DEV__ && IS_CLIENT
        ? // devtools custom properties
        {
          _customProperties: markRaw(new Set<string>()),
        }
        : {},
      partialStore,
      // using this means no new properties can be added as state
      computedFromState(pinia.state, $id),
      computedGetters,
      wrappedActions
    )
    ) as Store<Id, S, G, A>

然后处理我们的store,其实是使用了Object.assign进行混合

其实我们已经理解到storestategetters都是用computed进行包装。使得我们可以直接对state进行直接的修改,对比vuexmutations修改,操作上也是简化了不少

Object.defineProperty(store, '$state', descriptor)

然后给store$state添加我们传过来的属性描述符

最后返回storebuildStoreToUse结束。

 if (shouldProvide) {
    provide(storeAndDescriptor[2], store)
 }

回到最初的shouldProvide,它决定是否允许child重用这个store以避免创建一个新的store

总结

第一章,简单介绍了入门篇章,后面将持续讲解,不定期更新。

以上及以下是我对Pinia的个人理解,如果不足或失误,请指正。

pinia_1.png