vuex源码分析

March 24, 2021

前言

前文分析了Vue-router,感觉后劲十足,于是开始分析Vuex。在项目上,Vuex也是常客。它可以很好的管理状态,尤其是跨组件的时候,Vue的单向数据流使得子组件无法修改prop,经常用$emit和$on的话组件是要多难看就多难看。当组件切换,数据需要缓存总不能一直依赖于向上级组件emit传递数据吧?如果要更好的管理状态,Vuex是个很好的选择。Vuex代码量较Vue-router少了很多,而且也没有flow的校验机制,看起来更加习惯了。这里介绍的Vuex版本号为2.4.1。

从示例开始

Vue.use(Vuex)
const state = {
  count: 0
}

const mutations = {
  increment (state) {
    state.count++
  },
  decrement (state) {
    state.count--
  }
}

const actions = {
  increment: ({ commit }) => commit('increment'),
  decrement: ({ commit }) => commit('decrement'),
}
// getters are functions
const getters = {
  evenOrOdd: state => state.count % 2 === 0 ? 'even' : 'odd'
}
export default new Vuex.Store({
  state,
  getters,
  actions,
  mutations,
})

上面示例基本上包含了最常用的mutations,getters和actions了。可以发现这一切从Vue.use(Vuex)开始的,对Vue.use不熟悉的可以看上一篇的Vuex-router中对Vue.use的介绍。 Vuex中用到了install方法来提供Vuex的使用环境。和Vue-router不同,Vuex的主要代码功能都在store.js文件里面(这对查阅代码友好度明显提到了不少)。install过程里面用到了Vue.mixin,并用到了beforeCreate钩子,使得Vue实例化和组件加载的时候都可以调用到钩子。设计如下:

const version = Number(Vue.version.split('.')[0])
if (version >= 2) {
  Vue.mixin({ beforeCreate: vuexInit })
} else {
  const _init = Vue.prototype._init
  Vue.prototype._init = function (options = {}) {
    options.init = options.init
      ? [vuexInit].concat(options.init)
      : vuexInit
    _init.call(this, options)
  }
}

function vuexInit () {
  const options = this.$options
  // store injection
  if (options.store) {
    this.$store = typeof options.store === 'function'
      ? options.store()
      : options.store
  } else if (options.parent && options.parent.$store) {
    this.$store = options.parent.$store
  }
}

可以看到这里对Vue的版本分别做了处理,本版是2.0.0及以上的都会采用Vue.mixin的方法,而低版本的,则将修改Vue的内部_init方法,来添加$store至根。高级别的版本则采用mixin的方法,同样也是添加this.$store。在Vue-router里面是采用数据劫持的方法,来通知更新,顺便提供this.$router,对于状态管理而言,数据劫持显然是不需要的,仅仅提供入口this.$store就够了,这样为全局提供了访问store对象的方法,可以轻松得使用this.$store.commit, this.$store.state之类的方法。

Store

Store.js里面最主要的就是Store类,这个也是之前提到的this.$store对象。先看看constructor方法: 在构造里面先判断有无使用install方法,没有则intall一下,接着是断言有无安装Vue,是否支持Promise和是否是通过new创建Store的实例。另外在install过程里面还有是否重复安装Vuex的断言,这个场景会发生在已经先使用Vuex了,但是没有用Vue.use(Vuex)来显式安装Vuex,如果再加上Vue.use(Vuex)就会有这样的提示,尤其是在开发环境和生产环境配置中。 Store初始化过程,有this._modules = new ModuleCollection(options),这个_modules就是Store集合的意思了。Vuex有modules的概念,允许对store进行分割形成不同的模块,每个模块都可以有自己的state,getter,mutation和action,甚至还可以嵌套子模块。于是将这些模块包括根模块一起放入modules里面。this._modules的一个重要api就是注册添加一个模块:

register (path, rawModule, runtime = true) {
  if (process.env.NODE_ENV !== 'production') {
    assertRawModule(path, rawModule)
  }

  const newModule = new Module(rawModule, runtime)
  if (path.length === 0) {
    this.root = newModule
  } else {
    const parent = this.get(path.slice(0, -1))
    parent.addChild(path[path.length - 1], newModule)
  }

  // register nested modules
  if (rawModule.modules) {
    forEachValue(rawModule.modules, (rawChildModule, key) => {
      this.register(path.concat(key), rawChildModule, runtime)
    })
  }
}

