首发于不止前端

Vuex源码解析二

Anli Li:Vuex源码解析一zhuanlan.zhihu.com图标Anli Li:Vuex源码解析二zhuanlan.zhihu.com图标Anli Li:Vuex源码解析三zhuanlan.zhihu.com图标Anli Li:Vuex源码解析四及对比Reduxzhuanlan.zhihu.com图标

module

之所以先看Module,是因为生成Module的次序其实是在前,只有Module在创建Vuex.Store的时候准备好,才能安心的去commit,去dispatch,可以说创建Vuex.Store的过程其实基本就是准备Module的过程。


这一次用debug的方式来跟踪源码,先是不指定namespace参数,我们准备一下,在examples目录下新建一个deepinside目录,然后加入如下module模块文件

 // moduleA.js
 import moduleAA from "./moduleAA";
 
 const state = {
   count: 0
 };
 
 const mutations = {
   increment(state) {
     state.count++;
   },
   decrement(state) {
     state.count--;
   }
 };
 
 export default {
   //namespaced: true,
   state,
   mutations,
   modules: {
     moduleAA
   }
 };
 //moduleAA.js
 const state = {
   count: 0
 };
 
 const mutations = {
   increment(state) {
     state.count++;
   },
   decrement(state) {
     state.count--;
   }
 };
 
 export default {
   //namespaced: true,
   state,
   mutations
 };
 //moduleB.js
 const state = {
   count: 0
 };
 
 const mutations = {
   increment(state) {
     state.count++;
   },
   decrement(state) {
     state.count--;
   }
 };
 
 export default {
   //namespaced: true,
   state,
   mutations
 };
 //store.js
 const state = {
   count: 0
 };
 
 const mutations = {
   increment(state) {
     state.count++;
   },
   decrement(state) {
     state.count--;
   }
 };
 
 export default new Vuex.Store({
   state,
   mutations,
   modules: {
     moduleA,
     moduleB
   }
 })

这样形成了一个树状的module结构,注意我特意用了同样的mutations名字

  • store
    • moduleA
      • moduleAA
    • moduleB

其实我已经默认了在module配置的时候是可以用一个复杂的树状结构来表示的,接下来就是用debug见证奇迹的时刻了。

Storeconstructor里打上断点,静悄悄的观察起来,注意这一句

 this._modules = new ModuleCollection(options)

钻进去瞅一下

 constructor (rawRootModule) {
     // register root module (Vuex.Store options)
     this.register([], rawRootModule, false)
 }

继续深入到register方法

 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)
       })
     }
 }

一看就是递归组装树,也就是从

生成了这样一个对象

其实变化并不大,只能称得上是一种Transform。

看看此时的store对象,modules已经准备好了,但许多其他的属性都还空着。

状态数据也被抽到了一起

接下来看关键的一步,顺便翻译一下注释

 // init root module. 初始化根module
 // this also recursively registers all sub-modules 并且递归注册所有的子module
 // and collects all module getters inside this._wrappedGetters 收集所有的getter
 installModule(this, state, [], this._modules.root)

抛开检查、注释等语句,installModule方法里主要是

 // register in namespace map
 if (module.namespaced) {
   store._modulesNamespaceMap[namespace] = module
 }
 
   // set 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)
   })
 }
 
 module.forEachMutation((mutation, key) => {
   const namespacedType = namespace + key;
   registerMutation(store, namespacedType, mutation, local);
 });
 
 module.forEachAction((action, key) => {
   const type = action.root ? key : namespace + key;
   const handler = action.handler || action;
   registerAction(store, type, handler, local);
 });
 
 module.forEachGetter((getter, key) => {
   const namespacedType = namespace + key;
   registerGetter(store, namespacedType, getter, local);
 });
 
 module.forEachChild((child, key) => {
   installModule(store, rootState, path.concat(key), child, hot);
 });
  1. 准备_modulesNamespaceMap的内容
  2. 设置每一个非根部modulestate的内容,注意用的是Vue.set语句,使其响应式了,并且是通过提交commit(_withCommit())的过程形式进行的
  3. 递归把每个当前层的modulemutationaction·、getter给准备好

