Vuex - 原始碼概覽

清夜發表於2018-11-09

本文以 vuex v3.0.1版本進行分析

install

vuex提供了一個 install方法,用於給 vue.use進行註冊,install方法對 vue的版本做了一個判斷,1.x版本和 2.x版本的外掛註冊方法是不一樣的:

// vuex/src/mixin.js
if (version >= 2) {
  Vue.mixin({ beforeCreate: vuexInit })
} else {
  // override init and inject vuex init procedure
  // for 1.x backwards compatibility.
  const _init = Vue.prototype._init
  Vue.prototype._init = function (options = {}) {
    options.init = options.init
      ? [vuexInit].concat(options.init)
      : vuexInit
    _init.call(this, options)
  }
}
複製程式碼

對於 1.x版本,直接將vuexInit方法混入到 vueInit方法中,當 vue初始化的時候,vuex也就隨之初始化了 而對於 2.x版本,則是通過混入 mixin的方式,全域性混入了一個 beforeCreated鉤子函式

這個 vuexInit方法如下:

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
  }
}
複製程式碼

目的很明確,就是把 options.store 儲存在 this.$store中,options就是在 new一個 vue例項的時候,傳入的引數集合物件,如果想使用 vuex的話,肯定要把 store傳進來的,類似於下面的程式碼,所以可以拿到 options.storethis指的是當前 Vue例項,這個options.store 就是Store物件的例項,所以可以在元件中通過 this.$store訪問到這個 Store例項

const app = new Vue({
  el: '#app',
  // 把 store 物件提供給 “store” 選項,這可以把 store 的例項注入所有的子元件
  store,
  components: { Counter },
  template: '<div class="app"></div>'
})
複製程式碼

Store

上面在 beforeCreate生命週期中會拿到 options.store,這個 store自然也有初始化的過程

每一個 Vuex 應用的核心就是 store,所以需要有 Store的初始化過程,下面是一個最簡單的 Store示例(來源於 vuex的官網):

const store = new Vuex.Store({
  state: {
    count: 0
  },
  mutations: {
    increment (state) {
      state.count++
    }
  }
})
複製程式碼

Store的原始碼位於 vuex/src/store.js,在這個類的 constructor中,new了一個 vue例項,所以vuex可以使用 vue的很多特性,比如資料的響應式邏輯

初始化Store的過程中,其實也是對 modulesdispatchcommit等進行了初始化操作

初始化module,構建module tree

Store類的初始化函式 constructor中,下面這句就是 modules的初始化入口:

this._modules = new ModuleCollection(options)
複製程式碼

ModuleCollection是一個 ES6的類

// src/module/module-collection.js
constructor (rawRootModule) {
  // register root module (Vuex.Store options)
  this.register([], rawRootModule, false)
}
複製程式碼

這個類的 constructor中呼叫了 register方法,第二個引數 rawRootModule就是 Store初始化時傳進來引數物件 options

// src/module/module-collection.js
register (path, rawModule, runtime = true) {
    // ...省略無關程式碼

    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)
      })
    }
  }
複製程式碼

register方法中會 new Module,這個 Module就是用來描述單個模組的類,裡面定義了與單個模組相關(module)的資料結構(data struct)、屬性以及方法,大概如下:

Vuex - 原始碼概覽

這裡面的方法、屬性等,都和後續構建 Module Tree有關

由於每個 module都有其自己的 statenamespacedactions等,所以在初始化 module的過程中,也會給每個 module物件上掛載這些屬性或方法,例如,下面就是掛載 state的程式碼:

// src/module/module.js
this._rawModule = rawModule
const rawState = rawModule.state

// Store the origin module's state
this.state = (typeof rawState === 'function' ? rawState() : rawState) || {}
複製程式碼

開始初始化的時候,符合path.length === 0,所以執行 this.root = newMudle,接著遇到了 if (rawModule.modules)這個判斷語句,前面說了 rawModule是傳入的 options,所以這裡的 rawModule.modules就是類似下面示例程式碼 ExampleA中的modules

/**
 * 示例程式碼 ExampleA
 */