这里还可以看到this._modules.root就是根模块,并且对于子模块的,还会被添加到父模块parent的_children对象里面;到这里可以发现this.modules.root和原先的store很像,只是单独分离出state,并且将子模块改为了_children关系,并将_rawMoudule赋值为整个传过来模块,同时为this._modules和每个module都添加不少方法,这些方法自然是为后面做准备的。 在谈commit和dispatch方法之前,先看看后面的模块安装和StoreVM的设置

installModule(this, state, [], this._modules.root)
resetStoreVM(this, state)

function installModule (store, rootState, path, module, hot) {
  const isRoot = !path.length
  const namespace = store._modules.getNamespace(path)
  // 命名空间字典的添加
  if (module.namespaced) {
    store._modulesNamespaceMap[namespace] = module
  }
  // 设置state
  if (!isRoot && !hot) {
    const parentState = getNestedState(rootState, path.slice(0, -1))
    const moduleName = path[path.length - 1]
    store._withCommit(() => {
      Vue.set(parentState, moduleName, module.state)
    })
  }
  const local = module.context = makeLocalContext(store, namespace, path)
  module.forEachMutation((mutation, key) => {
    const namespacedType = namespace + key
    registerMutation(store, namespacedType, mutation, local)
  })
  // 下面省略部分是通过module提供的方法分别对action和getter进行registe
  // 以及对子模块modules的遍历式得注册mutation/action/getter

  // ...
}

对于modules而言,官方文档有介绍到,模块内部的 action,mutation和getter是注册在全局命名空间的,如果想要独立的空间,比如有命名重复的情况下,可以使用namespaced: true来注册单独的空间;同时访问的时候也也要加上模块的名字,否则否则无法定位到。 接着看state的设置,对于if条件语句,若是子模块并且非hot,会获取子模块的亲父级模块,并通过Vue.set方法将该子模块的state添加到亲父模块state里面,这是响应式的,会被Vue劫持到。后面部分就是对action/getter/mutation的注册添加了,这部分后面在讲。

后面是resetStoreVM:

function resetStoreVM (store, state, hot) {
  const oldVm = store._vm
  // bind store public getters
  store.getters = {}
  const wrappedGetters = store._wrappedGetters
  const computed = {}
  forEachValue(wrappedGetters, (fn, key) => {
    // use computed to leverage its lazy-caching mechanism
    computed[key] = () => fn(store)
    Object.defineProperty(store.getters, key, {
      get: () => store._vm[key],
      enumerable: true // for local getters
    })
  })
  const silent = Vue.config.silent
  Vue.config.silent = true
  store._vm = new Vue({
    data: {
      $$state: state
    },
    computed
  })
  Vue.config.silent = silent
  // enable strict mode for new vm
  if (store.strict) {
    enableStrictMode(store)
  }
  // 如果存在oldVM对其进行销毁
  // ... 
}

刚看到这里时候可能会惊奇何时来的_vm?事实上这个_vm正是这里的核心,_vm是个Vue实例,并将_vm.data.$$state指向的option中的state。细心的话还可以发现在Store类中,其中的Store.state:如下

get state () {
  return this._vm._data.$$state
}

state返回的正是_vm.data.$$state,这个也就是平时所用的this.$store.state。观察resetStoreVM还可以发现通过遍历wrappedGetters,来将wrappedGetters中的方法通过_vm.computed的形式添加到store.getters里面,这么复杂的办法有什么好处呢?而且为什么只是专门处理getter,没有对mutation和action进行这样的处理?getter的方法是对state进行处理提取过滤,而computed是依赖于data的,当data更新的时候computed就自动计算,同样这里也是的,当state更新的时候,通过computed的方法,getter不就自动计算更新了吗?只是这样就有点麻烦。。。。。要新建一个Vue实例,关于_vm,更多的可以点这里

commit和dispatch

在介绍之前先看看前面忽略的,在installModule方法里面对mutation/getter/action等方法的添加机制。 对于registerMutation:

