Vuex 原始碼分析

網易考拉前端團隊發表於2017-09-13

本文解讀的Vuex版本為2.3.1

Vuex程式碼結構

Vuex的程式碼並不多,但麻雀雖小,五臟俱全,下面來看一下其中的實現細節。

原始碼分析

入口檔案

入口檔案src/index.js:

import { Store, install } from './store'
import { mapState, mapMutations, mapGetters, mapActions } from './helpers'

export default {
  Store,
  install,
  version: '__VERSION__',
  mapState,
  mapMutations,
  mapGetters,
  mapActions
}複製程式碼

這是Vuex對外暴露的API,其中核心部分是Store,然後是install,它是一個vue外掛所必須的方法。Store
和install都在store.js檔案中。mapState、mapMutations、mapGetters、mapActions為四個輔助函式,用來將store中的相關屬性對映到元件中。

install方法

Vuejs的外掛都應該有一個install方法。先看下我們通常使用Vuex的姿勢:

import Vue from 'vue'
import Vuex from 'vuex'
...
Vue.use(Vuex)複製程式碼

install方法的原始碼:

export function install (_Vue) {
  if (Vue) {
    console.error(
      '[vuex] already installed. Vue.use(Vuex) should be called only once.'
    )
    return
  }
  Vue = _Vue
  applyMixin(Vue)
}

// auto install in dist mode
if (typeof window !== 'undefined' && window.Vue) {
  install(window.Vue)
}複製程式碼

方法的入參_Vue就是use的時候傳入的Vue構造器。
install方法很簡單,先判斷下如果Vue已經有值,就丟擲錯誤。這裡的Vue是在程式碼最前面宣告的一個內部變數。

let Vue // bind on install複製程式碼

這是為了保證install方法只執行一次。
install方法的最後呼叫了applyMixin方法。這個方法定義在src/mixin.js中:

export default function (Vue) {
  const version = Number(Vue.version.split('.')[0])

  if (version >= 2) {
    const usesInit = Vue.config._lifecycleHooks.indexOf('init') > -1
    Vue.mixin(usesInit ? { init: vuexInit } : { 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)
    }
  }

  /**
   * Vuex init hook, injected into each instances init hooks list.
   */

  function vuexInit () {
    const options = this.$options
    // store injection
    if (options.store) {
      this.$store = options.store
    } else if (options.parent && options.parent.$store) {
      this.$store = options.parent.$store
    }
  }
}複製程式碼

方法判斷了一下當前vue的版本,當vue版本>=2的時候,就在Vue上新增了一個全域性mixin,要麼在init階段,要麼在beforeCreate階段。Vue上新增的全域性mixin會影響到每一個元件。mixin的各種混入方式不同,同名鉤子函式將混合為一個陣列,因此都將被呼叫。並且,混合物件的鉤子將在元件自身鉤子之前。

來看下這個mixin方法vueInit做了些什麼:
this.$options用來獲取例項的初始化選項,當傳入了store的時候,就把這個store掛載到例項的$store上,沒有的話,並且例項有parent的,就把parent的$store掛載到當前例項上。這樣,我們在Vue的元件中就可以通過this.$store.xxx訪問Vuex的各種資料和狀態了。

Store建構函式

Vuex中程式碼最多的就是store.js, 它的建構函式就是Vuex的主體流程。

  constructor (options = {}) {
    assert(Vue, `must call Vue.use(Vuex) before creating a store instance.`)
    assert(typeof Promise !== 'undefined', `vuex requires a Promise polyfill in this browser.`)

    const {
      plugins = [],
      strict = false
    } = options

    let {
      state = {}
    } = options
    if (typeof state === 'function') {
      state = state()
    }

    // store internal state
    this._committing = false
    this._actions = Object.create(null)
    this._mutations = Object.create(null)
    this._wrappedGetters = Object.create(null)
    this._modules = new ModuleCollection(options)
    this._modulesNamespaceMap = Object.create(null)
    this._subscribers = []
    this._watcherVM = new Vue()

    // bind commit and dispatch to self
    const store = this
    const { dispatch, commit } = this
    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)
    }

    // strict mode
    this.strict = strict

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

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

    // apply plugins
    plugins.concat(devtoolPlugin).forEach(plugin => plugin(this))
  }複製程式碼

依然,先來看看使用Store的通常姿勢,便於我們知道方法的入參:

export default new Vuex.Store({
  state,
  mutations
  actions,
  getters,
  modules: {
    ...
  },
  plugins,
  strict: false
})複製程式碼

store建構函式的最開始,進行了2個判斷。