const store = new Vuex.Store({
  // ... 省略無關程式碼
  modules: {
    profile: {
      state: { age: 18 },
      getters: {
        age (state) {
          return state.age
        }
      }
    },
    account: {
      namespaced: true,

      state: {
        isAdmin: true,
        isLogin: true
      },
      getters: {
        isAdmin (state) {
          return state.isAdmin
        }
      },
      actions: {
        login ({ state, commit, rootState }) {
          commit('goLogin')
        }
      },
      mutations: {
        goLogin (state) {
          state.isLogin = !state.isLogin
        }
      },

      // 巢狀模組
      modules: {
        // 進一步巢狀名稱空間
        myCount: {
          namespaced: true,
          state: { count: 1 },
          getters: {
            count (state) {
              return state.count
            },
            countAddOne (state, getters, c, d) {
              console.log(123, state, getters, c, d);
              return store.getters.count
            }
          },
          actions: {
            addCount ({ commit }) {
              commit('addMutation')
            },
            delCount ({ commit }) {
              commit('delMutation')
            },
            changeCount ({ dispatch }, { type } = { type: 1 }) {
              if (type === 1) {
                dispatch('addCount')
              } else {
                dispatch('delCount')
              }
            }
          },
          mutations: {
            addMutation (state) {
              console.log('addMutation1');
              state.count = state.count + 1
            },
            delMutation (state) {
              state.count = state.count - 1
            }
          }
        },

        // 繼承父模組的名稱空間
        posts: {
          state: { popular: 2 },
          getters: {
            popular (state) {
              return state.popular
            }
          }
        }
      }
    }
  }
})
複製程式碼

所以這個判斷是用於處理使用了 module的情況,如果存在 modules,則呼叫 forEachValuemodules這個物件進行遍歷處理

export function forEachValue (obj, fn) {
  Object.keys(obj).forEach(key => fn(obj[key], key))
}
複製程式碼

拿到 modules裡面存在的所有 module,進行 register操作,這裡面的 key就是每個 module的名稱,例如 示例程式碼 ExampleA中的 profileaccount

到這裡再次呼叫 this.register方法的時候,path.length === 0就不成立了,所以走 else的邏輯,這裡遇到了一個this.get方法:

// src/module/module-collection.js
get (path) {
  return path.reduce((module, key) => {
    return module.getChild(key)
  }, this.root)
}
複製程式碼

首先對 path進行遍歷,然後對遍歷到的項呼叫 getChild,這個getChild方法是前面 Module類中的方法,用於根據 key,也就是在當前模組中根據模組名獲取子模組物件,與之對應的方法是 addChild,是給當前模組新增一個子模組,也就是建立父子間的關聯關係:

// src/module/module.js
this._children = Object.create(null)
// ...
addChild (key, module) {
  this._children[key] = module
}
// ...
getChild (key) {
  return this._children[key]
}
複製程式碼

看到這裡應該就有點思路了,上述一系列操作實際上就是為了以模組名作為屬性 key,遍歷所有模組及其子模組,構成一棵以 this.root為頂點的 Modules Tree,畫成流程圖的話會很清晰:

Vuex - 原始碼概覽

安裝 module tree

上面構建好一棵 module tree之後,接下來就要 install這棵樹了

// src/store.js
const state = this._modules.root.state

installModule(this, state, [], this._modules.root)
複製程式碼

這個方法裡做了很多事情,一個個看

首先是對名稱空間 namespaced的處理,如果發現當前 module具有 namespaced屬性並且值為 true,則會將其註冊到 namespace map,也就是存起來:

const namespace = store._modules.getNamespace(path)

// register in namespace map
if (module.namespaced) {
  store._modulesNamespaceMap[namespace] = module
}
複製程式碼

其中 getNamespace方法就是 ModuleCollection類上的方法,用於根據 path拼接出當前模組的完整 namespace

getNamespace (path) {
  let module = this.root
  return path.reduce((namespace, key) => {
    module = module.getChild(key)
    return namespace + (module.namespaced ? key + '/' : '')
  }, '')
}
複製程式碼

呼叫 getNamespace獲得名稱空間名稱,然後將名稱空間名稱作為 key,將對應的名稱空間所指的 module物件作為 value快取到 store._modulesNamespaceMap上,方便後續根據 namespace查詢模組,這個東西是可以通過 this.$store._modulesNamespaceMap取到的,例如,對於 ExampleA中的示例:

Vuex - 原始碼概覽

接下來是一個判斷邏輯,符合 !isRoot && !hot條件才能執行,這裡的 isRoot是在 installModule方法的開頭定義的:

const isRoot = !path.length
複製程式碼

path就是 module tree維護的 module父子關係的狀態,當 path.length !== 0時,isRoot就是 true,其實這裡就是判斷當前安裝的模組是不是 root模組,也就是 module tree最頂層的節點,這個節點的 path.length 就是 0

由於 module的安裝,在 module tree上就是從父級到子級,一開始執行 installModule方法時,傳入的 path[],則path.length === 0,所以會執行判斷語句裡面的程式碼

設定 state

// src/store.js
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)
  })
}
複製程式碼

呼叫了 getNestedState

