Pinia 原理解读 - 相关 methods 与 api 的解析
一、Pinia 的方法、api 解析
前面两个源码解析的章节我们已经分析了 Pinia 是如何被注册引入到 Vue 项目当中以及模块化数据仓库的初始化和获取,接下来我们来解析下 Pinia 提供的方法、api的实现。
1.1 Pinia 的 api:
storeToRefs
storeToRefs
的作用就是创建一个引用对象,包含 store 的所有 state、 getter 和 plugin 添加的 state 属性。 类似于Vue.js 的toRefs
,但专门为 Pinia store 设计, 所以 method 和非响应式属性会被完全忽略。
- 其实底层也是使用
toRef
和toRefs
就能实现的一个 api 方法。
使用例子:
import { mainStore } from '../store/index'
import { storeToRefs } from 'pinia'
const store = mainStore()
const { hello: myHello } = storeToRefs(store)
const { hello: hello2 } = store
// 响应式数据 - store.state 更新后会同步到 template 上
console.log('myHello', myHello)
// 非响应式数 - store.state 更新后不会同步到 template 上
const hello2 = 'hello2 world2'
console.log('myHello2', hello2)
源码分析:
// vuejs:pinia/packages/pinia/src/storeToRefs.ts
export function storeToRefs<SS extends StoreGeneric>(
store: SS
): ToRefs<
StoreState<SS> & StoreGetters<SS> & PiniaCustomStateProperties<StoreState<SS>>
> {
// 先通过 toRaw 方法获取原始的数据仓库对象
store = toRaw(store)
// 创建一个新的对象,存储属性
const refs = {} as ToRefs<
StoreState<SS> &
StoreGetters<SS> &
PiniaCustomStateProperties<StoreState<SS>>
>
// 遍历 store 对象
for (const key in store) {
const value = store[key]
// 检测是否为 Ref 或者 Reactive 对象的响应式数据
if (isRef(value) || isReactive(value)) {
//
refs[key] = toRef(store, key)
}
}
// 返回经过对属性关联性响应式 toRef 处理后的对象
return refs
}
能够看到,其实具体的实现非常简单,主要就是三步:
- 通过
toRaw
获取 store 的原始数据(这样子对原本 store 对象进行修改也不会触发 proxy 相关的响应式监听劫持处理,是性能优化的一种方法); - 创建一个新的对象 refs 去挂载响应式的数据(state、getter);
- 遍历这个 store 数据仓库:
-
- 使用
isRef
与isReactive
判断是否是响应式的数据; - 判断为响应式的属性数据时候使用
toRef
对 store 的单个属性的响应式转换获取并设置到 refs 对象的同名 key 属性上面;
- 使用
- 返回这个 refs 对象。
映射辅助函数
Pinia 在提供 hooks 写法的 api 同时 pinia 为了兼容 option api 其实也提供了类似于 Vuex map系列的映射辅助函数给到开发者使用(因为 Pinia 中没有 mutation 概念了因此是没有 mapMutation 这个 api 了)。虽然 pinia 为了兼容 option api 的形式也是提供了这些 map 的映射方法,但是在 Vue 3.x 的 composition api 下还是多使用 hooks 的形式,不推荐使用这种 option api 的使用。
接下来我们还是来简单的分析这些映射辅助函数的源码实现。
mapState / mapGetters
// vuejs:pinia/packages/pinia/src/mapHelpers.ts
export function mapState<
Id extends string,
S extends StateTree,
G extends _GettersTree<S>,
A,
KeyMapper extends Record<
string,
keyof S | keyof G | ((store: Store<Id, S, G, A>) => any)
>
>(
useStore: StoreDefinition<Id, S, G, A>,
keyMapper: KeyMapper
): _MapStateObjectReturn<Id, S, G, A, KeyMapper>
export function mapState<
Id extends string,
S extends StateTree,
G extends _GettersTree<S>,
A,
Keys extends keyof S | keyof G
>(
useStore: StoreDefinition<Id, S, G, A>,
keys: readonly Keys[]
): _MapStateReturn<S, G, Keys>
export function mapState<
Id extends string,
S extends StateTree,
G extends _GettersTree<S>,
A
>(
useStore: StoreDefinition<Id, S, G, A>,
keysOrMapper: any
): _MapStateReturn<S, G> | _MapStateObjectReturn<Id, S, G, A> {
return Array.isArray(keysOrMapper)
? keysOrMapper.reduce((reduced, key) => {
reduced[key] = function (this: ComponentPublicInstance) {
return useStore(this.$pinia)[key]
} as () => any
return reduced
}, {} as _MapStateReturn<S, G>)
: Object.keys(keysOrMapper).reduce((reduced, key: string) => {
reduced[key] = function (this: ComponentPublicInstance) {
const store = useStore(this.$pinia)
const storeKey = keysOrMapper[key]
return typeof storeKey === 'function'
? (storeKey as (store: Store<Id, S, G, A>) => any).call(this, store)
: store[storeKey]
}
return reduced
}, {} as _MapStateObjectReturn<Id, S, G, A>)
}
// mapGetters 与 mapState 同样逻辑
export const mapGetters = mapState
mapState
通过函数重载来实现不同的调用传入参数的处理,这样子方便业务代码使用方的认知和使用负担,只需要记住一个mapState
方法即可;
能够看到其实mapState
的实现逻辑是非常简单,其实就是在 pinia 对象当中使用找到对应的模块数据仓库(如果参数当中传入对应的数据仓库则直接使用这个 store hooks),在这个数据仓库当中通过查找对应的要取出映射的 key 的对象属性 value,并且将这个值作为另外一个新对象相同 key 的 value,最后将这个承载了响应式 state 或者 getters 的新的对象返回。
我们从上面的源码当中能够看到这样一句代码mapGetters = mapState
;也就是其实mapState
和mapGetters
是同样的逻辑实现,直接复用mapState
的处理逻辑,甚至能够直接通过mapState
方法直接来获取getters
。
mapActions
// vuejs:pinia/packages/pinia/src/mapHelpers.ts
export function mapActions<
Id extends string,
S extends StateTree,
G extends _GettersTree<S>,
A,
KeyMapper extends Record<string, keyof A>
>(
useStore: StoreDefinition<Id, S, G, A>,
keyMapper: KeyMapper
): _MapActionsObjectReturn<A, KeyMapper>
export function mapActions<
Id extends string,
S extends StateTree,
G extends _GettersTree<S>,
A
>(
useStore: StoreDefinition<Id, S, G, A>,
keys: Array<keyof A>
): _MapActionsReturn<A>
export function mapActions<
Id extends string,
S extends StateTree,
G extends _GettersTree<S>,
A,
KeyMapper extends Record<string, keyof A>
>(
useStore: StoreDefinition<Id, S, G, A>,
keysOrMapper: Array<keyof A> | KeyMapper
): _MapActionsReturn<A> | _MapActionsObjectReturn<A, KeyMapper> {
return Array.isArray(keysOrMapper)
? keysOrMapper.reduce((reduced, key) => {
reduced[key] = function (
this: ComponentPublicInstance,
...args: any[]
) {
return useStore(this.$pinia)[key](...args)
}
return reduced
}, {} as _MapActionsReturn<A>)
: Object.keys(keysOrMapper).reduce((reduced, key: keyof KeyMapper) => {
reduced[key] = function (
this: ComponentPublicInstance,
...args: any[]
) {
return useStore(this.$pinia)[keysOrMapper[key]](...args)
}
return reduced
}, {} as _MapActionsObjectReturn<A, KeyMapper>)
}
同样,mapActions
和mapState
一样是通过函数重载来实现不同的参数处理;甚至是处理逻辑也是基本上类似的思路,就是找到对应的数据仓库,然后找寻对应的 action 方法挂载到一个新的对象当中,最后返回这个对象。
1.2 Store 数据仓库的 api:
在上一篇的 “初始化流程” 的源码分析文章当中我们了解到,在模块化的 store 上面会在defineStore
阶段createSetupStore
方法里面对这个 store 对象挂载一系列的方法,因此接下来要探究的方法基本实现原理和逻辑都在那块地方上面。
$onAction
设置一个回调,当一个 action 即将被调用时,就会被调用。 回调接收一个对象, 其包含被调用 action 的所有相关信息:
- store: 被调用的 store
- name: action 的名称
- args: 传递给 action 的参数
前置知识 - 订阅发布模式
在 Pinia 的源码底层当中,是将 订阅发布 相关的功能模块单独抽取封装出来了,是软件设计当中的 “解耦” 的一个很好的例子。
// vuejs:pinia/packages/pinia/src/subscriptions.ts
import { getCurrentScope, onScopeDispose } from 'vue-demi'
import { _Method } from './types'
export const noop = () => {}
// 添加订阅回调事件
export function addSubscription<T extends _Method>(
subscriptions: T[], // 订阅的回调列表
callback: T, // 新增的订阅回调函数
detached?: boolean, // 标识 - 组件卸载后是否保留监听
onCleanup: () => void = noop // 清除订阅回调函数后执行的回调
) {
subscriptions.push(callback) // 回调列表添加新的回调函数项
// 内部定义的清除当前订阅回调函数的方法
const removeSubscription = () => {
const idx = subscriptions.indexOf(callback)
if (idx > -1) {
subscriptions.splice(idx, 1)
onCleanup()
}
}
// 根据 detached 参数以及当前组件作用域判断是否要调用 removeSubscription 在组件删除时候进行清除订阅的回调函数
if (!detached && getCurrentScope()) {
onScopeDispose(removeSubscription)
}
return removeSubscription // 返回清除订阅回调的函数
}
// 触发订阅(发布)
export function triggerSubscriptions<T extends _Method>(
subscriptions: T[],
...args: Parameters<T>
) {
// 遍历订阅列表,执行各个订阅函数
subscriptions.slice().forEach((callback) => {
callback(...args)
})
}
看源码便知道这是一个很标准的一个订阅发布的实现:
- 添加订阅 - 往订阅列表当中 push 增加订阅回调;
- 触发订阅 - 遍历订阅回调事件列表,逐个执行回调事件;
$onAction 分析
import { mainStore } from '../store/index'
const store = mainStore()
store.$onAction((option) => {
let { after, onError, args, name, store } = option;
console.log({
after, onError, args, name, store,
});
// 这将在 action 成功并完全运行后触发。它等待着任何返回的 promise
after((result) => {
console.log(
`Finished "${name}" after ${
Date.now() - startTime
}ms.\nResult: ${result}.`
)
})
// 如果 action 抛出或返回一个拒绝的 promise,这将触发
onError((error) => {
console.warn(
`Failed "${name}" after ${Date.now() - startTime}ms.\nError: ${error}.`
)
})
});
setInterval(() => {
store.sayHello();
}, 1000);
// vuejs:pinia/packages/pinia/src/store.ts
function createSetupStore<
Id extends string,
SS extends Record<any, unknown>,
S extends StateTree,
G extends Record<string, _Method>,
A extends _ActionsTree
>(
$id: Id,
setup: () => SS,
options:
| DefineSetupStoreOptions<Id, S, G, A>
| DefineStoreOptions<Id, S, G, A> = {},
pinia: Pinia,
hot?: boolean,
isOptionsStore?: boolean
): Store<Id, S, G, A> {
// ··· ···
// 创建订阅函数列表数组对象
let actionSubscriptions: StoreOnActionListener<Id, S, G, A>[] = markRaw([])
const setupStore = pinia._e.run(() => {
scope = effectScope()
return scope.run(() => setup())
})!
for (const key in setupStore) { // 遍历当前数据仓库的属性
const prop = setupStore[key]
if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) {
// ··· ···
} else if (typeof prop === 'function') { // 判断当前属性是函数,也就是 action 方法
// 使用 wrapAction 包装这个 action 方法
const actionValue = wrapAction(key, prop)
// 使用经过 wrapAction 包装后的 action 方法替换掉原方法
setupStore[key] = actionValue
// ··· ···
}
}
function wrapAction(name: string, action: _Method) {
return function (this: any) {
setActivePinia(pinia) // 设置激活的数据仓库为当前数据仓库
const args = Array.from(arguments) // 获取调用 action 方法时候的参数
// 定义相关回调函数数据列表
const afterCallbackList: Array<(resolvedReturn: any) => any> = []
const onErrorCallbackList: Array<(error: unknown) => unknown> = []
// 定义 after 方法
function after(callback: _ArrayType<typeof afterCallbackList>) {
afterCallbackList.push(callback) // 将
}
// 定义 onError 方法
function onError(callback: _ArrayType<typeof onErrorCallbackList>) {
onErrorCallbackList.push(callback)
}
// 触发 $onAction 的订阅函数
triggerSubscriptions(actionSubscriptions, { args, name, store, after, onError })
let ret: any
try {
// 执行 action 方法
ret = action.apply(this && this.$id === $id ? this : store, args)
} catch (error) {
// 执行 action 出异常或者 action 抛除 reject 时候触发 onError 的订阅发布
triggerSubscriptions(onErrorCallbackList, error)
throw error
}
// 判断执行 action 方法是否返回的是一个 Promise
if (ret instanceof Promise) {
// 如果返回的是 Promise 则需要进行 then 判断运行结果
return ret
.then((value) => {
// action 返回的 Promise 执行成功则进行对 $action 里对 after 的回调进行触发处理
triggerSubscriptions(afterCallbackList, value)
return value
})
.catch((error) => {
// 执行出现异常时候触发 onError 的订阅发布
triggerSubscriptions(onErrorCallbackList, error)
return Promise.reject(error)
})
}
// 触发 after 的订阅函数
triggerSubscriptions(afterCallbackList, ret)
return ret
}
}
const partialStore = {
// ··· ···
// 注册 $onAction 的订阅发布处理
$onAction: addSubscription.bind(null, actionSubscriptions),
} as _StoreWithState<Id, S, G, A>
// ··· ···
}
从上面精简后的createSetupStore
方法来看,$onAction
就是基于前文提及到的订阅发布模式为基础来实现的。
首先在createSetupStore
里面会创建一个 onAction方法的订阅发布回调事件数组,然后使用bind重新生成一个修改参数的‘addSubscription‘增加订阅的方法作为onAction 方法的订阅发布回调事件数组,然后使用 bind 重新生成一个修改参数的`addSubscription`增加订阅的方法作为 onAction方法的订阅发布回调事件数组,然后使用bind重新生成一个修改参数的‘addSubscription‘增加订阅的方法作为onAction,也就是 $onAction 调用时候其实就是调用addSubscription
方法添加了一次订阅;
接着将 action 里面的方法都使用wrapAction
方法包装了一次,wrapAction
方法就是实现 action 方法调用时候触发 onAction回调的核心处理逻辑:在‘wrapAction‘方法其实主要是先执行完原action的方法,接在再对onAction 回调的核心处理逻辑:在`wrapAction`方法其实主要是先执行完原 action 的方法,接在再对 onAction回调的核心处理逻辑:在‘wrapAction‘方法其实主要是先执行完原action的方法,接在再对onAction 回调内的 after 与 onError 回调的处理;
- 创建 after 与 onError 各自的订阅发布回调函数数组,并且封装对应添加订阅的方法并且该方法作为 $onAction 方法的参数,提供给外部调用添加 after 与 onError 的订阅回调;
- 先执行完原 action 的方法(包括如果 action 返回的 Promise 也保证执行完);
- 然后 action 方法没执行异常或者方法内部没返回 Promise.reject 则使用
triggerSubscriptions
触发 after 的发布继而正常执行 after 的订阅回调; - 如果执行其中有报错则因为 try catch 原因进入捕获报错逻辑,进而调用
triggerSubscriptions
触发 onError 的订阅发布,触发对应的错误订阅回调。
$subscribe
设置一个回调,当状态发生变化时被调用。它会返回一个用来移除此回调的函数。 请注意,当在组件内调用 store.$subscribe() 时,除非 detached 被设置为 true, 否则当组件被卸载时,它将被自动清理掉。
import { mainStore } from '../store/index'
const store = mainStore()
store.$subscribe(
(option, state) => {
let { events, storeId, type } = option;
console.log(events, storeId, type, state);
},
{ detached: false }
);
// vuejs:pinia/packages/pinia/src/store.ts
function createSetupStore<
Id extends string,
SS extends Record<any, unknown>,
S extends StateTree,
G extends Record<string, _Method>,
A extends _ActionsTree
>(
$id: Id,
setup: () => SS,
options:
| DefineSetupStoreOptions<Id, S, G, A>
| DefineStoreOptions<Id, S, G, A> = {},
pinia: Pinia,
hot?: boolean,
isOptionsStore?: boolean
): Store<Id, S, G, A> {
// ··· ···
let subscriptions: SubscriptionCallback<S>[] = markRaw([]) // 存储 state 的发布订阅回调事件列表
const partialStore = {
// ··· ···
$subscribe(callback, options = {}) {
// 新增一个 state 监听回调
const removeSubscription = addSubscription(
subscriptions,
callback,
options.detached,
() => stopWatcher() // 传入停止 watch 监听的函数
)
const stopWatcher = scope.run(() => // 通过 effectScope 来捕获内部创建可清除处理的响应式副作用
// 使用 watch 来对当前数据仓库的 state 对象属性进行监听
watch(
() => pinia.state.value[$id] as UnwrapRef<S>,
(state) => {
if (options.flush === 'sync' ? isSyncListening : isListening) {
// 在触发到 state 对象内部属性进行修改时候执行 callback
callback(
{
storeId: $id,
type: MutationType.direct,
events: debuggerEvents as DebuggerEvent,
},
state
)
}
},
assign({}, $subscribeOptions, options)
)
)!
return removeSubscription
}
} as _StoreWithState<Id, S, G, A>
// ··· ···
}
$subscribe
这个 api 是基于 Vue.js 的watch
以及前文提及的订阅发布模式来实现的。
实现 state 的监听的逻辑不难理解,就是创建了一个监听 state 的回调函数数组列表subscriptions
,然后使用订阅发布的addSubscription
添加监听执行 callback;然后利用 Vue.js 的watch
api 对当前数据仓库 store 的 state 属性进行监听,当修改触发 watch 回调时候调用参数的 callback 回调。
这时候有没有发现为啥创建了两种不同类型的监听呢,watch 监听的回调是有着标志 isSyncListening 与 isListening 判断是否进行回调 callback 的执行,并且通过订阅发布的addSubscription
创建的订阅好像没有相关的发布触发回调呢。先别着急,接下来的$patch
这个修改 state 值的 api 就会牵涉到这块订阅发布的回调逻辑了。
$patch
将一个 state 补丁应用于当前状态,其实就是改 state 的值。允许传递嵌套值。
useCounter.$patch({ counter: 2 });
useCounter.$patch((state) => {
state.counter = 2;
});
// vuejs:pinia/packages/pinia/src/store.ts
let activeListener: Symbol | undefined
function $patch(stateMutation: (state: UnwrapRef<S>) => void): void
function $patch(partialState: _DeepPartial<UnwrapRef<S>>): void
function $patch(partialStateOrMutator: | _DeepPartial<UnwrapRef<S>> | ((state: UnwrapRef<S>) => void)): void {
let subscriptionMutation: SubscriptionCallbackMutation<S>
isListening = isSyncListening = false
if (typeof partialStateOrMutator === 'function') { // 参数是函数情况
partialStateOrMutator(pinia.state.value[$id] as UnwrapRef<S>) // 调用 $patch 的参数函数,传入当前 state 作为参数,函数内直接对该 state 对象的属性进行修改
subscriptionMutation = {
type: MutationType.patchFunction,
storeId: $id,
events: debuggerEvents as DebuggerEvent[],
}
} else { // 参数是对象情况
mergeReactiveObjects(pinia.state.value[$id], partialStateOrMutator) // 调用合并响应式的对象的方法合并当前数据仓库的 state 与新的对象
subscriptionMutation = {
type: MutationType.patchObject,
payload: partialStateOrMutator,
storeId: $id,
events: debuggerEvents as DebuggerEvent[],
}
}
const myListenerId = (activeListener = Symbol())
nextTick().then(() => {
if (activeListener === myListenerId) {
isListening = true
}
})
isSyncListening = true
// 因为 isListening 和 isSyncListening 在修改值的时候设置为了false,不会触发 $subscribe 中 watch 设置的回调,需要手动调用触发订阅发布
triggerSubscriptions(
subscriptions, // state 的订阅发布回调函数列表
subscriptionMutation,
pinia.state.value[$id] as UnwrapRef<S>
)
}
$patch
的逻辑需要结合着前面的$subscribe
方法的实现才能较好的理解。
- 首先是设置 isListening 与 isSyncListening 这两个前面控制
$subscribe
内 watch 监听回调 callback 的标识为 false,让其先不触发 state 修改后 watch 监听的回调(这是因为$patch
能够批量同时对 state 多个属性进行更新,会多次触发 watch 的监听回调,因此需要先在更新 state 前进行对监听回调的禁用); - 然后根据
$patch
参数的形式是函数还是对象分别处理逻辑(函数直接执行、对象则进行对象属性合并)来进行对 state 的更新,更新后将前面的 isListening 与 isSyncListening 重新设置为 true; - 因为前面更新 state 时候禁用了 watch 的监听回调,因此我们需要手动去调用
$triggerSubscriptions
触发订阅发布事件,这时候就是回收前面$subscribe
方法里面提及到的subscriptions
订阅回调函数列表的使用。
$reset
通过建立一个新的状态对象,将 store 重设为初始状态。
const userStore = useUserStore();
userStore.$patch(state => {
state.name = 'xxx'
});
userStore.$reset();
// vuejs:pinia/packages/pinia/src/store.ts
function $reset() {
const newState = state ? state() : {}
this.$patch(($state) => {
assign($state, newState)
})
}
简单粗暴的实现方式,就是重新调用 state 方法进行获取 setup 响应式的 state与getters,然后使用$patch
修改设置当前数据仓库 store 对象的$state
属性。
$dispose
停止 store 的相关作用域,并从 store 注册表中删除它。 插件可以覆盖此方法来清理已添加的任何副作用函数。
const userStore = useUserStore();
userStore.$dispose();
// vuejs:pinia/packages/pinia/src/store.ts
function $dispose() {
scope.stop()
subscriptions = []
actionSubscriptions = []
pinia._s.delete($id)
}
在之前的初始化数据仓库的文章当中我们谈及到,其实模块数据仓库 store 其实是在createSetupStore
方法里面初始化创建的对象,其中数据仓库的 state、getters 都是存储在一个使用effectScope
(至于 effectScope 这个 Vue.js 的高阶响应式 api 这里就不展开讲了)进行创建的一个响应式对象 scope 上面,因此这里的销毁数据仓库的操作就变得比较简单了,只要清除响应式相关数据、清除相关的监听等就可以了。
- 调用 scope: EffectScope 的
stop
方法,对内部所创建的响应式副作用一次性清除掉; - 清空当前数据仓库的监听列表;
- 最后从 pinia 对象当中删除对该数据仓库的指向引用即可。
二、收获与感想
通过对 Pinia 源码阅读,最大的收获还是更深层次的对 pinia 这个全局数据仓库管理库的一个实现原理的认知与学习,学会了一些可能在官方文档上也比较少记载描述的 api 的用法或者是平常当中较少使用的方法,像是初始化数据 state 仓库那样三种不同的方式;以及一些 api 的巧妙使用方法或者用处;甚至是一些 Vue.js 日常当中可能比较少使用到的 api 以及像 effectScope、makeRaw、toRaw 等能够提升代码运行性能的 api 的应用场景等。
当然在阅读源码的这段时间过程当中收获的不仅仅是代码知识相关的,还有额外的一些收获,像是这段时间的沉下心来专心的去完成并且一点一滴的不断地在进行阅读、思考、做笔记;自从毕业了之后投身到工作当中,这几年好像仿佛好久没有试着真正沉静下来,放空其他思想去认真仔细的参详、研究一样事物了。后续也会保持着继续深入对 Vue.js 周边的生态环境进行深入的研究,像 Vue.js Code、Vue-Router、Vuex 等相关库的源码、实现原理进行阅读分析。
参考资料
相关的系列文章
- Pinia 原理解读 - 引入与初始化数据仓库流程:juejin.cn/post/721017…
- Pinia 原理解读 - 相关 methods 与 api 的解析:juejin.cn/post/721354…
相关的参考资料
- Pinia 🍍:pinia.vuejs.org/zh/api/modu…
- Pinia 🍍 - _StoreWithState:pinia.vuejs.org/zh/api/inte…
转载自:https://juejin.cn/post/7213548316981968954