抽一个forEachMutation看看

 forEachMutation (fn) {
     if (this._rawModule.mutations) {
       forEachValue(this._rawModule.mutations, fn)
     }
 }

整个installModule方法跑完后再来看store对象,mutations已经准备好了。

之前定义的mutation都叫incrementdecrement,这里把各个模块的都打平放到了一起,但它们并不相等,测试一下

 this._mutations.increment[0] === this._mutations.increment[1] // 返回false

下一步也非常重要,也把注释翻译一下

 // initialize the store vm, which is responsible for the reactivity 初始化store vm 响应变化
 // (also registers _wrappedGetters as computed properties) 并注册_wrappedGetters为计算属性
 resetStoreVM(this, state)

为什么要响应变化,因为在各个Vue实例里都用到了$store的数据,每当数据发生变化的时候,必须要通知到各个使用的地方。我的第一反应是通过Vue实例,事实果然印证了我的第八感。

之前已经创建了名为_wrappedGetters的对象,是在registerGetter里。

 store._wrappedGetters[type] = function wrappedGetter (store) {
     return rawGetter(
       local.state, // local state
       local.getters, // local getters
       store.state, // root state
       store.getters // root getters
     )
 }

我们知道Vuexgetter的用法和Vuecomputed用法是很相似的,所以在getter上也是要建立响应机制的。再到resetStoreVM方法里

 forEachValue(wrappedGetters, (fn, key) => {
     computed[key] = partial(fn, store)
     Object.defineProperty(store.getters, key, {
       get: () => store._vm[key],
       enumerable: true // for local getters
     })
 })

getterstore._vm建立起关联,而store._vm马上就来

 store._vm = new Vue({
     data: {
       $$state: state
     },
     computed
 })

整个state都放入了Vue实例中,每当state发生变化时,就是通知到使用它的地方做更新,而在store上暴露出去的state就是这里的_vm._data.$$state

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

源码里还缓存了原来的_vm,这是在热更新或者动态注册module的时候会用到。

 const oldVm = store._vm
 //...
 if (oldVm) {
     if (hot) {
       store._withCommit(() => {
         oldVm._data.$$state = null
       })
     }
     Vue.nextTick(() => oldVm.$destroy())
 }

最后就是应用插件了

 plugins.forEach(plugin => plugin(this))
 
 const useDevtools = options.devtools !== undefined ? options.devtools : Vue.config.devtools
 if (useDevtools) {
   devtoolPlugin(this)
 }

回顾一下整个的步骤

  1. 如果没有通过插件形式注入mixin,那么先注入它,保证每个Vue实例都能拿到$store
  2. 初始化一系列的内部对象,其中最重要的是_modules,它其实是传入的optionsmodule部分映射的对象
  3. (不设namespace时)把module里的mutationaction·、getter都抽出来,打平放一起,不用管原来它们属于哪个module,按名字分配到同一个数组里即可
  4. 为数据响应做好准备
  5. 应用配置的插件


注意刚才我们构造的是特殊场景,即所有的module都配置了相同名称的mutation。写一个组件来测试一下

 <template>
   <div id="app">
     {{$store.state.count}}
     <button @click="increment">+</button>
   </div>
 </template><script>
 export default {
   methods: {
     increment() {
       this.$store.commit('increment')
       console.log(this.$store.state)
     }
   }
 }
 </script>

执行后发现,所有module上的count都被加了1,通过debug后发现,Store接到commit后,在_mutations里找到了名为increment的数组,每一个都执行了一遍,导致所有module上的count都被加了1,这也即是namespace诞生的原因。

module配置里有一项namespace的属性,现在我们在每个module配置里把它加上并设置为true。

 export default {
   namespaced: true,
   state,
   mutations,
   modules: {
     moduleAA
   }
 };

此时再来观察一下生成的_mutations数组

发现每一个mutation都用path + action name来作为key,这样子就减少了冲突。当我们再次执行this.$store.commit('increment')的时候就只针对了根部的module了。

而带namespace的场景下子module的分发commit默认只针对了该module,详情在后面章节里会有介绍。