assert(Vue, `must call Vue.use(Vuex) before creating a store instance.`)
assert(typeof Promise !== 'undefined', `vuex requires a Promise polyfill in this browser.`)複製程式碼

這裡的assert是util.js裡的一個方法。

export function assert (condition, msg) {
  if (!condition) throw new Error(`[vuex] ${msg}`)
}複製程式碼

先判斷一下Vue是否存在,是為了保證在這之前store已經install過了。另外,Vuex依賴Promise,這裡也進行了判斷。
assert這個函式雖然簡單,但這種程式設計方式值得我們學習。
接著往下看:

const {
  plugins = [],
  strict = false
} = options

let {
  state = {}
} = options
if (typeof state === 'function') {
  state = state()
}複製程式碼

這裡使用解構並設定預設值的方式來獲取傳入的值,分別得到了plugins, strict 和state。傳入的state也可以是一個方法,方法的返回值作為state。

然後是定義了一些內部變數:

// store internal state
this._committing = false
this._actions = Object.create(null)
this._mutations = Object.create(null)
this._wrappedGetters = Object.create(null)
this._modules = new ModuleCollection(options)
this._modulesNamespaceMap = Object.create(null)
this._subscribers = []
this._watcherVM = new Vue()複製程式碼

this._committing 表示提交狀態,作用是保證對 Vuex 中 state 的修改只能在 mutation 的回撥函式中,而不能在外部隨意修改state。
this._actions 用來存放使用者定義的所有的 actions。
this._mutations 用來存放使用者定義所有的 mutatins。
this._wrappedGetters 用來存放使用者定義的所有 getters。
this._modules 用來儲存使用者定義的所有modules
this._modulesNamespaceMap 存放module和其namespace的對應關係。
this._subscribers 用來儲存所有對 mutation 變化的訂閱者。
this._watcherVM 是一個 Vue 物件的例項,主要是利用 Vue 例項方法 $watch 來觀測變化的。
這些引數後面會用到,我們再一一展開。

繼續往下看:

// bind commit and dispatch to self
const store = this
const { dispatch, commit } = this
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)
}複製程式碼

如同程式碼的註釋一樣,繫結Store類的dispatch和commit方法到當前store例項上。dispatch 和 commit 的實現我們稍後會分析。this.strict 表示是否開啟嚴格模式,在嚴格模式下會觀測所有的 state 的變化,建議在開發環境時開啟嚴格模式,線上環境要關閉嚴格模式,否則會有一定的效能開銷。

建構函式的最後:

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

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

// apply plugins
plugins.concat(devtoolPlugin).forEach(plugin => plugin(this))複製程式碼
Vuex的初始化核心

installModule

使用單一狀態樹,導致應用的所有狀態集中到一個很大的物件。但是,當應用變得很大時,store 物件會變得臃腫不堪。

為了解決以上問題,Vuex 允許我們將 store 分割到模組(module)。每個模組擁有自己的 state、mutation、action、getters、甚至是巢狀子模組——從上至下進行類似的分割。

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

在進入installModule方法之前,有必要先看下方法的入參this._modules.root是什麼。

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

這裡主要用到了src/module/module-collection.js 和 src/module/module.js

module-collection.js:

export default class ModuleCollection {
  constructor (rawRootModule) {
    // register root module (Vuex.Store options)
    this.root = new Module(rawRootModule, false)

    // register all nested modules
    if (rawRootModule.modules) {
      forEachValue(rawRootModule.modules, (rawModule, key) => {
        this.register([key], rawModule, false)
      })
    }
  }
  ...
}複製程式碼

module-collection的建構函式裡先定義了例項的root屬性,為一個Module例項。然後遍歷options裡的modules,依次註冊。

看下這個Module的建構函式:

export default class Module {
  constructor (rawModule, runtime) {
    this.runtime = runtime
    this._children = Object.create(null)
    this._rawModule = rawModule
    const rawState = rawModule.state
    this.state = (typeof rawState === 'function' ? rawState() : rawState) || {}
  }
  ...
}複製程式碼

這裡的rawModule一層一層的傳過來,也就是new Store時候的options。
module例項的_children目前為null,然後設定了例項的_rawModule和state。

回到module-collection建構函式的register方法, 及它用到的相關方法:

register (path, rawModule, runtime = true) {
  const parent = this.get(path.slice(0, -1))
  const newModule = new Module(rawModule, runtime)
  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)
    })
  }
}

get (path) {
  return path.reduce((module, key) => {
    return module.getChild(key)
  }, this.root)
}

addChild (key, module) {
  this._children[key] = module
}複製程式碼