function getNestedState (state, path) {
  return path.length
    ? path.reduce((state, key) => state[key], state)
    : state
}
複製程式碼

這裡實際上是通過一層層 path.reduce來查詢最終的子模組的 state

例如,對於 account/myCount下的 state來說,它的 path['account', 'myCount'],全域性 state結構如下:

{
  profile: { ... },
  account: {
    isAdmin: true,
    isLogin: true,
    // 這是子模組 myCount的名稱空間
    myCount: {
    // 這是子模組myCount的state
      count: 1
    },
    posts: {
      popular: 2
    }
  }
}
複製程式碼

當對這個全域性 statepath = ['account', 'myCount']呼叫 getNestedState方法時,最終將得到 /myCountstate

{
  count: 1
}
複製程式碼

查詢到具體子模組的 state後,掛載到 store._withCommit上,至於為什麼掛到這上,這裡暫且不分析,後面會說到

構建本地上下文

接下來會執行一個 makeLocalContext方法:

const local = module.context = makeLocalContext(store, namespace, path)
複製程式碼

關於這個方法的作用,在它的註釋上已經大概描述了一遍:

/**
 * make localized dispatch, commit, getters and state
 * if there is no namespace, just use root ones
 */
function makeLocalContext (store, namespace, path) {
  // ...
}
複製程式碼

大概意思就是,本地化 dispatchcommitgetterstate,如果(當前模組)沒有 namespace,則直接掛載到 root module

可能還是不太明白說的是什麼意思,實際上,這就是對名稱空間模組的一個處理,是為了在呼叫相應模組的 dispatchcommitgetters以及 state的時候,如果模組使用用了名稱空間,則自動在路徑上追加上 namespace

比如,對於 dispath而言,如果當前模組存在 namespace,則在呼叫這個模組的 dispatch方法的時候,把 namespace拼接到 type上,然後根據這個拼接之後的 type來查詢 store上的方法並執行:

// makeLocalContext
dispatch: noNamespace ? store.dispatch : (_type, _payload, _options) => {
    const args = unifyObjectStyle(_type, _payload, _options)
    const { payload, options } = args
    let { type } = args

    if (!options || !options.root) {
      type = namespace + type
      if (process.env.NODE_ENV !== 'production' && !store._actions[type]) {
        console.error(`[vuex] unknown local action type: ${args.type}, global type: ${type}`)
        return
      }
    }

    return store.dispatch(type, payload)
  }
複製程式碼

例如,對於 ExampleA程式碼而言,想要改變 account/myCount下的 count值,可以直接全域性呼叫 this.$store.dispatch('account/myCount/changeCount'), 當 type = 1的時候又會執行 dispatch('addCount'),這個 dispatch其實是想要執行 account/myCount模組下的 addCount這個 actions,而不是 root module下的 addCount

於是,這裡就進行了一個全路徑 type的拼接,將當前模組的 namespacetype拼接到一起,即 account/myCount/addCount的拼接,最後就拼接成了 account/myCount/addCount,正是我們想要的 path,最後將這個全路徑 type作為引數傳給 store.dispatch方法,這個過程主要是簡化了巢狀 module路徑的拼接

commit的邏輯與此類似,不過 getterstate 就有點不一樣了

// src/store.js
// makeLocalContext

Object.defineProperties(local, {
  getters: {
    get: noNamespace
      ? () => store.getters
      : () => makeLocalGetters(store, namespace)
  },
  state: {
    get: () => getNestedState(store.state, path)
  }
})
複製程式碼

對於 getter,如果沒有 namspace則直接返回 store.getters,否則就呼叫 makeLocalGetters

// src/store.js
function makeLocalGetters (store, namespace) {
  const gettersProxy = {}

  const splitPos = namespace.length
  Object.keys(store.getters).forEach(type => {
    // skip if the target getter is not match this namespace
    if (type.slice(0, splitPos) !== namespace) return

    // extract local getter type
    const localType = type.slice(splitPos)

    // Add a port to the getters proxy.
    // Define as getter property because
    // we do not want to evaluate the getters in this time.
    Object.defineProperty(gettersProxy, localType, {
      get: () => store.getters[type],
      enumerable: true
    })
  })

  return gettersProxy
}
複製程式碼

直接看這段程式碼可能不太清晰,所以這裡帶入一個例子看,比如對於 account/myCount/count這個 getter來說(即上述原始碼中的 type),它的 namespace就是 account/myCount/,它的 localType就是 count,當訪問 gettersProxy.count這個 getters的時候,會自動指向全域性的 account/myCount/count