Vuex在内部其实已经做好了检查,在installModule

 // register in namespace map
 if (module.namespaced) {
   if (store._modulesNamespaceMap[namespace] && process.env.NODE_ENV !== 'production') {
     console.error(`[vuex] duplicate namespace ${namespace} for the namespaced module ${path.join('/')}`)
   }
   store._modulesNamespaceMap[namespace] = module
 }
 
   // set state
 if (!isRoot && !hot) {
   const parentState = getNestedState(rootState, path.slice(0, -1))
   const moduleName = path[path.length - 1]
   store._withCommit(() => {
     if (process.env.NODE_ENV !== 'production') {
       if (moduleName in parentState) {
         console.warn(
           `[vuex] state field "${moduleName}" was overridden by a module with the same name at "${path.join('.')}"`
         )
       }
     }
     Vue.set(parentState, moduleName, module.state)
   })
 }

针对namespacemodule的名字是否重复,都做了检查与提醒。Redux里针对同名的action,并没有做特殊的处理,可以见我之前的文章zhuanlan.zhihu.com/p/37。不过在dva里,还是考虑了把namespace放入action作为前缀。

第二个问题来了,如果我们直接修改Storestate是否可行,我的第一反应是可以的,因为从刚才的源码上也看出来了,Vuex已经做了响应式,它把内部的state放入了Vue实例的data中,一旦有Vue实例,那么就自然而然的有响应式变化了。

继续做实验,直接修改state

 // 在组件里直接修改state值
 increment() {
   this.$store.state.count = 100
 }

结果当然是界面上数值更新为100,那么使用commit来提交变化是否还有意义呢?

答案当然是有的,使用commit提交修改状态数据的话,有助于做手脚,比如打日志,跟踪数据变化,关联上浏览器开发调试插件等,或者其他一些想做的事情。如果你想要更灵活自定义一些操作,还有plugin或者subscribe等方式可选。

Vuex也有一个strict的配置项,如果设置为true的话,直接修改state将会抛错。

 function enableStrictMode (store) {
   store._vm.$watch(function () { return this._data.$$state }, () => {
     if (process.env.NODE_ENV !== 'production') {
       assert(store._committing, `do not mutate vuex store state outside mutation handlers.`)
     }
   }, { deep: true, sync: true })
 }

动态module

动态注册主要是使用registerModule方法

 // 注册模块 `myModule`
 store.registerModule('myModule', {
   // ...
 })
 // 注册嵌套模块 `nested/myModule`
 store.registerModule(['nested', 'myModule'], {
   // ...
 })

也能用unregisterModule把动态注册的module给卸载掉

 registerModule (path, rawModule, options = {}) {
   if (typeof path === 'string') path = [path]
 
   if (process.env.NODE_ENV !== 'production') {
     assert(Array.isArray(path), `module path must be a string or an Array.`)
     assert(path.length > 0, 'cannot register the root module by using registerModule.')
   }
 
   this._modules.register(path, rawModule)
   installModule(this, this.state, path, this._modules.get(path), options.preserveState)
   // reset store to update getters...
   resetStoreVM(this, this.state)
 }
 
 unregisterModule (path) {
   if (typeof path === 'string') path = [path]
 
   if (process.env.NODE_ENV !== 'production') {
     assert(Array.isArray(path), `module path must be a string or an Array.`)
   }
 
   this._modules.unregister(path)
   this._withCommit(() => {
     const parentState = getNestedState(this.state, path.slice(0, -1))
     Vue.delete(parentState, path[path.length - 1])
   })
   resetStore(this)
 }

对于注册和卸载,都是要再走一下初始化时的那些流程的,比如卸载的时候在挪走了模块、解除了上下关联关系之后,会调用resetStore方法

 function resetStore (store, hot) {
   store._actions = Object.create(null)
   store._mutations = Object.create(null)
   store._wrappedGetters = Object.create(null)
   store._modulesNamespaceMap = Object.create(null)
   const state = store.state
   // init all modules
   installModule(store, state, [], store._modules.root, true)
   // reset vm
   resetStoreVM(store, state, hot)
 }

搭车说一下热重载hotUpdate方法,它内部也主要是调用了resetStore重新整一遍内部的数据属性。

编辑于 03-09

文章被以下专栏收录