Pinia 状态管理及其原理
一、什么是状态管理
组件的单向数据流如下:
其中,包括以下几个部分:
- State 状态:驱动整个应用的数据源;
- View 视图:对 State 状态的一种声明式映射;
- Actions 交互:State 状态根据用户输入而作出相应的变更。
当有多个组件共享一个共同的状态时,单向数据流会变得复杂:
- 多个视图依赖于同一份状态(可通过透传的方式解决,但会导致 Prop 逐级透传,代码变得繁琐冗长)
- 来自不同视图的交互可能要更改同一份状态(可通过事件逐层同步,但也会导致代码难以维护)
因此,就需要抽取出组件间的共享状态,放在一个全局单例中来管理。这样组件树就变成了一个大的“视图”,不管在树的哪个位置,组件都可以访问其中的状态或触发动作。
二、Pinia 安装及使用
Pinia 是 Vue 官方提供的状态管理库,用于跨组件共享状态。Pinia 的优点如下:
- 与 Vue DevTools 集成;
- 更完善的 TypeScript 支持;
- 更简洁的 API;
- 轻量化,体积约 1KB;
- 模块热更新 (HMR)
- 支持 SSR 服务端渲染。
1、安装
npm install pinia
# yarn
yarn add pinia
2、基本用法
- 引入 pinia
- 创建 store
- 使用 store
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { createPinia } from 'pinia'
createApp(App).mount('#app')
createApp(App).use(createPinia()).mount('#app')
import { defineStore } from "pinia";
/**
* 转换 style 样式值
* @param id store 的 ID
* @param options 选项对象
* @returns 对外部暴露一个 use 方法的函数
*/
export const useMainStore = defineStore('main', {
// 存储全局状态
state: () => ({
count: 0
}),
// 用来修改 state
actions: {
increment() {
this.count++
},
},
})
注意这里不能使用箭头函数定义 action,因为箭头函数内部的 this
指向外部。
<template>
<div>
<span>count is {{ store.count }}</span>
<button @click="handleAdd">+1</button>
</div>
</template>
<script setup lang="ts">
import { useMainStore } from './stores'
const store = useMainStore()
console.log(store)
const handleAdd = () => {
store.increment()
}
</script>
这里直接打印出来的 store 是一个 Proxy:
实现效果:
3、storeToRefs 解构 store
const { countPinia } = store
这种解构是不合法的,会破坏响应式,如果需要解构 store,需要用 storeToRefs 来实现:
<template>
<div>
<span>count is {{ store.count }}</span>
<span>count is {{ count }}</span>
<button @click="handleAdd">+1</button>
</div>
</template>
<script setup lang="ts">
import { useMainStore } from './stores'
import { storeToRefs } from 'pinia'
const store = useMainStore()
const { count } = storeToRefs(store)
const handleAdd = () => {
store.increment()
}
</script>
4、监听 store 状态变化
可以通过 store 的 $subscribe() 方法监听状态变化:
<template>
<div>
<span>count is {{ store.count }}</span>
<button @click="handleAdd">+1</button>
</div>
</template>
<script setup lang="ts">
import { useMainStore } from './stores'
const store = useMainStore()
store.$subscribe((mutation, state) => {
console.log(mutation, state)
})
const handleAdd = () => {
store.increment()
}
</script>
当状态变化时,打印以下结果:
默认情况下,当使用 $subscribe
组件被卸载时,state subscriptions 将被自动删除,如果想在组件卸载后保留它们,可以给 $subscribe
的第二个参数加上 { detached: true }。
5、getters 用法
getters 相当于 store 的 computed,用于封装计算属性,第一个参数是 state。使用如下:
- 声明 getters
- 使用 getters
import { defineStore } from 'pinia'
export const useMainStore = defineStore('main', {
state: () => ({
count: 0
}),
getters: {
// 传 state - 普通函数
doubleCount1(state) {
return state.count * 2
},
// 传 state - 箭头函数
doubleCount2: (state) => state.count * 2,
// 不传 state 时,可以使用 this 的方式拿到 state 的值
// 但这种方式需要手动声明返回值的类型
tripleCount(): number {
return this.count * 3
}
},
actions: {
increment() {
this.count++
}
}
})
<template>
<div>
<span>count is {{ store.count }}</span>
<button @click="handleAdd">+1</button>
<p>Double count is {{ store.doubleCount1 }}</p>
<p>Double count is {{ store.doubleCount2 }}</p>
<p>Triple count is {{ store.tripleCount }}</p>
</div>
</template>
<script setup lang="ts">
import { useMainStore } from './stores'
const store = useMainStore()
const handleAdd = () => {
store.increment()
}
</script>
实现效果:
三、Pinia 原理解析
1、createPinia 返回 pinia 实例
- createPinia 通过 effect 作用域创建响应式对象 state;
- 然后使用 markRaw 声明一个 pinia 对象,使其不会被响应式代理;
- 在其中用 install 注册执行函数保存当前的 pinia 对象,注入到 vue 中;
- 同时使用 use 来注册 pinia 插件;
- 最后返回 pinia 实例。
- createPinia 源码解析
- install 阶段为 Vue2 环境时执行的 PiniaVuePlugin
// https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/createPinia.ts
export function createPinia(): Pinia {
const scope = effectScope(true)
// 通过 effect 作用域创建响应式对象 state,effect 作用域会捕获其中所创建的响应式副作用
const state = scope.run<Ref<Record<string, StateTree>>>(() =>
ref<Record<string, StateTree>>({})
)!
// 存储扩展 store 的 plugin
let _p: Pinia['_p'] = []
// install 之前使用 pinia.use() 添加的 plugin
let toBeInstalled: PiniaPlugin[] = []
// 使用 markRaw 声明一个 pinia 对象,使其不会被响应式代理
const pinia: Pinia = markRaw({
// 使用 app.use(pinia) 时,会触发这里的注册执行函数
install(app: App) {
// 将当前使用的 pinia 赋给 activePinia 全局变量
setActivePinia(pinia)
// 判断是否为 Vue2 环境
// 如果是 Vue2,则全局注册在 PiniaVuePlugin 中完成
if (!isVue2) {
// 如果不是 Vue2,保存 app 实例
pinia._a = app
// 然后将 pinia 注入到 app 实例,供后续使用
app.provide(piniaSymbol, pinia)
// 再将 pinia 暴露为全局属性 $pinia
app.config.globalProperties.$pinia = pinia
if (USE_DEVTOOLS) {
registerPiniaDevtools(app, pinia)
}
// 处理未执行的 pinia 插件,然后置空
toBeInstalled.forEach((plugin) => _p.push(plugin))
toBeInstalled = []
}
},
// use 可添加一个 plugin 以扩展每个 store
// 接收 plugin 参数,返回当前 pinia
use(plugin) {
// 如果 this._a 为空且非 Vue2 环境
if (!this._a && !isVue2) {
// 将 plugin 暂存到 toBeInstalled 中,等待 install 时进行安装
toBeInstalled.push(plugin)
} else {
// 否则直接添加到 this._p 中
_p.push(plugin)
}
return this
},
// 扩展 store 的所有 pinia 插件
_p,
// app 实例,在 install 时会被设置
_a: null,
// pinia 的 effect 作用域对象,每个 store 都是独立的 scope
_e: scope,
// 在这个 pinia 中注册的 stores
_s: new Map<string, StoreGeneric>(),
// pinia 所有 state 的合集
state,
})
// 集成 vue devtools
if (USE_DEVTOOLS && typeof Proxy !== 'undefined') {
pinia.use(devtoolsPlugin)
}
return pinia
}
// https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/vue2-plugin.ts#L28
export const PiniaVuePlugin: Plugin = function (_Vue) {
// 通过向全局混入一个对象来支持 pinia
_Vue.mixin({
// 在 beforeCreate 中设置 this.$pinia
// 以使 vue 实例可以通过 this.$pinia 的方式来获取 pinia
beforeCreate() {
const options = this.$options
if (options.pinia) {
const pinia = options.pinia as Pinia
if (!(this as any)._provided) {
const provideCache = {}
Object.defineProperty(this, '_provided', {
get: () => provideCache,
set: (v) => Object.assign(provideCache, v),
})
}
;(this as any)._provided[piniaSymbol as any] = pinia
if (!this.$pinia) {
this.$pinia = pinia
}
pinia._a = this as any
if (IS_CLIENT) {
setActivePinia(pinia)
}
if (USE_DEVTOOLS) {
registerPiniaDevtools(pinia._a, pinia)
}
} else if (!this.$pinia && options.parent && options.parent.$pinia) {
this.$pinia = options.parent.$pinia
}
},
destroyed() {
delete this._pStores
},
})
}
2、defineStore 定义 store
defineStore 会对三种创建形式进行兼容,然后在 useStore 中根据参数类型调用 createSetupStore
或 createOptionsStore
方法创建 store,同时将相关的变量和方法并入 store 中,最后返回 store。
- defineStore 用法
- defineStore 源码解析
- createOptionsStore
- createSetupStore
import { defineStore } from 'pinia'
// 创建形式一
export const useMainStore = defineStore('main', {
state: () => ({
count: 0
}),
actions: {
increment() {
this.count++
}
}
})
// 创建形式二
export const useMainStore = defineStore({
id: 'main',
state: () => ({
count: 0
}),
actions: {
increment() {
this.count++
}
}
})
// 创建形式三
export const useMainStore = defineStore('main', () => {
const count = ref(0)
function increment() {
count.value++
}
return { count, increment }
})
// https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/store.ts#L851
export function defineStore(
// TODO: add proper types from above
idOrOptions: any,
setup?: any,
setupOptions?: any
): StoreDefinition {
// store 的唯一 ID
let id: string
// 定义 store 时的 options
let options:
| DefineStoreOptions<
string,
StateTree,
_GettersTree<StateTree>,
_ActionsTree
>
| DefineSetupStoreOptions<
string,
StateTree,
_GettersTree<StateTree>,
_ActionsTree
>
// 表示传入的 setup 是否为函数
const isSetupStore = typeof setup === 'function'
// 对三种 store 创建形式进行兼容
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
}
// 只有在 store 被执行时才会运行 useStore
function useStore(pinia?: Pinia | null, hot?: StoreGeneric): StoreGeneric {
// 通过 getCurrentInstance 获取当前组件实例
const currentInstance = getCurrentInstance()
// 如果存在组件实例,则使用 inject 获取 pinia 实例
pinia =
(__TEST__ && activePinia && activePinia._testing ? null : pinia) ||
(currentInstance && inject(piniaSymbol))
// 将获取到的 pinia 实例设置为当前活跃的 pinia
if (pinia) setActivePinia(pinia)
// 如果在 dev 环境且全局变量 activePinia 获取不到当前的 pinia 实例时
// 则说明 pinia 未全局注册,抛出错误
if (__DEV__ && !activePinia) {
throw new Error(
`[🍍]: getActivePinia was called with no active Pinia. Did you forget to install pinia?\n` +
`\tconst pinia = createPinia()\n` +
`\tapp.use(pinia)\n` +
`This will fail in production.`
)
}
// 设置 pinia 为当前活跃的 pinia
pinia = activePinia!
// 第一次使用创建 store 逻辑时,创建一个 store 并注册到 pinia._s 中
if (!pinia._s.has(id)) {
// 如果 defineStore 第二个参数是函数,执行 createSetupStore
if (isSetupStore) {
createSetupStore(id, setup, options, pinia)
} else {
createOptionsStore(id, options as any, pinia)
}
if (__DEV__) {
useStore._pinia = pinia
}
}
// 从 pinia._s 中获取当前 id 对应的 store
const store: StoreGeneric = pinia._s.get(id)!
if (__DEV__ && hot) {
const hotId = '__hot:' + id
const newStore = isSetupStore
? createSetupStore(hotId, setup, options, pinia, true)
: createOptionsStore(hotId, assign({}, options) as any, pinia, true)
hot._hotUpdate(newStore)
// cleanup the state properties and the store from the cache
delete pinia.state.value[hotId]
pinia._s.delete(hotId)
}
// 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
}
// 返回 store
return store as any
}
useStore.$id = id
// 返回 useStore 的执行结果
return useStore
}
- 从 options 中提取 state、getter、actions;
- 构建 setup 函数,在 setup 函数中将 getter 处理成计算属性;
- 使用 setup 方式创建 store;
- 重写
store.$reset
。
// https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/store.ts#L126
function createOptionsStore<
Id extends string,
S extends StateTree,
G extends _GettersTree<S>,
A extends _ActionsTree
>(
// 定义 store 的 ID
id: Id,
options: DefineStoreOptions<Id, S, G, A>,
// Pinia 实例
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() {
if (!initialState && (!__DEV__ || !hot)) {
/* istanbul ignore if */
if (isVue2) {
set(pinia.state.value, id, state ? state() : {})
} else {
// 将数据存储到 pinia.state 中
pinia.state.value[id] = state ? state() : {}
}
}
// avoid creating a state in pinia.state.value
const localState =
__DEV__ && hot
? // 使用 ref() 来解开状态 TODO 中的 refs:检查这是否仍是必要的
toRefs(ref(state ? state() : {}).value)
: toRefs(pinia.state.value[id])
return assign(
localState,
actions,
Object.keys(getters || {}).reduce((computedGetters, name) => {
if (__DEV__ && name in localState) {
// 如果 getters 名称与 state 中的名称相同,则抛出错误
console.warn(
`[🍍]: A getter cannot have the same name as another state property. Rename one of them. Found with "${name}" in store "${id}".`
)
}
// markRow 防止对象被重复代理
computedGetters[name] = markRaw(
// 使用计算属性处理 getters 的距离逻辑,并通过 call 处理 this 指向问题
computed(() => {
setActivePinia(pinia)
// it was created just before
const store = pinia._s.get(id)!
// allow cross using stores
/* istanbul ignore next */
if (isVue2 && !store._r) return
// 将 store 的 this 指向 getters 中
return getters![name].call(store, store)
})
)
return computedGetters
}, {} as Record<string, ComputedRef>)
)
}
// 使用 createSetupStore 创建 store
store = createSetupStore(id, setup, options, pinia, hot, true)
// 重写 $store 方法
store.$reset = function $reset() {
const newState = state ? state() : {}
// 将所有更改分组到一个订阅中
this.$patch(($state) => {
assign($state, newState)
})
}
return store as any
}
- 首先在 pinia.state.value 中添加键为 $id 的空对象,以便后续赋值;
- 使用 reactive 声明一个响应式对象 store;
- 将 store 存至 pinia._s 中;
- 执行 setup 获取返回值 setupStore;
- 遍历 setupStore 的键值,如果值是 ref(且不是 computed)或为 reactive,将键值添加到
pinia.state.value[$id]
中;如果值时为 function,首先将值使用 wrapAction 包装,然后用包装后的 function 替换 setupStore 中对应的值; - 将 setupStore 合并到 store 中;
- 拦截
store.$state
,使 get 操作可以正确获取pinia.state.value[$id]
,set 操作使用 this.$patch 更新; - 调用
pinia._p
中的扩展函数,扩展 store。
// https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/store.ts#L204
function createSetupStore<
Id extends string,
SS,
S extends StateTree,
G extends Record<string, _Method>,
A extends _ActionsTree
>(
// 定义 store 的 ID
$id: Id,
// 一个可以返回 state 的函数
setup: () => SS,
// defineStore 的 options
options:
| DefineSetupStoreOptions<Id, S, G, A>
| DefineStoreOptions<Id, S, G, A> = {},
// Pinia 实例
pinia: Pinia,
// 是否启用热更新
hot?: boolean,
// 是否为使用 options 声明的 store
isOptionsStore?: boolean
): Store<Id, S, G, A> {
let scope!: EffectScope
const optionsForPlugin: DefineStoreOptionsInPlugin<Id, S, G, A> = assign(
{ actions: {} as A },
options
)
// ...
// partialStore 定义暴露给用户操作 store 的方法
const partialStore = {
_p: pinia,
$id,
// 用于设置 actions 回调的方法
$onAction: addSubscription.bind(null, actionSubscriptions),
// 用于更新 store 中 state 的方法
$patch,
$reset,
$subscribe(callback, options = {}) {
// ...
return removeSubscription
},
// 用于销毁 store 的方法
$dispose,
} as _StoreWithState<Id, S, G, A>
// ...
// store 是通过 reactive 包装的一个响应式对象
// 该对象使用 Object.assign 拷贝了上面的 partialStore
const store: Store<Id, S, G, A> = reactive(
assign(
__DEV__ && IS_CLIENT
? // devtools custom properties
{
_customProperties: markRaw(new Set<string>()),
_hmrPayload,
}
: {},
partialStore
)
) as unknown as Store<Id, S, G, A>
pinia._s.set($id, store)
// 在创建 pinia 实例时执行 setup
const setupStore = pinia._e.run(() => {
// 单独创建一个 effectScope 来单独执行 setup
scope = effectScope()
return scope.run(() => setup())
})!
// 遍历 setupStore 的属性
for (const key in setupStore) {
const prop = setupStore[key]
// 如果 (prop 是 ref 且不是 computed) 或 (为 reactive)
if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) {
if (__DEV__ && hot) {
set(hotState.value, key, toRef(setupStore as any, key))
} else if (!isOptionsStore) {
// ...
// 则将对应属性同步至 pinia.state 中
if (isVue2) {
set(pinia.state.value[$id], key, prop)
} else {
pinia.state.value[$id][key] = prop
}
}
// ...
} else if (typeof prop === 'function') {
// 如果 prop 为 function,则使用【wrapAction】包装 prop
// wrapAction 会处理 afterCallback 和 errorCallback
const actionValue = __DEV__ && hot ? prop : wrapAction(key, prop)
// 将包装后的方法添加到 setupStore 中,覆盖原来的值
if (isVue2) {
set(setupStore, key, actionValue)
} else {
setupStore[key] = actionValue
}
// ...
// 同时将包装后的方法存入 optionsForPlugin.actions 中
optionsForPlugin.actions[key] = prop
} else if (__DEV__) {
// ...
}
}
// 遍历完 setupStore 后,将 setupStore 合并至 store 和 store 的原始对对象中
// 以便使用 storeToRefs() 检索响应式对象
if (isVue2) {
Object.keys(setupStore).forEach((key) => {
set(
store,
key,
setupStore[key]
)
})
} else {
assign(store, setupStore)
assign(toRaw(store), setupStore)
}
// 紧接着拦截 store.$state 的 get、set 方法
Object.defineProperty(store, '$state', {
// 当调用 store.$state 时,能从 pinia.state.value 找到对应的state
get: () => (__DEV__ && hot ? hotState.value : pinia.state.value[$id]),
// 当使用 store.$state = xxx 去修改值时,则调用 $patch 方法修改值
set: (state) => {
/* istanbul ignore if */
if (__DEV__ && hot) {
throw new Error('cannot set hotState')
}
$patch(($state) => {
assign($state, state)
})
},
})
// 至此,store 准备完毕
// ...
// Vue2 环境下会将 store._r 设为 true
if (isVue2) {
store._r = true
}
// 接下来调用 use 注册的 plugins
pinia._p.forEach((extender) => {
/* istanbul ignore else */
if (__DEV__ && IS_CLIENT) {
const extensions = scope.run(() =>
extender({
store,
app: pinia._a,
pinia,
options: optionsForPlugin,
})
)!
Object.keys(extensions || {}).forEach((key) =>
store._customProperties.add(key)
)
assign(store, extensions)
} else {
// 将 plugin 的结果合并到 store 中
assign(
store,
scope.run(() =>
extender({
store,
app: pinia._a,
pinia,
options: optionsForPlugin,
})
)!
)
}
})
if (
__DEV__ &&
store.$state &&
typeof store.$state === 'object' &&
typeof store.$state.constructor === 'function' &&
!store.$state.constructor.toString().includes('[native code]')
) {
console.warn(
`[🍍]: The "state" must be a plain object. It cannot be\n` +
`\tstate: () => new MyClass()\n` +
`Found in store "${store.$id}".`
)
}
// only apply hydrate to option stores with an initial state in pinia
if (
initialState &&
isOptionsStore &&
(options as DefineStoreOptions<Id, S, G, A>).hydrate
) {
;(options as DefineStoreOptions<Id, S, G, A>).hydrate!(
store.$state,
initialState
)
}
isListening = true
isSyncListening = true
return store
}
3、storeToRefs 解构原理
storeToRefs 会判断是否为 vue2,如果是 vue2 则用 toRefs 将 store 转为普通对象;如果不是 vue2 则遍历 store 原始对象的键值,将 ref 与 reactive 类型的值转为 ref 然后复制到一个新的对象 refs 中,最后返回这个新的对象。
// https://github.com/vuejs/pinia/blob/v2/packages/pinia/src/storeToRefs.ts
export function storeToRefs<SS extends StoreGeneric>(
store: SS
): ToRefs<
StoreState<SS> & StoreGetters<SS> & PiniaCustomStateProperties<StoreState<SS>>
> {
// It's easier to just use toRefs() even if it includes more stuff
if (isVue2) {
// 如果是 vue2 直接返回 toRefs(store)
return toRefs(store)
} else {
// 如果非 vue2,则过滤 store 中的非 ref 或 reactive 对象
// 通过 toRaw 获取 store 的原始对象并赋值
store = toRaw(store)
const refs = {} as ToRefs<
StoreState<SS> &
StoreGetters<SS> &
PiniaCustomStateProperties<StoreState<SS>>
>
for (const key in store) {
const value = store[key]
if (isRef(value) || isReactive(value)) {
// 使用 toRef 获取一个新的 ref
refs[key] = toRef(store, key)
}
}
return refs
}
}