然後是 state,呼叫了 getNestedState,這個方法前面已經說過了,作用和上面的大體一致,就不多說了

另外,這個過程中多次用到 Object.defineProperty來設定給物件上的屬性設定 get函式,而不是直接給屬性賦值,例如上面的 localType,這種做法的目的在程式碼上也已經註釋得很清楚了,就是為了能夠做到在訪問的時候才計算值,既減少了實時運算量,主要是又能夠保證獲取到的值是實時準確的,這個跟 vue的響應式機制相關,這裡就不多說了

綜上, makeLocalContext這個方法實際上就是做了一個具有名稱空間的子模組的 dispatchcommitgetterstate到全域性的對映:

Vuex - 原始碼概覽

vuex的官網在介紹 Actions 這一節的時候,有這麼一段話:

Vuex - 原始碼概覽

其中, Action 函式接受一個與 store 例項具有相同方法和屬性的 context 物件這句話裡的 context物件指的就是這裡本地化的 module物件

註冊 mutation action getter

Mutation

首先是 Mutation

// src/store.js
module.forEachMutation((mutation, key) => {
  const namespacedType = namespace + key
  registerMutation(store, namespacedType, mutation, local)
})
複製程式碼

這個 forEachMutation方法是掛在 module例項上的,這個方法沒什麼好說的,作用就是遍歷當前 module上的 mutations,然後將這些 mutation作為引數傳入 registerMutation方法中:

// src/store.js
function registerMutation (store, type, handler, local) {
  const entry = store._mutations[type] || (store._mutations[type] = [])
  entry.push(function wrappedMutationHandler (payload) {
    handler.call(store, local.state, payload)
  })
}
複製程式碼

該方法是給 root store 上的 _mutations[types] 新增 wrappedMutationHandler 方法(至於這個方法是幹什麼的是另外的問題,這裡暫且不去看),而且 store._mutations[type]的值是一個陣列,也就是說同一個type_mutations 是可以對應多個 wrappedMutationHandler方法的

例如,對於 ExampleA中的 account/myCount這個 module來說,如果它的 namespaced屬性不存在,或者其值是 false,即沒有單獨的名稱空間,然後它的 mutations中又有個叫 goLogin的方法,這個方法在 account這個 modulemutations中同樣存在,於是 state._mutations['account/goLogin']的陣列中就存在了兩項,一個是 account下的 goLogin方法,一個是 account/myCount下的 goLogin方法

而如果 account/myCountnamespacedtrue,就不存在這種情況了,因為這個時候,它的goLogin對應的 typeaccount/myCount/goLogin

action

// src/store.js
module.forEachAction((action, key) => {
  const type = action.root ? key : namespace + key
  const handler = action.handler || action
  registerAction(store, type, handler, local)
})
複製程式碼

邏輯其實和上面的 Mutation差不多,都是遍歷所有的 actions,然後掛到 store的某個屬性上,只不過 action是掛到 store._actions上,同樣的,對於同一個 key,也可以對應多個 action方法,這也跟名稱空間有關

getter

// src/store.js
module.forEachGetter((getter, key) => {
  const namespacedType = namespace + key
  registerGetter(store, namespacedType, getter, local)
})
複製程式碼

getter和上面的邏輯也都是差不多的,遍歷所有的 getter,然後掛到 store的某個屬性上,只不過 getter是掛到 store._wrappedGetters上,另外,對於同一個 key,只允許存在一個值,如果存在多個值,則以第二個為準:

// src/store.js
function registerGetter (store, type, rawGetter, local) {
  if (store._wrappedGetters[type]) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(`[vuex] duplicate getter key: ${type}`)
    }
    return
  }
  store._wrappedGetters[type] = function wrappedGetter (store) {
    return rawGetter(
      local.state, // local state
      local.getters, // local getters
      store.state, // root state
      store.getters // root getters
    )
  }
}
複製程式碼

最後,如果當前模組具有子模組,則遍歷其所有子模組,給這些子模組執行 installModule方法,也就是把上面的步驟再次走一遍

至此,installModule方法就執行完了,這裡再回頭整體看一遍, 呼叫 installModule這個方法的時候,程式碼上面有兩行註釋:

// src/store.js

// init root module.
// this also recursively registers all sub-modules
// and collects all module getters inside this._wrappedGetters
installModule(this, state, [], this._modules.root)
複製程式碼

大概意思就是:

初始化 root module
同時也會遞迴地註冊所有子 module
並且會將所有 module的 getters 收集到 this._wrappedGetters上
複製程式碼

經過上述的分析,再看這段註釋,就沒什麼難以理解的了,這個方法(installModule)就是用於包括子模組在內的所有模組的state、getters、actions、mutations 的一個初始化工作

