阅读 1350

[Vue.js进阶]从源码角度剖析 Vuex

image

前言

之前几篇解析 Vue 源码的文章都是完整的分析整个源码的执行过程,这篇文章我会将重点放在核心原理的解析

完整源码地址

有兴趣的朋友也可以看我学习源码时的详细注释 源码地址

Vuex 版本:3.1.0

Vuex 简介

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式,通俗的来说就是将原本分散在各个组件的数据,通过一个公共的仓库存储,使得每个组件都能直接从 Vuex 中获取数据,可以把它想象成一个全局变量,但是和全局变量不同的是

  1. Vuex 状态存储是响应式的,当 Vuex 中的状态发生改变,会通知所有依赖到的组件更新数据
  2. 强调状态的可预测,可追踪,所以严格模式无法直接从 Vuex 中修改状态,必须通过提交 mutation 同步修改

当某些数据可能会发生变化,并且被多个不同的组件依赖时,可以考虑将数据放到 Vuex 中存储,例如表格每页显示的最大条数

将最大页数存储在 state 中,一旦用户修改最大页数,需要反映到所有分页器组件,这时只需派发一个 mutation 修改对应的 state 即可

使用 Vuex

使用 Vuex 分为 3 步

  1. 安装 Vuex 插件
  2. 实例化 Vuex 的仓库 Store
  3. 将第二步的实例传入根 Vue 实例中

安装 Vuex 插件核心原理和 vue-router 相同,调用插件暴露的 install 方法,通过 Vue.mixin 全局混入 beforeCreate 钩子,之后每当初始化一个组件,都会生成一个 $store 属性指向根 Vue 实例中的 store 对象,最后所有的组件都可以通过 this.$store 访问根实例中的 store 对象

当我们执行 new Vuex.Store 就会创建一个仓库实例 store

之后将第二步生成的实例注入根 Vue 实例

实例化 Store

Vuex 所有的行为都是围绕 new Vuex.Store 生成的 store 实例展开的,在实例化 Store 的过程中,主要做了三件事

  1. 初始化模块
  2. 安装模块
  3. 创建一个管理所有数据的 Vue 实例

初始化模块

我们知道,Vuex 是支持模块嵌套的,即在一个 Vuex 模块内部,可以通过 modules 属性嵌套子模块,从而形成一个树形的结构,通过模块的划分可以在复杂的情况更好的管理模块,Vuex 将这个树形结构的模块保存在 store 实例的 _modules 属性中

this._modules = new ModuleCollection(options)
复制代码

image.png

ModuleCollection 的实例代表了所有模块的集合,即这个树形结构,我称之为模块树,它在实例化时会调用 register 方法,注册所有模块

rawModule 即 new Vuex.Store 传入的模块配置项,包括根模块在内,每个模块都是 Module 的一个实例,将第一次调用 register 方法传入的模块作为 root 根模块,之后会遍历 modules 对象,递归调用 register 注册子模块

同时子模块会通过 get 方法找到父模块,并通过 addChild 往父模块的 _children 属性添加当前子模块,从而建立父子关系

这里有个非常重要的参数,即 path ,它是一个数组,第一次调用 register 时, path 是一个空数组,每当递归调用时,会将 path 拼接当前子模块的属性名,举个例子

export default new Vuex.Store({
  // 根模块
  modules: {
    // 子模块A
    moduleA: {
      actions: {
        action(context) {context.commit('mutation')},
      },
      mutations: {
        mutation() {}
      },
      
      modules: {
        // 孙子模块B
        moduleB: {
          actions: {
            action(context) {context.commit('mutation')},
          },
          mutations: {
            mutation() {}
          },
        }
        
      }
    },
  }
})
复制代码