function registerMutation (store, type, handler, local) {
  // 内部的_mutations[type]保存对应的mutattion方法
  const entry = store._mutations[type] || (store._mutations[type] = [])
  entry.push(function wrappedMutationHandler (payload) {
    // 在mutation方法里面传入local.state和payload,
    // wrappedMutationHandler只需要payload,符合commit时,仅需传入type和payload
    handler.call(store, local.state, payload)
  })
}

上面方法添加了store._mutations[type],而handler传参里面的local.state又是什么呢?回头看可以发现这里调用了makeLocalContext,生产local变量,makeLocalContext代码这里就不贴出来了。local.state就是对应path的state变量,只是是通过数据劫持的方法获得的,代码中说明getters和state对象都必须要懒加载,因为可能被vm更新影响到,这里是不是指_vm重新创建的时候造成的影响呢?由于namespaced的问题,local里面的dispatch和commit都做了特别处理,但是还是使用store的dispatch和commit的方法,只是传参做了修改。 对于registerAction,类似与mutation,采用了store._actions[type]来保存handler数组,但由于action有用于异步的情况,所以若返回的action不是Promise类型,则进行Promise包装。同时action的传参不是local.state,而是传入local的本身的所有字段和store的getters以及state,这也符合action的基本应用。 对于registerGetter,这里比较简单直接采用store._wrappedGetters[type] = handler的形式,而registerMutation是采用数组的形式。所以对于重复名字的getter就会有告警``[vuex] duplicate getter key: ${type}`。

回到commit方法和dispatch,在Store类构造的时候,有如下:

this.dispatch = function boundDispatch (type, payload) {
  return dispatch.call(store, type, payload)
}
this.commit = function boundCommit (type, payload, options) {
  return commit.call(store, type, payload, options)
}

这里面定义commit方法和dispatch方法,这两个就是$store.commit$store.dispatch,而commit这个方法处理起来也是比较简单,就是将_mutations里面对应方法名都执行一遍,并传递payload进去。同时还将_subscribers里面的函数都遍历执行。_subscribers是通过subscribe这个api添加进来:

subscribe (fn) {
  const subs = this._subscribers
  if (subs.indexOf(fn) < 0) {
    subs.push(fn)
  }
  return () => {
    const i = subs.indexOf(fn)
    if (i > -1) {
      subs.splice(i, 1)
    }
  }
}

该方法可以添加订阅函数,每当mutation执行的时候,所有订阅函数都会执行,值得一提的时候在devtool.js文件里面用到了:

store.subscribe((mutation, state) => {
  devtoolHook.emit('vuex:mutation', mutation, state)
})

当使用devtoolHook的时候(这个也涉及到Vue官方推荐的浏览器插件工具Vue devtools)能在每个mutation动作结束后,触发vuex:mutation事件,并在devtools插件内打印动作 还可以看出这个subscribe设计很巧妙,subscribe直接运行是添加订阅函数,而其返回函数就是disSubscribe,就是将订阅函数去除掉,由于不常用,所以就没有直接给出api了,厉害的很。

dispatch该动作类似的,也是调用之前存在_actions里的handlers,只是由于handles可能有多个,并且是异步的原因,若是多个的话需要用Promise.all来执行;

其他Api

日常用的比较多的是registerModule/unregisterModule,两个过程是类似的,注册新模块的时候需要重新installModule和resetStoreVM,这个时候就会将老的_vm delete掉,重新实例化Vue给到_vm。 mapState/mapMutations/mapGetters/mapActions等api结构类似。以mapState为例子:

export const mapState = normalizeNamespace((namespace, states) => {
  const res = {}
  normalizeMap(states).forEach(({ key, val }) => {
    res[key] = function mappedState () {
      let state = this.$store.state
      let getters = this.$store.getters
      if (namespace) {
        const module = getModuleByNamespace(this.$store, 'mapState', namespace)
        if (!module) {
          return
        }
        state = module.context.state
        getters = module.context.getters
      }
      return typeof val === 'function'
        ? val.call(this, state, getters)
        : state[val]
    }
    // mark vuex getter for devtools
    res[key].vuex = true
  })
  return res
})

normalizeNamespace来调整参数,再通过normalizeMap将传入的state调整为{ key, val: key }结构,并根据情况返回。这几个api还是很容易懂的。

结束

一周下来写了两篇源码分析,Vuex的代码和Vue-router相比还是很良心的,没有Vue-router里面那么多弯弯绕绕,Vuex简单明了多了。