你好,我是大圣。
上一讲我们分析了Vite原理,今天我们来剖析Vuex的原理。其实在之前的课程中,我们已经实现过一个迷你的Vuex,整体代码逻辑比较简单,基于Vue提供的响应式函数reactive和computed的能力,我们封装了一个独立的共享数据的store,并且对外暴露了commit和dispatch方法修改和更新数据,这些原理就不赘述了。
今天我们探讨一下下一代Vuex5的提案,并且看一下实际的代码是如何实现的,你学完之后可以对比之前gvuex mini版本,感受一下两者的区别。
由于Vuex有模块化namespace的功能,所以模块user中的mutation add方法,我们需要使用 commit('user/add')
来触发。这样虽然可以让Vuex支持更复杂的项目,但是这种字符串类型的拼接功能,在TypeScript4之前的类型推导中就很难实现。然后就有了Vuex5相关提案的讨论,整个讨论过程都是在GitHub的issue里推进的,你可以访问GitHub链接去围观。
Vuex5的提案相比Vuex4有很大的改进,解决了一些Vuex4中的缺点。Vuex5能够同时支持Composition API和Option API,并且去掉了namespace模式,使用组合store的方式更好地支持了TypeScript的类型推导,还去掉了容易混淆的Mutation和Action概念,只保留了Action,并且支持自动的代码分割。
我们也可以通过对这个提案的研究,来体验一下在一个框架中如何讨论新的语法设计和实现,以及如何通过API的设计去解决开发方式的痛点。你可以在Github的提案RFCs中看到Vuex5的设计文稿,而Pinia正是基于Vuex5设计的框架。
现在Pinia已经正式合并到Vue组织下,成为了Vue的官方项目,尤雨溪也在多次分享中表示Pinia就是未来的Vuex,接下来我们就好好学习一下Pinia的使用方式和实现的原理。
下图是Pinia官网的介绍,可以看到类型安全、Vue 的Devtools支持、易扩展、只有1KB的体积等优点。快来看下Pinia如何使用吧。
首先我们在项目根目录下执行下面的命令去安装Pinia的最新版本。
npm install pinia@next
然后在src/main.js中,我们导入createPinia方法,通过createPinia方法创建Pinia的实例后,再通过app.use方法注册Pinia。
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia()
const app = createApp(App)
app.use(pinia).mount('#app')
然后我们可以在store文件夹中创建一个count.js。下面的代码中我们通过Pinia的defineStore方法定义了一个store,store内部通过state返回一个对象,并且通过Actions配置修改数据的方法add。这里使用的语法和Vuex比较类似,只是删除了Mutation的概念,统一使用Actions来配置。
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('count', {
id:'count',
state: () => {
return { count: 1 }
},
actions: {
add() {
this.count++
},
},
})
然后我们可以使用Composition的方式在代码中使用store。注意上面的store返回的其实就是一个Composition风格的函数,使用useCounterStore返回count后,可以在add方法中直接使用count.add触发Actions,实现数据的修改。
import { useCounterStore } from '../stores/count'
const count = useCounterStore()
function add(){
count.add()
}
我们也可以使用Composition风格的语法,去创建一个store。使用ref或者reactive包裹后,通过defineStore返回,这样store就非常接近我们自己分装的Composition语法了,也去除了很多Vuex中特有的概念,学习起来更加简单。
export const useCounterStore = defineStore('count', () => {
const count = ref(0)
function increment() {
count.value++
}
return { count, increment }
})
然后我们通过阅读Pinia的源码,来看下Pinia是如何实现的。
首先我们进入到Pinia的GitHub中,我们可以在packages/pinia/src/createPinia.ts中看到createPinia函数的实现。
下面的代码中,我们通过effectScope创建一个作用域对象,并且通过ref创建了响应式的数据对象state。然后通过install方法支持了app.use的注册,内部通过provide的语法和全局的$pinia变量配置Pinia对象,并且通过use方法和toBeInstalled数组实现了Pinia的插件机制。最后还通过pinia.use(devtoolsPlugin) 实现了对VueDevtools的支持。
export function createPinia(): Pinia {
const scope = effectScope(true)
// NOTE: here we could check the window object for a state and directly set it
// if there is anything like it with Vue 3 SSR
const state = scope.run(() => ref<Record<string, StateTree>>({}))!
let _p: Pinia['_p'] = []
// plugins added before calling app.use(pinia)
let toBeInstalled: PiniaPlugin[] = []
const pinia: Pinia = markRaw({
install(app: App) {
// this allows calling useStore() outside of a component setup after
// installing pinia's plugin
setActivePinia(pinia)
if (!isVue2) {
pinia._a = app
app.provide(piniaSymbol, pinia)
app.config.globalProperties.$pinia = pinia
toBeInstalled.forEach((plugin) => _p.push(plugin))
toBeInstalled = []
}
},
use(plugin) {
if (!this._a && !isVue2) {
toBeInstalled.push(plugin)
} else {
_p.push(plugin)
}
return this
},
_p,
_a: null,
_e: scope,
_s: new Map<string, StoreGeneric>(),
state,
})
if (__DEV__ && IS_CLIENT) {
pinia.use(devtoolsPlugin)
}
return pinia
}
通过上面的代码,我们可以看到Pinia实例就是 ref({}) 包裹的响应式对象,项目中用到的state都会挂载到Pinia这个响应式对象内部。
然后我们去看下创建store的defineStore方法, defineStore内部通过useStore方法去定义store,并且每个store都会标记唯一的ID。
首先通过getCurrentInstance获取当前组件的实例,如果useStore参数没有Pinia的话,就使用inject去获取Pinia实例,这里inject的数据就是createPinia函数中install方法提供的。
然后设置activePinia,项目中可能会存在很多Pinia的实例,设置activePinia就是设置当前活跃的Pinia实例。这个函数的实现方式和Vue中的componentInstance很像,每次创建组件的时候都设置当前的组件实例,这样就可以在组件的内部通过getCurrentInstance获取,最后通过createSetupStore或者createOptionsStore创建组件。
这就是上面代码中我们使用Composition和Option两种语法创建store的不同执行逻辑,最后通过pinia._s缓存创建后的store,_s就是在createPinia的时候创建的一个Map对象,防止store多次重复创建。到这store创建流程就结束了。
export function defineStore(
// TODO: add proper types from above
idOrOptions: any,
setup?: any,
setupOptions?: any
): StoreDefinition {
let id: string
let options:...
const isSetupStore = typeof setup === 'function'
if (typeof idOrOptions === 'string') {
id = idOrOptions
// the option store setup will contain the actual options in this case
options = isSetupStore ? setupOptions : setup
} else {
options = idOrOptions
id = idOrOptions.id
}
function useStore(pinia?: Pinia | null, hot?: StoreGeneric): StoreGeneric {
const currentInstance = getCurrentInstance()
pinia =
// in test mode, ignore the argument provided as we can always retrieve a
// pinia instance with getActivePinia()
(__TEST__ && activePinia && activePinia._testing ? null : pinia) ||
(currentInstance && inject(piniaSymbol))
if (pinia) setActivePinia(pinia)
pinia = activePinia!
if (!pinia._s.has(id)) {
// creating the store registers it in `pinia._s`
if (isSetupStore) {
createSetupStore(id, setup, options, pinia)
} else {
createOptionsStore(id, options as any, pinia)
}
/* istanbul ignore else */
if (__DEV__) {
// @ts-expect-error: not the right inferred type
useStore._pinia = pinia
}
}
const store: StoreGeneric = pinia._s.get(id)!
// save stores in instances to access them devtools
if (
__DEV__ &&
IS_CLIENT &&
currentInstance &&
currentInstance.proxy &&
// avoid adding stores that are just built for hot module replacement
!hot
) {
const vm = currentInstance.proxy
const cache = '_pStores' in vm ? vm._pStores! : (vm._pStores = {})
cache[id] = store
}
// StoreGeneric cannot be casted towards Store
return store as any
}
useStore.$id = id
return useStore
}
在Pinia中createOptionsStore内部也是调用了createSetupStore来创建store对象。下面的代码中,我们通过assign方法实现了setup函数,这里可以看到computed的实现,内部就是通过pinia._s缓存获取store对象,调用store的getters方法来模拟,最后依然通过createSetupStore创建。
function createOptionsStore<
Id extends string,
S extends StateTree,
G extends _GettersTree<S>,
A extends _ActionsTree
>(
id: Id,
options: DefineStoreOptions<Id, S, G, A>,
pinia: Pinia,
hot?: boolean
): Store<Id, S, G, A> {
const { state, actions, getters } = options
const initialState: StateTree | undefined = pinia.state.value[id]
let store: Store<Id, S, G, A>
function setup() {
pinia.state.value[id] = state ? state() : {}
return assign(
localState,
actions,
Object.keys(getters || {}).reduce((computedGetters, name) => {
computedGetters[name] = markRaw(
computed(() => {
setActivePinia(pinia)
// it was created just before
const store = pinia._s.get(id)!
return getters![name].call(store, store)
})
)
return computedGetters
}, {} as Record<string, ComputedRef>)
)
}
store = createSetupStore(id, setup, options, pinia, hot)
return store as any
}
最后我们来看一下createSetupStore函数的实现。这个函数也是Pinia中最复杂的函数实现,内部的$patch函数可以实现数据的更新。如果传递的参数partialStateOrMutator是函数,则直接执行,否则就通过mergeReactiveObjects方法合并到state中,最后生成subscriptionMutation对象,通过triggerSubscriptions方法触发数据的更新。
function $patch(
partialStateOrMutator:
| _DeepPartial<UnwrapRef<S>>
| ((state: UnwrapRef<S>) => void)
): void {
let subscriptionMutation: SubscriptionCallbackMutation<S>
isListening = isSyncListening = false
// reset the debugger events since patches are sync
/* istanbul ignore else */
if (__DEV__) {
debuggerEvents = []
}
if (typeof partialStateOrMutator === 'function') {
partialStateOrMutator(pinia.state.value[$id] as UnwrapRef<S>)
subscriptionMutation = {
type: MutationType.patchFunction,
storeId: $id,
events: debuggerEvents as DebuggerEvent[],
}
} else {
mergeReactiveObjects(pinia.state.value[$id], partialStateOrMutator)
subscriptionMutation = {
type: MutationType.patchObject,
payload: partialStateOrMutator,
storeId: $id,
events: debuggerEvents as DebuggerEvent[],
}
}
nextTick().then(() => {
isListening = true
})
isSyncListening = true
// because we paused the watcher, we need to manually call the subscriptions
triggerSubscriptions(
subscriptions,
subscriptionMutation,
pinia.state.value[$id] as UnwrapRef<S>
)
}
然后定义partialStore对象去存储ID、$patch、Pinia实例,并且新增了subscribe方法。再调用reactive函数把partialStore包裹成响应式对象,通过pinia._s.set的方法实现store的挂载。
最后我们通过pinia._s.get获取的就是partialStore对象,defineStore返回的方法useStore就可以通过useStore去获取缓存的Pinia对象,实现对数据的更新和读取。
这里我们也可以看到,除了直接执行Action方法,还可以通过调用内部的 count.$patch({count:count+1})
的方式来实现数字的累加。
const partialStore = {
_p: pinia,
// _s: scope,
$id,
$onAction: addSubscription.bind(null, actionSubscriptions),
$patch,
$reset,
$subscribe(callback, options = {}) {
const removeSubscription = addSubscription(
subscriptions,
callback,
options.detached,
() => stopWatcher()
)
const stopWatcher = scope.run(() =>
watch(
() => pinia.state.value[$id] as UnwrapRef<S>,
(state) => {
if (options.flush === 'sync' ? isSyncListening : isListening) {
callback(
{
storeId: $id,
type: MutationType.direct,
events: debuggerEvents as DebuggerEvent,
},
state
)
}
},
assign({}, $subscribeOptions, options)
)
)!
return removeSubscription
}
const store: Store<Id, S, G, A> = reactive(
assign({}, partialStore )
)
// store the partial store now so the setup of stores can instantiate each other before they are finished without
// creating infinite loops.
pinia._s.set($id, store)
我们可以看出一个简单的store功能,真正需要支持生产环境的时候,也需要很多逻辑的封装。
代码内部除了__dev__调试环境中对Devtools支持的语法,还有很多适配Vue 2的语法,并且同时支持Optipn风格和Composition风格去创建store。createSetupStore等方法内部也会通过Map的方式实现缓存,并且setActivePinia方法可以在多个Pinia实例的时候获取当前的实例。
这些思路在Vue、vue-router源码中都能看到类似的实现方式,这种性能优化的思路和手段也值得我们学习,在项目开发中也可以借鉴。
最后我们总结一下今天学到的内容吧。由于课程之前的内容已经手写了一个迷你的Vuex,这一讲我们就越过Vuex4,直接去研究了Vuex5的提案。
Vuex5针对Vuex4中的几个痛点,去掉了容易混淆的概念Mutation,并且去掉了对TypeScript不友好的namespace功能,使用组合store的方式让Vuex对TypeScript更加友好。
Pinia就是Vuex5提案产出的框架,现在已经是Vue官方的框架了,也就是Vuex5的实现。在Pinia的代码中,我们通过createPinia创建Pinia实例,并且可以通过Option和Composition两种风格的API去创建store,返回 useStore 函数获取Pinia的实例后,就可以进行数据的修改和读取。
最后留一个思考题吧。对于数据共享语法,还有provide/inject和自己定义的Composition,什么时候需要使用Pinia呢?
欢迎到评论区分享你的想法,也欢迎你把这一讲的内容分享给你的朋友们,我们下一讲再见!