Vue3状态管理浅析

每个 Vue 组件实例都可以"管理"自己的响应式状态,同时,我们也可以通过 props 将祖先状态传递下去。但是,在复杂的场景下,比如多个组件可能都依赖于同一份状态、更深层的组件需要共享状态、来自不同组件的交互需要更改同一份状态,那么灵活高效的状态管理方案就是必要的。

响应式API方案

用法说明

Vue3 向我们暴露了响应式 API:reactiveref等,让我们可以抽离和共享响应式变量进而实现一个简单粗暴的状态管理。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 值时使用响应式 apirefreactive等。同时为了防止子孙组件随意更改共享状态,我们一般将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)。

  1. Vuex 的状态存储是响应式的。当 Vue 组件从 store 中读取状态的时候,若 store 中的状态发生变化,那么相应的组件也会相应地得到高效更新。

  2. 你不能直接改变 store 中的状态。改变 store 中的状态的唯一途径就是显式地提交 (commit) mutation。这样使得我们可以方便地跟踪每一个状态的变化,从而让我们能够实现一些工具帮助我们更好地了解我们的应用。

  3. 异步逻辑都应该封装到 action 里面。

二、模块化管理store。

Vuex4

Vuex4 在安装方式上使用了Vuex.createStoreapp.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.providestore添加到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

通过对createStoreappContext以及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 状态管理库

用法说明

  1. Pinia 放弃 mutation,只用action改变state,更改state的链路变得简单。

  2. store通过defineStore创建后,直接被组件import,就可通过useStore访问和更改state

  3. 可以通过storeToRefs解决store被解构后响应式丢失的问题。

  4. store扁平架构,所有store同级且顶级存在(只有一层),没有模块嵌套,但是store直接可以交叉引用(一个storeimport另一个store)。

Pinia 用法主要依赖createPiniadefineStorestoreToRefs三个方法,其组合式用法示例为:

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属性,一个存放所有statestate属性,以及插件列表和提供给外界注册插件的函数。

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中的所有属性,对actionssetup里的函数进行一层包装(例如将原函数的this绑定到store上,以及处理异步函数的情况)。将每个state都存放到全局的state中。

最后,将存放内置apistore与用户定义的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在使用前已经创建好,资源浪费;上手成本高。 还比较年轻,可能会有一些兼容性问题。
适用 简单的状态管理 跨组件传值,实现状态管理方案 复杂的状态管理,老项目迁移 复杂的状态管理,新项目
相关推荐
cs_dn_Jie3 小时前
钉钉 H5 微应用 手机端调试
前端·javascript·vue.js·vue·钉钉
开心工作室_kaic3 小时前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿4 小时前
webWorker基本用法
前端·javascript·vue.js
customer085 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
getaxiosluo6 小时前
react jsx基本语法,脚手架,父子传参,refs等详解
前端·vue.js·react.js·前端框架·hook·jsx
理想不理想v6 小时前
vue种ref跟reactive的区别?
前端·javascript·vue.js·webpack·前端框架·node.js·ecmascript
栈老师不回家7 小时前
Vue 计算属性和监听器
前端·javascript·vue.js
前端啊龙7 小时前
用vue3封装丶高仿element-plus里面的日期联级选择器,日期选择器
前端·javascript·vue.js
小远yyds7 小时前
前端Web用户 token 持久化
开发语言·前端·javascript·vue.js
程序媛小果7 小时前
基于java+SpringBoot+Vue的宠物咖啡馆平台设计与实现
java·vue.js·spring boot