初始化 store vm

接下來,又執行了 resetStoreVM

// src/store.js

// initialize the store vm, which is responsible for the reactivity
// (also registers _wrappedGetters as computed properties)
resetStoreVM(this, state)
複製程式碼

這個方法的作用可以從它的註釋上大概看出來,初始化 store vm,看到這個 vm我們應該想到 vue的例項 vm,這裡實際上就是讓 store藉助 vue的響應式機制

並且會將 _wrappedGetters註冊為 computed的屬性,也就是計算屬性,_wrappedGetters前面已經提到過了,就是各個模組的 getters的集合,計算屬性在 vue中的特性之一是 計算屬性是基於它們的依賴進行快取的。只在相關依賴發生改變時它們才會重新求值,也就是做到了 高效地實時計算,這裡就是想讓 store上各個模組的 getters也具備這種特性

// src/store.js
// resetStoreVM

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
  })
})
複製程式碼

使用 forEachValue來遍歷 _wrappedGettersforEachValue前面也提到過了,所以這裡的 fn(store)實際上就是:

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

也就是 wrappedGetter這個函式,返回一個 rawGetter方法執行的結果,這裡的 rawGetter可以看作就是 getter計算得到的結果,所以我們在 getter方法的引數中拿到的四個引數指的就是上面四個:

// https://vuex.vuejs.org/zh/api/#getters

state,       // 如果在模組中定義則為模組的區域性狀態
getters,     // 等同於 store.getters
rootState    // 等同於 store.state
rootGetters  // 所有 getters
複製程式碼

拿到 getter之後,就把它交給 computed

接下來又定義了一個 Object.defineProperty

// src、store.js

Object.defineProperty(store.getters, key, {
  get: () => store._vm[key],
  enumerable: true // for local getters
})
複製程式碼

store.getters[key]對映到 store._vm[key]上,也就是當訪問 store.getters[key]的時候,就相當於獲取store._vm[key]的計算值,至於這裡的 store_vm又是什麼,跟下面的邏輯有關:

// src、store.js

store._vm = new Vue({
  data: {
    $$state: state
  },
  computed
})
複製程式碼

store._vm實際上就是一個 vue例項,這個例項只有 datacomputed屬性,就是為了藉助 vue的響應式機制

這裡實際上就是建立了一個 stategetter的對映關係,因為 getter的計算結果肯定依賴於 state的,它們之間必然存在關聯的關係,Store類上有個 state的訪問器屬性:

// src/store.js

get state () {
  return this._vm._data.$$state
}
複製程式碼

於是 stategetter的對映關係流程如下:

Vuex - 原始碼概覽

接下來是一個用於規範開發方式的邏輯:

// enable strict mode for new vm
if (store.strict) {
  enableStrictMode(store)
}
複製程式碼

store.strict這裡的 strict是需要開發者在初始化 Store的時候顯式宣告的,一般似乎大家都不怎麼關心這個,不過為了更好地遵循 vuex的開發規範,最好還是加上這個屬性

enableStrictMode方法如下:

// src/store.js

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 })
}
複製程式碼

上面說了,store._vm其實就是一個 vue例項,所以它有 $watch方法,用於檢測 this._data.$$state的變化,也就是 state的變化,當 state變化的時候,store._committing的值必須為 true

這個 store._committing的值在 Store的初始化程式碼中就已經定義了,值預設為 false

// src/store.js

this._committing = false
複製程式碼

這個值的修改是在 _withCommit方法中:

_withCommit (fn) {
  const committing = this._committing
  this._committing = true
  fn()
  this._committing = committing
}
複製程式碼

確保在執行 fn的時候, this._committing值為 true,然後執行完了再重置回去,這個 _withCommit的執行場景一般都是對 state進行修改,例如 commit

// src/store.js

commit (_type, _payload, _options) {
  // 省略無關程式碼
  // ...
  this._withCommit(() => {
    entry.forEach(function commitIterator (handler) {
      handler(payload)
    })
  })
  // 省略無關程式碼
  // ...
}
複製程式碼

enableStrictMode主要就是為了防止不通過 vuex提供的方法,例如 commitreplaceState等,非法修改 state值的情況,在開發環境下會報警告

總結

從上述分析來看,vuex的初始化基本上與 store的初始化緊密相關,store初始化完畢,vuex基本上也就初始化好了,不過過程中涉及到的部分還是比較多的

分析到現在,都是在說初始化,vuexapi幾乎沒說上多少,而vuex的能力就是通過 api來體現的,有空再分析下 vuex api相關的吧

相關文章