在子模块 moduleA中,path 的值为 ["moduleA"],而对于孙子模块 moduleB,path 的值为 ["moduleA,"moduleB"],有了这样的层级关系,就可以通过 path 数组很好的找到对应的模块

安装模块

安装模块和初始化模块的区别在于,初始化模块会建立整个模块树(ModuleCollection ),而安装模块会给模块添加作用于每个模块的 dispatch,mutation,getters 的 context 对象

什么意思呢,以上图为例,当我们在一个 action 中触发一个 mutation 时,一般会通过 action 第一个参数 context 的 commit 属性来触发

但是如果别的模块也存在名为 mutation 的 mutation ,此时就会发生冲突, Vuex 为了解决这个问题引入了命名空间的概念,引用官网的一句话

如果希望你的模块具有更高的封装度和复用性,你可以通过添加 namespaced: true 的方式使其成为带命名空间的模块。当模块被注册后,它的所有 getter、action 及 mutation 都会自动根据模块注册的路径调整命名

当设置 namespaced:true 的模块,其 context 参数中的 commit 只会影响到当前模块下的 mutations,实现方法其实非常的简单:执行 context .commit 最终会给 mutation 拼上模块的命名前缀再执行全局对应的 commit

如果模块没有设置 namespaced 则使用全局的 store.commit,否则会拼上 namespace 再调用全局的 store.commit,而 namespace 是根据之前介绍到的模块的 path 数组生成的命名前缀

getNamespace 会通过 reduce 遍历 path 数组,递归向下遍历子模块,当子模块设置了 namespaced 时会给 namespace 变量拼接当前模块名

所以当 mutation 拼上模块的命名前缀就不会发生冲突,结合之前的例子,因为子模块 moduleA 中的 path 值为 ["moduleA"],所以 mutation 最终会变为 moduleA/mutation ,而孙子模块 moduleB 中的 path 值为 ["moduleA","moduleB"], mutation 最终会变为 moduleA/moduleB/mutation

对于 context.actions 和 context.getters 实现大致的思路也是相同,最后会递归的给模块树(ModuleCollection )的所有子模块生成 context 对象

carbon.png

创建 Vue 实例

之所以 Vuex 中的状态在发生变化时能够通知到所有依赖的组件,是因为 Vuex 在 Store 实例中创建了一个内部的 Vue 实例用来管理所有的状态

根模块的 state 代表了整个 Vuex 的所有数据, Vuex 将根模块的 state 作为 $$state 属性的值保存在内部 Vue 实例中,同时将 wrappedGetters (在安装模块时,会将所有的 getters 保存在这个对象中)中的所有的 getter 作为 computed 属性

通过 Vue 响应式原理可以知道,如果组件通过 this.$store.<prop 名> 依赖到了 Vuex 的某个数据,当 $$state 中的任何状态发生变化,都会触发内部的 setter 函数, 从而通知依赖到的组件发生视图更新

这里再介绍一下 Vuex 中的 getters,它们最终都会变成内部 Vue 实例的 computed 属性, 当某个 getter 依赖的值发生变化会触发重新计算,从而执行 fn(store) 这个函数,store 是的 Store 实例,而 fn 又是什么呢?在安装模块时,会定义 store._wrappedGetters 这个对象,fn 就是 wrappedGetter 这个函数

根据官方文档可以发现,每个 getter 支持 4 个参数,当前模块的 state,当前模块的 getters,全局的 state,全局的 getters,对应 rawGetter 的 4 个参数(rawGetter 即开发者定义的 getter 函数)

Vuex 通过返回一个函数,使其保存了 local(context )对象,又通过传入参数使得能够访问全局的 store 实例,非常灵活的运用了闭包

Vuex 核心 api

Vuex 允许开发者通过 dispatch 派发一个异步的 action,通过 commit 提交一个同步的 mutation

之所以区分异步和同步是为了能够更加准确的追踪状态的变化,因为就像无法准确知道一个响应何时会收到一样,异步操作并不能准确的知道何时修改的数据,所以不能将修改 state 的操作放在 action 中,但是我们可以在异步完成后通过提交一个 commit 的形式同步的修改 state ,同步的特点使得任何状态的变化都能够确切知道执行前后 state 的状态,以便完成一些高级操作, 例如记录日志,时间旅行等

dispatch

在安装模块中给模块添加作用于每个模块的 dispatch 时,会给每个 action 包裹一层函数,作用是保证每个 action 都是一个 Promise

而 store 实例的 dispatch 方法会通过 Promise 的 then 方法解析 action ,当存在同名的 action ( 多个模块含有相同命名的 action 且没有使用命名空间),会使用 Promise.all 并发的解析

commit

通过 commit 方法可以同步的执行一个 mutation,之前提到,在严格模式下 Vuex 规定只有 mutation 才能同步修改数据,因为这样才能方便数据追踪,Vuex 声明了一个 _withCommit 方法,只有调用这个方法才能修改 state,类似一个开关的功能,当执行一个 mutation 时,会调用它使得允许修改 state

至于只有调用 _withCommit 方法才能修改 state 的原理也很简单,因为 state 都被保存在内部 Vue 实例中,通过 Vue 的 $watch 深度监听整个 state 当发现 _committing 为 false 就发出警告

在根模块设置 strict 为 true 开启严格模式时才会启用检查,可能是考虑到深度监听影响性能,所以推荐只在开发环境启用

其他 API 原理

Vuex 还提供了很多其他的 API ,涉及到篇幅原因这里简要介绍下内部实现原理

map 系列的辅助函数

在组件中通过 mapState ,mapActions,mapMutations,mapGetters 辅助函数,可以省去写 this.$store.<prop 名> ,直接使用 this.<prop 名> 这种写法,同时让项目分层更加清晰,也是比较推荐的写法,这些辅助函数最终都会返回一个对象,所以需要使用 ES9 的对象扩展运算符将对象放入对应的 Vue 属性中

同时这些 map 辅助函数可以通过传入多个参数来实现命名空间的功能

核心原理是将传入的第一个参数,也就是命名前缀拼上对应的 state 名(action / mutation / getter 名),去 store 实例中 _modulesNamespaceMap 属性中找到对应模块(Module 实例),因为在安装模块的过程中会给每个模块添加 context 属性,所以这里就可以通过 context 对象拿到作用于当前模块的 state (action / mutation / getter )

至于 _modulesNamespaceMap 是在之前安装模块时生成的,保存了每个模块和对应的命名前缀

拿到 context 对象后,根据不同的功能返回不同的对象给组件

  • state:返回指定模块内部的 state,如果是一个函数就传入 store 实例返回执行后的结果
  • action:返回指定模块 context.dispatch,执行 action 会拼上命名前缀执行 store 的 dispatch
  • mutation: 同 action
  • getter:访问 getter 会拼上命名前缀访问 store.getters 对象对应的 getter

plugins

Vuex 自身也提供了一个插件功能,用于监听 action 和 mutation

原理是采用了观察者模式,声明一个订阅者数组,每当执行完一个 action / mutation 都会遍历数组中所有订阅者依次执行回调,同时回调中还会传入 action / mutation 名和当前的 state 状态,而插件只需要调用 store.subscribeAction / store.subscribe 将订阅者放入数组中即可

  // 调用订阅者的回调函数
 this._subscribers.forEach(sub => sub(mutation, this.state))
复制代码

replaceState

根据传入的参数替换 Vuex 中的 state,Vuex 使用这个 API 实现时光旅行的功能

原理也很简单,只需通过 _withCommit 修改内部 Vue 实例中保存所有状态的 $$state 属性即可,虽然时光旅行只能在开发模式中使用,但是我们可以将它抽象出来,开发一个 plugin 记录每个 mutation 提交时的状态(需要深拷贝)和步骤,调用 replaceState 使数据回滚到指定步骤中

registerModule

Vuex 还提供了动态注册模块的功能,通过传入模块和模块插入的位置,来动态注入到已有的模块树中

介绍模块树 ModuleCollection 时提到它有一个 register 方法,通过传入的 path 数组和模块,插入到模块树中对应的位置,正好对应 registerModule 的 2 个参数,而 registerModule 在将模块插入到整个模块树之后,还会给传入的模块执行安装模块的函数,以及重置 Vue 实例

因为所有的数据都会保存在 store.state 中,所以重置 Vue 实例并不会导致丢失之前的数据

参考资料

Vue.js 技术揭秘

关注下面的标签,发现更多相似文章
评论