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

image

前言

之前几篇解析 Vue 源码的文章都是完整的分析整个源码的执行过程,这篇文章我会将重点放在核心原理的解析,不会具体解释每个函数的执行顺序,调用栈情况

完整源码地址

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

Vuex 版本:3.1.0

Vuex 简介

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

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

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

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

使用 Vuex

使用 Vuex 分为 3 步

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

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

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

 actions: {
    action(context ) {
      context .commit('mutation')
    }
  }

但是如果别的模块也存在名为 mutationA 的 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"],所以 action 最终会变为 moduleA/action ,而孙子模块 moduleB 中的 path 值为 ["moduleA","moduleB"], action 最终会变为 moduleA/moduleB/action

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

carbon.png

创建 Vue 实例

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

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

原理是采用了观察者模式,声明一个 subs 数组,每当执行完一个 action / mutation 都会遍历所有数组依次执行回调,而插件只需要调用 store.subscribeAction / store.subscribe 将插件放入 subs 数组中即可

  // 调用订阅者的回调函数
 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 实例并不会导致丢失之前的数据

找呀找呀找工作~

本人为18年毕业本科生,坐标上海,1年多的前端开发经验,希望找一个在前端领域有一定深度和规模团队的互联网企业,欢迎在评论区能留下联系方式或者联系我的邮箱1996yeyan@gmail.com,非常感谢~

参考资料

Vue.js 技术揭秘

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,723评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,485评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,998评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,323评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,355评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,079评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,389评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,019评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,519评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,971评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,100评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,738评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,293评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,289评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,517评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,547评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,834评论 2 345

推荐阅读更多精彩内容

  • 安装 npm npm install vuex --save 在一个模块化的打包系统中,您必须显式地通过Vue.u...
    萧玄辞阅读 2,926评论 0 7
  • ### store 1. Vue 组件中获得 Vuex 状态 ```js //方式一 全局引入单例类 // 创建一...
    芸豆_6a86阅读 726评论 0 3
  • ### store 1. Vue 组件中获得 Vuex 状态 ```js //方式一 全局引入单例类 // 创建一...
    芸豆_6a86阅读 339评论 0 0
  • State 单一状态树 Vuex使用单一状态树——用一个对象就包含了全部的应用层级状态。至此它便作为一个“唯一数据...
    oWSQo阅读 1,087评论 0 0
  • redux数据请求机制 图片来源于:https://segmentfault.com/img/bVRQRK?w=1...
    dwy_interesting阅读 363评论 0 0