get方法的入參path為一個陣列,例如['subModule', 'subsubModule'], 這裡使用reduce方法,一層一層的取值, this.get(path.slice(0, -1))取到當前module的父module。然後再呼叫Module類的addChild方法,將改module新增到父module的_children物件上。

然後,如果rawModule上有傳入modules的話,就遞迴一次註冊。

看下得到的_modules資料結構:

扯了一大圈,就是為了說明installModule函式的入參,接著回到installModule方法。

const isRoot = !path.length
const namespace = store._modules.getNamespace(path)複製程式碼

通過path的length來判斷是不是root module。

來看一下getNamespace這個方法:

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

又使用reduce方法來累加module的名字。這裡的module.namespaced是定義module的時候的引數,例如:

export default {
  state,
  getters,
  actions,
  mutations,
  namespaced: true
}複製程式碼

所以像下面這樣定義的store,得到的selectLabelRule的namespace就是'selectLabelRule/'

export default new Vuex.Store({
  state,
  actions,
  getters,
  mutations,
  modules: {
    selectLabelRule
  },
  strict: debug
})複製程式碼

接著看installModule方法:

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

傳入了namespaced為true的話,將module根據其namespace放到內部變數_modulesNamespaceMap物件上。

然後

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

getNestedState跟前面的getNamespace類似,也是用reduce來獲得當前父module的state,最後呼叫Vue.set將state新增到父module的state上。

看下這裡的_withCommit方法:

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

this._committing在Store的建構函式裡宣告過,初始值為false。這裡由於我們是在修改 state,Vuex 中所有對 state 的修改都會用 _withCommit函式包裝,保證在同步修改 state 的過程中 this._committing 的值始終為true。這樣當我們觀測 state 的變化時,如果 this._committing 的值不為 true,則能檢查到這個狀態修改是有問題的。

看到這裡,可能會有點困惑,舉個例子來直觀感受一下,以 Vuex 原始碼中的 example/shopping-cart 為例,開啟 store/index.js,有這麼一段程式碼:

export default new Vuex.Store({
  actions,
  getters,
  modules: {
    cart,
    products
  },
  strict: debug,
  plugins: debug ? [createLogger()] : []
})複製程式碼

這裡有兩個子 module,cart 和 products,我們開啟 store/modules/cart.js,看一下 cart 模組中的 state 定義,程式碼如下:

const state = {
  added: [],
  checkoutStatus: null
}複製程式碼

執行這個專案,開啟瀏覽器,利用 Vue 的除錯工具來看一下 Vuex 中的狀態,如下圖所示:

來看installModule方法的最後:

const local = module.context = makeLocalContext(store, namespace, path)

module.forEachMutation((mutation, key) => {
  const namespacedType = namespace + key
  registerMutation(store, namespacedType, mutation, local)
})

module.forEachAction((action, key) => {
  const namespacedType = namespace + key
  registerAction(store, namespacedType, action, 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)
})複製程式碼

local為接下來幾個方法的入參,我們又要跑偏去看一下makeLocalContext這個方法了:

/**
 * make localized dispatch, commit, getters and state
 * if there is no namespace, just use root ones
 */
function makeLocalContext (store, namespace, path) {
  const noNamespace = namespace === ''

  const local = {
    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 (!store._actions[type]) {
          console.error(`[vuex] unknown local action type: ${args.type}, global type: ${type}`)
          return
        }
      }

      return store.dispatch(type, payload)
    },

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

      if (!options || !options.root) {
        type = namespace + type
        if (!store._mutations[type]) {
          console.error(`[vuex] unknown local mutation type: ${args.type}, global type: ${type}`)
          return
        }
      }

      store.commit(type, payload, options)
    }
  }

  // getters and state object must be gotten lazily
  // because they will be changed by vm update
  Object.defineProperties(local, {
    getters: {
      get: noNamespace
        ? () => store.getters
        : () => makeLocalGetters(store, namespace)
    },
    state: {
      get: () => getNestedState(store.state, path)
    }
  })

  return local
}複製程式碼

就像方法的註釋所說的,方法用來得到區域性的dispatch,commit,getters 和 state, 如果沒有namespace的話,就用根store的dispatch, commit等等

以local.dispath為例:
沒有namespace為''的時候,直接使用this.dispatch。有namespace的時候,就在type前加上namespace再dispath。

local引數說完了,接來是分別註冊mutation,action和getter。以註冊mutation為例說明:

module.forEachMutation((mutation, key) => {
  const namespacedType = namespace + key
  registerMutation(store, namespacedType, mutation, local)
})複製程式碼
function registerMutation (store, type, handler, local) {
  const entry = store._mutations[type] || (store._mutations[type] = [])
  entry.push(function wrappedMutationHandler (payload) {
    handler(local.state, payload)
  })
}複製程式碼

