每个 Vue 组件实例都可以"管理"自己的响应式状态,同时,我们也可以通过 props
将祖先状态传递下去。但是,在复杂的场景下,比如多个组件可能都依赖于同一份状态、更深层的组件需要共享状态、来自不同组件的交互需要更改同一份状态,那么灵活高效的状态管理方案就是必要的。
响应式API方案
用法说明
Vue3 向我们暴露了响应式 API:reactive
、ref
等,让我们可以抽离和共享响应式变量进而实现一个简单粗暴的状态管理。Vue3 的响应性系统天生与组件解耦,使得这种方式非常灵活;
抽离状态:
JavaScript
// store.js
import { reactive } from 'vue'
export const store = reactive({
count: 0
})
组件共享、更新状态:
JavaScript
<!-- ComponentA.vue -->
<script setup>
import { store } from './store.js'
</script>
<template>From A: {{ store.count++ }}</template>
JavaScript
<!-- ComponentB.vue -->
<script setup>
import { store } from './store.js'
</script>
<template>From B: {{ store.count++ }}</template>
每当 store
对象被更改时,<ComponentA>
与 <ComponentB>
都会自动更新它们的视图,然而,这也意味着任意一个导入了 store
的组件都可以随意修改它的状态。为了确保改变状态的逻辑像状态本身一样集中,建议在 store
上定义方法:
JavaScript
// store.js
import { reactive } from 'vue'
export const store = reactive({
count: 0
})
export function setCount(target){
store.count = target
}
我们甚至可以通过组合式函数来返回一个全局状态
JavaScript
import { ref } from 'vue'
// 全局状态,创建在模块作用域下
const globalCount = ref(1)
export function useCount() {
// 局部状态,每个组件都会创建
const localCount = ref(1)
return {
globalCount,
localCount
}
}
provide/inject
provide/inject
在一些跨组件传值场景下很方便。通过妙用provide/inject
可以实现更优秀的全局状态管理工具(vuex4,pinia)
用法说明
provide/inject
适用于深度嵌套的组件间传值。默认情况下,provide/inject
绑定并不是响应式的,为了添加响应式需要在 provide
值时使用响应式 api
:ref
、reactive
等。同时为了防止子孙组件随意更改共享状态,我们一般将provide
的状态设置为只读属性,并将更改状态的方法设置在provide
组件内部和状态一起provide
给子孙组件使用。
JavaScript
<!-- src/components/MyMap.vue -->
<template>
<MarkerParent>
<MyMarker />
<MarkerParent/>
</template>
<script setup>
import { provide, reactive, readonly, ref } from "vue";
import MyMarker from "./MyMarker.vue";
//状态1
const location = ref("North Pole");
//状态2
const geolocation = reactive({
longitude: 90,
latitude: 135,
})
//更改状态
const updateLocation = () => {
location.value = "South Pole";
};
//provide
provide("location", readonly(location));
provide("geolocation", readonly(geolocation));
provide("updateLocation", updateLocation);
</script>
JavaScript
<!-- src/components/MyMarker.vue -->
<script>
import { inject } from "vue";
//inject
const userLocation = inject("location", "The Universe");//第二个参数为默认值
const userGeolocation = inject("geolocation");
const updateUserLocation = inject("updateLocation");
</script>
除了在一个组件中提供依赖,我们还可以在整个应用层面提供依赖:
JavaScript
import { createApp } from 'vue'
const app = createApp({})
app.provide(/* 注入名 */ 'message', /* 值 */ 'hello!')
在应用级别提供的数据在该应用内的所有组件中都可以注入。这在你编写插件时会特别有用,因为插件一般都不会使用组件形式来提供值。
provide
给组件实例对象provides
添加key/value
。默认情况下,当前组件实例的provides
对象和父组件实例一致,但是当它需要提供自己的provide
时(组件中调用provide(key,value)
),它使用父组件provides
对象作为原型来创建自己的provides
对象。
JavaScript
//provide api 核心代码
export function provide<T>(key: InjectionKey<T> | string | number, value: T) {
if (!currentInstance) {
if (__DEV__) {
warn(`provide() can only be used inside setup().`)
}
} else {
//默认情况下,当前组件实例的provides对象和父组件实例一致(为什么看下方代码👇🏻)
//但是当它需要提供自己的provide时,它使用父组件provides对象作为原型来创建自己的provides对象。
let provides = currentInstance.provides
const parentProvides =
currentInstance.parent && currentInstance.parent.provides
if (parentProvides === provides) {
provides = currentInstance.provides = Object.create(parentProvides)
}
provides[key as string] = value
}
}
//每当给当前组件实例设置自己的provides值时要将继承的父组件实例的provides丢到原型上。举个🌰:
//app.provide提供的对象(appContext.prvodes):
{store : { state : 1}}
//APP组件provides :
{
customA: 1,
__proto__: {
store: { state: 1 },
},
};
//APP子组件provides :
{
customB: 2,
__proto__: {
customA: 1,
__proto__: {
store: { state: 1 },
},
},
};
TypeScript
//为什么默认情况下,当前组件实例的provides对象为父组件实例的provides对象呢?
//下面是创建组件实例的函数,其中对provides对象的处理特别简单。
export function createComponentInstance(vnode,parent,suspense) {
const type = vnode.type as ConcreteComponent
// inherit parent app context - or - if root, adopt from root vnode
const appContext =
(parent ? parent.appContext : vnode.appContext) || emptyAppContext
const instance: ComponentInternalInstance = {
uid: uid++,
vnode,
type,
parent,
//每个组件实例都有一个appContext对象
//app.provide 注入的数据最终会被存储在 appContext.provides 属性中
appContext,
//...
withProxy: null,
//看这里
//根组件实例的provides继承appContext,其余组件的provides默认为父组件的provides
provides: parent ? parent.provides : Object.create(appContext.provides),
//....
}
//...
return instance
}
Inject
取出父级组件实例(根组件取appContext
)的provides
对象的对应key
值。
TypeScript
//inject 核心代码
function inject(key,defaultValue,treatDefaultAsFactory = false) {
const instance = currentInstance || currentRenderingInstance
if (instance) {
//取出父级组件实例(根组件取appContext)的provides对象。
const provides =
instance.parent == null
? instance.vnode.appContext && instance.vnode.appContext.provides
: instance.parent.provides
if (provides && (key as string | symbol) in provides) {
//取出provides对象的对应key值
return provides[key as string]
} else if (arguments.length > 1) {
//默认值处理
return treatDefaultAsFactory && isFunction(defaultValue)
? defaultValue.call(instance.proxy)
: defaultValue
} else if (__DEV__) {
warn(`injection "${String(key)}" not found.`)
}
} else if (__DEV__) {
warn(`inject() can only be used inside setup() or functional components.`)
}
Vuex
Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式 + 库,发展稳定且有强大的社区支持。
用法说明
一、每一个 Vuex 应用的核心就是 store
(仓库)。store
基本上就是一个容器,它包含着你的应用中大部分的状态 (state
)。
-
Vuex 的状态存储是响应式的。当 Vue 组件从
store
中读取状态的时候,若store
中的状态发生变化,那么相应的组件也会相应地得到高效更新。 -
你不能直接改变
store
中的状态。改变store
中的状态的唯一途径就是显式地提交 (commit
)mutation
。这样使得我们可以方便地跟踪每一个状态的变化,从而让我们能够实现一些工具帮助我们更好地了解我们的应用。 -
异步逻辑都应该封装到
action
里面。
二、模块化管理store。
Vuex4
Vuex4 在安装方式上使用了Vuex.createStore
、app.use(store)
;使用Vue.reactive
监测数据变化;同时增加了组合式API的用法(通过妙用provide/inject
实现 ),即通过useStore
的方式访问store
。
JavaScript
import { createApp } from "vue";
import { createStore } from "vuex";
// 创建一个新的 store 实例
const store = createStore({
state() {
return {
count: 0,
};
},
getter:{
double(state){
return state.count*2
}
}
mutation:{
increment(state){
state.count+=1
}
},
action:{
async asyncIncrement({commit}){
await somethingAsync()
commit('increment')
}
}
});
const app = createApp({
/* 根组件 */
});
// 将 store 实例作为插件安装
app.use(store);
组合式API使用方法:
JavaScript
import { computed } from 'vue'
import { useStore } from 'vuex'
export default {
setup () {
const store = useStore()
return {
// 使用 computed 包裹state和getter 以保留响应式
count: computed(() => store.state.count),
double: computed(() => store.getters.double)
//使用mutation和action
mutationincrement: () => store.commit('increment'),
actionasyncIncrement: () => store.dispatch('asyncIncrement')
}
}
}
源码分析
createStore
通过app.provide
将store
添加到appContext.provides
中,将state
用响应式 API reactive
包裹。
JavaScript
export function createStore(options) {
return new Store(options);
}
export class Store {
constructor(options = {}) {
...
const state = this._modules.root.state;
//使用响应式API reactive实现响应式
resetStoreState(this, state);
...
}
//app.use时执行的插件函数
install(app, injectKey) {
//composition API中使用,app.provide(store)=>appContext.provides对象添加store
//store为全局store,包含各模块嵌套、state、action、getter、mutation等,其中分别怎么处理就不细讲了
app.provide(injectKey || storeKey, this);
//options API使用 config全局配置globalProperties的store对象=>this.$store
app.config.globalProperties.$store = this;
...
}
export function resetStoreState (store, state, hot) {
//响应式API reactive包裹state
store._state = reactive({
data: state
})
}
注:执行app.use(store)
的时候便会执行store
对象中的install
函数。
appContext
我们在上文多次提到过appContext
对象,我们可以理解为每个组件实例都有一个appContext
对象,appContext.provides
是组件实例的provides
对象的顶层原型对象,其值可以被inject
在任意组件中取出 。实际上在根组件挂载的时候我们已经将appContext
对象挂到vnode
上了。
JavaScript
//createApp函数
function createApp(rootComponent, rootProps = null) {
//初始化context对象,创建appContext
const context = createAppContext()
const app: App = (context.app = {
_component: rootComponent as ConcreteComponent,
_context: context,
_instance: null,
mount(
rootContainer: HostElement,
): any {
//...
//将appContext对象挂到vnode上
vnode.appContext = context
return getExposeProxy(vnode.component!) || vnode.component!.proxy
}
},
provide(key, value) {
//app.provide即给appContext.provides设置key/value
context.provides[key as string | symbol] = value
return app
}
})
return app
}
//createAppContext
export function createAppContext(): AppContext {
return {
app: null as any,
config: {
...
},
mixins: [],
components: {},
directives: {},
provides: Object.create(null),
...
}
}
useStore
通过对createStore
、appContext
以及provide/inject
的介绍,我们可以推断出useStore
是通过inject
实现的,事实上也确实如此:
JavaScript
import { inject } from 'vue'
export const storeKey = 'store'
export function useStore (key = null) {
return inject(key !== null ? key : storeKey)
}
Pinia
Pinia 起始于 2019 年 11 月左右的一次实验,其目的是设计一个拥有组合式 API 的 Vue 状态管理库。
用法说明
-
Pinia 放弃
mutation
,只用action
改变state
,更改state
的链路变得简单。 -
store
通过defineStore
创建后,直接被组件import
,就可通过useStore
访问和更改state
。 -
可以通过
storeToRefs
解决store
被解构后响应式丢失的问题。 -
store
扁平架构,所有store
同级且顶级存在(只有一层),没有模块嵌套,但是store
直接可以交叉引用(一个store
里import
另一个store
)。
Pinia 用法主要依赖createPinia
、defineStore
、storeToRefs
三个方法,其组合式用法示例为:
JavaScript
//main.js
import { createPinia } from "pinia";
//...
app.use(createPinia());
//...
JavaScript
//store/useCountStore.js 创建counterStore仓库
import { defineStore } from "pinia";
//使用defineStore创建store
export const useCountStore = defineStore("count", {
const counter = ref(0)
const doubleCounter = computed(()=>counter.value * 2)
async function increment(){
await somethingAsync()
couter.value++;
}
return {
counter,
doubleCounter,
increment
}
});
JavaScript
//components/Counter.vue
import { useCountStore } from "../storePinia/index";
import { storeToRefs } from "pinia";
export default {
setup() {
//任一仓库引入即用
const couterStore = useCountStore();
//storeToRefs保持响应式
const { counter, doubleCounter } = storeToRefs(couterStore);
function incrementCounter() {
couterStore.increment();
}
return {
counter,
incrementCounter,
doubleCounter,
};
},
};
源码分析
createPinia
createPinia
主要是为了返回一个带install
方法的对象(同上app.use)。在install
时,将pinia
对象暴露到全局,这样无论是在Vue 3、Vue 2还是其他非Vue 组件中,都可以使用这个对象。在这个对象中,有一个存放了所有store
的_s
属性(在install
函数执行过程中,通过 app.provide
将 _s
对象存储在 appContext
中,Vue3 组件即可以通过 inject
访问该对象,处理和Vuex4类似都是通过妙用 provide/inject
实现 ),一个用于停止所有state
的响应式的_e
属性,一个存放所有state
的state
属性,以及插件列表和提供给外界注册插件的函数。
JavaScript
export function createPinia(): Pinia {
//创建一个依赖函数集,到时候方便一起暂停他们的响应式。
const scope = effectScope(true)
//存放 state
const state = scope.run<Ref<Record<string, StateTree>>>(() =>
ref<Record<string, StateTree>>({})
)!
//markRaw 是 Vue 3 中提供的一个函数,用于标记某个对象为"非响应式"的。
const pinia: Pinia = markRaw({
install(app: App) {
//...
if (!isVue2) {
//让vue3的所有组件都可以通过app.inject(piniaSymbol)访问到piniaStore
app.provide(piniaSymbol, pinia)
//让vue2的组件实例也可以共享piniaStore,本文暂不分析
app.config.globalProperties.$pinia = pinia
}
},
//提供给外侧注册插件
use(plugin) {
if (!this._a && !isVue2) {
toBeInstalled.push(plugin) //未初始化完成加入待安装插件
} else {
_p.push(plugin)
}
return this
},
_p,//插件
_a: null,//app实例
_e: scope,//用来停止所有state的响应式
_s: new Map<string, StoreGeneric>(),//存放所有的store映射表
state,
})
return pinia
}
defineStore
defineStore
函数返回一个useStore
函数(useStore
声明在defineStore
作用域下,可以访问id
等信息),这个函数会获取整个pinia
实例(即上面createPinia
暴露出全局的对象),然后检查该实例中是否存在当前正在使用的store
(pinia._s.has(id)
),如果不存在则创建一个新的store
,最后将该store
返回给用户。因此,store
的创建是在use
阶段完成的,而不是在define
阶段。
JavaScript
export function defineStore(
idOrOptions: any,
setup?: any,
setupOptions?: any
): StoreDefinition {
let id: string
//判断是不是组合函数 setup 写法,我们主要分析的就是 setup 写法
const isSetupStore = typeof setup === 'function'
//根据传参格式获取id、setup、options
if (typeof idOrOptions === 'string') {
id = idOrOptions
options = isSetupStore ? setupOptions : setup
} else {
options = idOrOptions
id = idOrOptions.id
}
function useStore(pinia?: Pinia | null, hot?: StoreGeneric): StoreGeneric {
//判断当前实例是否存在
const hasContext = hasInjectionContext()
//inject取出Store映射表
pinia = (hasContext ? inject(piniaSymbol, null) : null)
if (pinia) setActivePinia(pinia)
pinia = activePinia!
//没有对应store时创建store
if (!pinia._s.has(id)) {
//创建 store 后将其注册在`pinia._s`中。
if (isSetupStore) {
//setup函数处理方式,
createSetupStore(id, setup, options, pinia)
} else {
//对于`createOptionsStore`,首先需要从`options`中提取出`state`、`actions`和`getters`,
//然后将它们编写为`setup`函数的形式。在`setup`函数中,`state`需要放置在`pinia`的`state`属性中,
//`getters`中的函数需要转换为计算属性。最后,将处理后的`state`、`actions`和`getters`合并并导出,
//然后将`setup`函数传递给上面的`createSetupStore`来构建`store`。
//最后不论是option方式创建还是setup的形式创建,最后都统一通过createSetupStore完成对store最后的处理
createOptionsStore(id, options as any, pinia)
}
}
//通过id(define时的命名)拿到对应的store
const store: StoreGeneric = pinia._s.get(id)!
return store as any
}
return useStore
}
createSetupStore
createSetupStore
函数的逻辑如下:
先定义一个store
,用于存放不是用户定义的属性和方法以及内置的api
。然后,定义一个scope
,以停止自己store
的响应式。
接下来,遍历store
中的所有属性,对actions
即setup
里的函数进行一层包装(例如将原函数的this
绑定到store
上,以及处理异步函数的情况)。将每个state
都存放到全局的state
中。
最后,将存放内置api
的store
与用户定义的store
进行合并,并将其存放到全局pinia
的\_s
属性中。
JavaScript
function createSetupStore(
$id,
setup,
options,
pinia,
): Store<Id, S, G, A> {
let scope!: EffectScope
pinia.state.value[$id] = {}
//创建初始store,存放内置api
const partialStore = {
_p: pinia,
$id,
$onAction: addSubscription.bind(null, actionSubscriptions),
$patch,
$reset,
$subscribe
$dispose,
} as _StoreWithState<Id, S, G, A>
//给store添加响应式
const store: Store = reactive(partialStore)
//添加store到全局pinia上
pinia._s.set($id, store as Store)
//这样包一层就可以到时候通过pinia.store.stop()来停止全部store的响应式
//返回值同setup的返回值
const setupStore = piniaStore._e.run(() => {
scope = effectScope()
//这样包一层就可以到时候通过scope.stop()来停止这个store的响应式
return scope.run(()=>setup())
})
for (const key in setupStore) {
const prop = setupStore[key]
if ((isRef(prop) && !isComputed(prop)) || isReactive(prop)) {
//将state更新到pinia.state中
pinia.state.value[$id][key] = prop
} else if (typeof prop === 'function') {
//对actions即setup里的函数进行一层包装,包括异步处理、this绑定等等
const actionValue = wrapAction(key, prop)
setupStore[key] = actionValue
}
}
//合并store和setupStore
assign(store, setupStore)
//这个步骤是为了storeToRefs()做基础,响应式对象(用户自定义状态)作为store源对象属性
//toRaw用于获取一个响应式对象的原始(非代理)数据。
assign(toRaw(store), setupStore)
//返回包装好的store
return store
}
storeToRefs
为了在从 store
中提取属性时保持其响应性,你需要使用storeToRefs()
,它将为每一个响应式属性创建引用。(acticon
不需要)。storeToRefs
的作用与toRefs
相似,但存在一些区别,具体来说,storeToRefs
不处理函数,而toRefs
会处理(例如refs.yourFunction.value()
)。
JavaScript
export function storeToRefs(store){
//toRaw用于获取一个响应式对象的原始(非代理)数据。
store = toRaw(store)
const refs = {}
for (const key in store) {
const value = store[key]
//assign(toRaw(store), setupStore) 上边已经将响应式对象(用户自定义状态)作为store源对象属性
if (isRef(value) || isReactive(value)) {
//toRef用于创建一个响应式对象的引用。
refs[key] = toRef(store, key)
}
}
return refs
}
方案对比
响应式API | provide/inject | vuex4 | pinia | |
---|---|---|---|---|
优点 | 简单粗暴,不引入任何其他库,灵活方便,完美支持Vue3组合式写法 | 不引入其他库,单一场景即跨组件传值场景下很方便。通过妙用provide/inject可以实现更优秀的全局状态管理工具 | 强大的社区支持,强的团队协作约定,Vue3组合式写法支持相对友好 | 官方推荐;强的团队协作约定;扁平化store,无模块嵌套;store随用随建,节约资源;Vue3组合式写法支持很友好;友好的TS推导。 |
缺点 | 对状态、更改状态的方法缺少封装性,引入响应式变量后可以对其直接更改;也没有强的团队协作约定。 | 适用场景单一,对状态、更改状态的方法缺少封装性,也没有强的团队协作约定。 | 不友好的TS推导;使用方式繁琐如:单一store,内部模块嵌套,整体成塔字形;store在使用前已经创建好,资源浪费;上手成本高。 | 还比较年轻,可能会有一些兼容性问题。 |
适用 | 简单的状态管理 | 跨组件传值,实现状态管理方案 | 复杂的状态管理,老项目迁移 | 复杂的状态管理,新项目 |