根據mutation的名字找到內部變數_mutations裡的陣列。然後,將mutation的回到函式push到裡面。
例如有這樣一個mutation:

mutation: {
  increment (state, n) {
    state.count += n
  }
}複製程式碼

就會在_mutations[increment]裡放入其回撥函式。

commit

前面說到mutation被放到了_mutations物件裡。接下來看一下,Store建構函式裡最開始的將Store類的dispatch和commit放到當前例項上,那commit一個mutation的執行情況是什麼呢?

  commit (_type, _payload, _options) {
    // check object-style commit
    const {
      type,
      payload,
      options
    } = unifyObjectStyle(_type, _payload, _options)

    const mutation = { type, payload }
    const entry = this._mutations[type]
    if (!entry) {
      console.error(`[vuex] unknown mutation type: ${type}`)
      return
    }
    this._withCommit(() => {
      entry.forEach(function commitIterator (handler) {
        handler(payload)
      })
    })
    this._subscribers.forEach(sub => sub(mutation, this.state))

    if (options && options.silent) {
      console.warn(
        `[vuex] mutation type: ${type}. Silent option has been removed. ` +
        'Use the filter functionality in the vue-devtools'
      )
    }
  }複製程式碼

方法的最開始用unifyObjectStyle來獲取引數,這是因為commit的傳參方式有兩種:

store.commit('increment', {
  amount: 10
})複製程式碼

提交 mutation 的另一種方式是直接使用包含 type 屬性的物件:

store.commit({
  type: 'increment',
  amount: 10
})複製程式碼
function unifyObjectStyle (type, payload, options) {
  if (isObject(type) && type.type) {
    options = payload
    payload = type
    type = type.type
  }

  assert(typeof type === 'string', `Expects string as the type, but found ${typeof type}.`)

  return { type, payload, options }
}複製程式碼

如果傳入的是物件,就做引數轉換。
然後判斷需要commit的mutation是否註冊過了,this._mutations[type],沒有就拋錯。
然後迴圈呼叫_mutations裡的每一個mutation回撥函式。
然後執行每一個mutation的subscribe回撥函式。

Vuex輔助函式

Vuex提供的輔助函式有4個:

以mapGetters為例,看下mapGetters的用法:

程式碼在src/helpers.js裡:

export const mapGetters = normalizeNamespace((namespace, getters) => {
  const res = {}
  normalizeMap(getters).forEach(({ key, val }) => {
    val = namespace + val
    res[key] = function mappedGetter () {
      if (namespace && !getModuleByNamespace(this.$store, 'mapGetters', namespace)) {
        return
      }
      if (!(val in this.$store.getters)) {
        console.error(`[vuex] unknown getter: ${val}`)
        return
      }
      return this.$store.getters[val]
    }
    // mark vuex getter for devtools
    res[key].vuex = true
  })
  return res
})


function normalizeMap (map) {
  return Array.isArray(map)
    ? map.map(key => ({ key, val: key }))
    : Object.keys(map).map(key => ({ key, val: map[key] }))
}

function normalizeNamespace (fn) {
  return (namespace, map) => {
    if (typeof namespace !== 'string') {
      map = namespace
      namespace = ''
    } else if (namespace.charAt(namespace.length - 1) !== '/') {
      namespace += '/'
    }
    return fn(namespace, map)
  }
}複製程式碼

normalizeNamespace方法使用函數語言程式設計的方式,接收一個方法,返回一個方法。
mapGetters接收的引數是一個陣列或者一個物件:

computed: {
// 使用物件展開運算子將 getters 混入 computed 物件中
  ...mapGetters([
    'doneTodosCount',
    'anotherGetter',
    // ...
  ])
}複製程式碼
mapGetters({
  // 對映 this.doneCount 為 store.getters.doneTodosCount
  doneCount: 'doneTodosCount'
})複製程式碼

這裡是沒有傳namespace的情況,看下方法的具體實現。
normalizeNamespace開始進行了引數跳轉,傳入的陣列或物件給map,namespace為'' , 然後執行fn(namespace, map)
接著是normalizeMap方法,返回一個陣列,這種形式:

{
  key: doneCount,
  val: doneTodosCount
}複製程式碼

然後往res物件上塞方法,得到如下形式的物件:

{
  doneCount: function() {
    return this.$store.getters[doneTodosCount]
  }
}複製程式碼

也就是最開始mapGetters想要的效果:

by kaola/fangwentian

相關文章