Vuex 2.0 原始碼分析

滴滴出行·DDFE發表於2016-11-23

作者:滴滴公共前端團隊 - 黃軼

大家好,我叫黃軼,來自滴滴公共前端團隊,我們團隊最近寫了一本書 ——《Vue.js 權威指南》,內容豐富,由淺入深。不過有一些同學反饋說缺少 Vuex 的介紹的章節。既然 Vue.js 2.0 已經正式釋出了,我們也要緊跟步伐,和大家聊一聊 Vuex 2.0。本文並不打算講官網已有的內容,而會通過原始碼分析的方式,讓同學們從另外一個角度認識和理解 Vuex 2.0。

當我們用 Vue.js 開發一箇中到大型的單頁應用時,經常會遇到如下問題:

  • 如何讓多個 Vue 元件共享狀態
  • Vue 元件間如何通訊

通常,在專案不是很複雜的時候,我們會利用全域性事件匯流排 (global event bus)解決,但是隨著複雜度的提升,這些程式碼將變的難以維護。因此,我們需要一種更加好用的解決方案,於是,Vuex 誕生了。

本文並不是 Vuex 的科普文章,對於還不瞭解 Vuex 的同學,建議先移步 Vuex 官方文件;看英文文件吃力的同學,可以看 Vuex 的中文文件

Vuex 2.0 原始碼分析
vuex 原理圖

Vuex 的設計思想受到了 Flux,Redux 和 The Elm Architecture 的啟發,它的實現又十分巧妙,和 Vue.js 配合相得益彰,下面就讓我們一起來看它的實現吧。

目錄結構

Vuex 的原始碼託管在 github,我們首先通過 git 把程式碼 clone 到本地,選一款適合自己的 IDE 開啟原始碼,展開 src 目錄,如下圖所示:

Vuex 2.0 原始碼分析
enter image description here

src 目錄下的檔案並不多,包含幾個 js 檔案和 plugins 目錄, plugins 目錄裡面包含 2 個 Vuex 的內建外掛,整個原始碼加起來不過 500-600 行,可謂非常輕巧的一個庫。

麻雀雖小,五臟俱全,我們先直觀的感受一下原始碼的結構,接下來看一下其中的實現細節。

原始碼分析

本文的原始碼分析過程不會是自上而下的給程式碼加註釋,我更傾向於是從 Vuex 提供的 API 和我們的使用方法等維度去分析。Vuex 的原始碼是基於 es6 的語法編寫的,對於不瞭解 es6 的同學,建議還是先學習一下 es6。

從入口開始

看原始碼一般是從入口開始,Vuex 原始碼的入口是 src/index.js,先來開啟這個檔案。

我們首先看這個庫的 export ,在 index.js 程式碼最後。

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

這裡可以一目瞭然地看到 Vuex 對外暴露的 API。其中, Store 是 Vuex 提供的狀態儲存類,通常我們使用 Vuex 就是通過建立 Store 的例項,稍後我們會詳細介紹。接著是 install 方法,這個方法通常是我們編寫第三方 Vue 外掛的“套路”,先來看一下“套路”程式碼:

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

我們實現了一個 install 方法,這個方法當我們全域性引用 Vue ,也就是 window 上有 Vue 物件的時候,會手動呼叫 install 方法,並傳入 Vue 的引用;當 Vue 通過 npm 安裝到專案中的時候,我們在程式碼中引入第三方 Vue 外掛通常會編寫如下程式碼:

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

當我們執行 Vue.use(Vuex) 這句程式碼的時候,實際上就是呼叫了 install 的方法並傳入 Vue 的引用。install 方法顧名思義,現在讓我們來看看它的實現。它接受了一個引數 _Vue,函式體首先判斷 Vue ,這個變數的定義在 index.js 檔案的開頭部分:

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

對 Vue 的判斷主要是保證 install 方法只執行一次,這裡把 install 方法的引數 _Vue 物件賦值給 Vue 變數,這樣我們就可以在 index.js 檔案的其它地方使用 Vue 這個變數了。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 的生命週期中的初始化(1.0 版本是 init,2.0 版本是 beforeCreated)鉤子前插入一段 Vuex 初始化程式碼。這裡做的事情很簡單——給 Vue 的例項注入一個 $store 的屬性,這也就是為什麼我們在 Vue 的元件中可以通過 this.$store.xxx 訪問到 Vuex 的各種資料和狀態。

認識 Store 建構函式

我們在使用 Vuex 的時候,通常會例項化 Store 類,然後傳入一個物件,包括我們定義好的 actions、getters、mutations、state等,甚至當我們有多個子模組的時候,我們可以新增一個 modules 物件。那麼例項化的時候,到底做了哪些事情呢?帶著這個疑問,讓我們回到 index.js 檔案,重點看一下 Store 類的定義。Store 類定義的程式碼略長,我不會一下就貼上所有程式碼,我們來拆解分析它,首先看一下建構函式的實現:

class Store {
  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 {
      state = {},
      plugins = [],
      strict = false
    } = options

    // store internal state
    this._options = options
    this._committing = false
    this._actions = Object.create(null)
    this._mutations = Object.create(null)
    this._wrappedGetters = Object.create(null)
    this._runtimeModules = 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, [], options)

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

建構函式的一開始就用了“斷言函式”,來判斷是否滿足一些條件。

assert(Vue, `must call Vue.use(Vuex) before creating a store instance.`)複製程式碼

這行程式碼的目的是確保 Vue 的存在,也就是在我們例項化 Store 之前,必須要保證之前的 install 方法已經執行了。

assert(typeof Promise !== 'undefined', `vuex requires a Promise polyfill in this browser.`)複製程式碼

這行程式碼的目的是為了確保 Promsie 可以使用的,因為 Vuex 的原始碼是依賴 Promise 的。Promise 是 es6 提供新的 API,由於現在的瀏覽器並不是都支援 es6 語法的,所以通常我們會用 babel 編譯我們的程式碼,如果想使用 Promise 這個 特性,我們需要在 package.json 中新增對 babel-polyfill 的依賴並在程式碼的入口加上 import 'babel-polyfill' 這段程式碼。

再來看看 assert 這個函式,它並不是瀏覽器原生支援的,它的實現在 src/util.js 裡,程式碼如下:

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

非常簡單,對 condition 判斷,如果不不為真,則丟擲異常。這個函式雖然簡單,但這種程式設計方式值得我們學習。

再來看建構函式接下來的程式碼:

const {
  state = {},
  plugins = [],
  strict = false
} = options複製程式碼

這裡就是利用 es6 的結構賦值拿到 options 裡的 state,plugins 和 strict。state 表示 rootState,plugins 表示應用的外掛、strict 表示是否開啟嚴格模式。

接著往下看:

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

這裡主要是建立一些內部的屬性:
this._options 儲存引數 options。
this._committing 標誌一個提交狀態,作用是保證對 Vuex 中 state 的修改只能在 mutation 的回撥函式中,而不能在外部隨意修改 state。
this._actions 用來儲存使用者定義的所有的 actions。
this._mutations 用來儲存使用者定義所有的 mutatins。
this._wrappedGetters 用來儲存使用者定義的所有 getters 。
this._runtimeModules 用來儲存所有的執行時的 modules。
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)
}

// strict mode
this.strict = strict複製程式碼

這裡的程式碼也不難理解,把 Store 類的 dispatch 和 commit 的方法的 this 指標指向當前 store 的例項上,dispatch 和 commit 的實現我們稍後會分析。this.strict 表示是否開啟嚴格模式,在嚴格模式下會觀測所有的 state 的變化,建議在開發環境時開啟嚴格模式,線上環境要關閉嚴格模式,否則會有一定的效能開銷。

Vuex 的初始化核心

installModule

我們接著往下看:

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

// 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 方法是把我們通過 options 傳入的各種屬性模組註冊和安裝;resetStoreVM 方法是初始化 store._vm,觀測 state 和 getters 的變化;最後是應用傳入的外掛。

下面,我們先來看一下 installModule 的實現:

function installModule (store, rootState, path, module, hot) {
  const isRoot = !path.length
  const {
    state,
    actions,
    mutations,
    getters,
    modules
  } = 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, state || {})
    })
  }

  if (mutations) {
    Object.keys(mutations).forEach(key => {
      registerMutation(store, key, mutations[key], path)
    })
  }

  if (actions) {
    Object.keys(actions).forEach(key => {
      registerAction(store, key, actions[key], path)
    })
  }

  if (getters) {
    wrapGetters(store, getters, path)
  }

  if (modules) {
    Object.keys(modules).forEach(key => {
      installModule(store, rootState, path.concat(key), modules[key], hot)
    })
  }
}複製程式碼

installModule 函式可接收5個引數,store、rootState、path、module、hot,store 表示當前 Store 例項,rootState 表示根 state,path 表示當前巢狀模組的路徑陣列,module 表示當前安裝的模組,hot 當動態改變 modules 或者熱更新的時候為 true。

先來看這部分程式碼:

 const isRoot = !path.length
 const {
   state,
   actions,
   mutations,
   getters,
   modules
 } = module複製程式碼

程式碼首先通過 path 陣列的長度判斷是否為根。我們在建構函式呼叫的時候是 installModule(this, state, [], options),所以這裡 isRoot 為 true。module 為傳入的 options,我們拿到了 module 下的 state、actions、mutations、getters 以及巢狀的 modules。

接著看下面的程式碼:

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

這裡判斷當不為根且非熱更新的情況,然後設定級聯狀態,這裡乍一看不好理解,我們先放一放,稍後來回顧。

再往下看程式碼:

if (mutations) {
  Object.keys(mutations).forEach(key => {
    registerMutation(store, key, mutations[key], path)
  })
}

if (actions) {
  Object.keys(actions).forEach(key => {
    registerAction(store, key, actions[key], path)
  })
}

if (getters) {
  wrapGetters(store, getters, path)
}複製程式碼

這裡分別是對 mutations、actions、getters 進行註冊,如果我們例項化 Store 的時候通過 options 傳入這些物件,那麼會分別進行註冊,我稍後再去介紹註冊的具體實現。那麼到這,如果 Vuex 沒有 module ,這個 installModule 方法可以說已經做完了。但是 Vuex 巧妙了設計了 module 這個概念,因為 Vuex 本身是單一狀態樹,應用的所有狀態都包含在一個大物件內,隨著我們應用規模的不斷增長,這個 Store 變得非常臃腫。為了解決這個問題,Vuex 允許我們把 store 分 module(模組)。每一個模組包含各自的 state、mutations、actions 和 getters,甚至是巢狀模組。所以,接下來還有一行程式碼:

if (modules) {
  Object.keys(modules).forEach(key => {
    installModule(store, rootState, path.concat(key), modules[key], hot)
  })
}複製程式碼

這裡通過遍歷 modules,遞迴呼叫 installModule 去安裝子模組。這裡傳入了 store、rootState、path.concat(key)、和 modules[key],和剛才不同的是,path 不為空,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, state || {})
  })
}複製程式碼

當遞迴初始化子模組的時候,isRoot 為 false,注意這裡有個方法getNestedState(rootState, path),來看一下 getNestedState 函式的定義:

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

這個方法很簡單,就是根據 path 查詢 state 上的巢狀 state。在這裡就是傳入 rootState 和 path,計算出當前模組的父模組的 state,由於模組的 path 是根據模組的名稱 concat 連線的,所以 path 的最後一個元素就是當前模組的模組名,最後呼叫

store._withCommit(() => {
  Vue.set(parentState, moduleName, state || {})
})複製程式碼

把當前模組的 state 新增到 parentState 中。
這裡注意一下我們用了 store._withCommit 方法,來看一下這個方法的定義:

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

由於我們是在修改 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 中的狀態,如下圖所示:

Vuex 2.0 原始碼分析
enter image description here

可以看到,在 rootState 下,分別有 cart 和 products 2個屬性,key 根據模組名稱而來,value 就是在每個模組檔案中定義的 state,這就把模組 state 掛載到 rootState 上了。

我們瞭解完巢狀模組 state 是怎麼一回事後,我們回過頭來看一下 installModule 過程中的其它 3 個重要方法:registerMutation、registerAction 和 wrapGetters。顧名思義,這 3 個方法分別處理 mutations、actions 和 getters。我們先來看一下 registerMutation 的定義:

registerMutation

function registerMutation (store, type, handler, path = []) {
  const entry = store._mutations[type] || (store._mutations[type] = [])
  entry.push(function wrappedMutationHandler (payload) {
    handler(getNestedState(store.state, path), payload)
  })
}複製程式碼

registerMutation 是對 store 的 mutation 的初始化,它接受 4 個引數,store為當前 Store 例項,type為 mutation 的 key,handler 為 mutation 執行的回撥函式,path 為當前模組的路徑。mutation 的作用就是同步修改當前模組的 state ,函式首先通過 type 拿到對應的 mutation 物件陣列, 然後把一個 mutation 的包裝函式 push 到這個陣列中,這個函式接收一個引數 payload,這個就是我們在定義 mutation 的時候接收的額外引數。這個函式執行的時候會呼叫 mutation 的回撥函式,並通過 getNestedState(store.state, path) 方法得到當前模組的 state,和 playload 一起作為回撥函式的引數。舉個例子:

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

這裡我們定義了一個 mutation,通過剛才的 registerMutation 方法,我們註冊了這個 mutation,這裡的 state 對應的就是當前模組的 state,n 就是額外引數 payload,接下來我們會從原始碼分析的角度來介紹這個 mutation 的回撥是何時被呼叫的,引數是如何傳遞的。

我們有必要知道 mutation 的回撥函式的呼叫時機,在 Vuex 中,mutation 的呼叫是通過 store 例項的 API 介面 commit 來呼叫的,來看一下 commit 函式的定義:

commit (type, payload, options) {
  // check object-style commit
  if (isObject(type) && type.type) {
    options = payload
    payload = type
    type = type.type
  }
  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)
    })
  })
  if (!options || !options.silent) {
    this._subscribers.forEach(sub => sub(mutation, this.state))
  }
}複製程式碼

commit 支援 3 個引數,type 表示 mutation 的型別,payload 表示額外的引數,options 表示一些配置,比如 silent 等,稍後會用到。commit 函式首先對 type 的型別做了判斷,處理了 type 為 object 的情況,接著根據 type 去查詢對應的 mutation,如果找不到,則輸出一條錯誤資訊,否則遍歷這個 type 對應的 mutation 物件陣列,執行 handler(payload) 方法,這個方法就是之前定義的 wrappedMutationHandler(handler),執行它就相當於執行了 registerMutation 註冊的回撥函式,並把當前模組的 state 和 額外引數 payload 作為引數傳入。注意這裡我們依然使用了 this._withCommit 的方法提交 mutation。commit 函式的最後,判斷如果不是靜默模式,則遍歷 this._subscribers,呼叫回撥函式,並把 mutation 和當前的根 state 作為引數傳入。那麼這個 this._subscribers 是什麼呢?原來 Vuex 的 Store 例項提供了 subscribe API 介面,它的作用是訂閱(註冊監聽) store 的 mutation。先來看一下它的實現:

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

subscribe 方法很簡單,他接受的引數是一個回撥函式,會把這個回撥函式儲存到 this._subscribers 上,並返回一個函式,當我們呼叫這個返回的函式,就可以解除當前函式對 store 的 mutation 的監聽。其實,Vuex 的內建 logger 外掛就是基於 subscribe 介面實現對 store 的 muation的監聽,稍後我們會詳細介紹這個外掛。

registerAction

在瞭解完 registerMutation,我們再來看一下 registerAction 的定義:

function registerAction (store, type, handler, path = []) {
const entry = store._actions[type] || (store._actions[type] = [])
  const { dispatch, commit } = store
  entry.push(function wrappedActionHandler (payload, cb) {
    let res = handler({
      dispatch,
      commit,
      getters: store.getters,
      state: getNestedState(store.state, path),
      rootState: store.state
    }, payload, cb)
    if (!isPromise(res)) {
      res = Promise.resolve(res)
    }
    if (store._devtoolHook) {
      return res.catch(err => {
        store._devtoolHook.emit('vuex:error', err)
        throw err
      })
    } else {
      return res
    }
  })
 }複製程式碼

registerAction 是對 store 的 action 的初始化,它和 registerMutation 的引數一致,和 mutation 不同一點,mutation 是同步修改當前模組的 state,而 action 是可以非同步去修改 state,這裡不要誤會,在 action 的回撥中並不會直接修改 state ,仍然是通過提交一個 mutation 去修改 state(在 Vuex 中,mutation 是修改 state 的唯一途徑)。那我們就來看看 action 是如何做到這一點的。

函式首先也是通過 type 拿到對應 action 的物件陣列,然後把一個 action 的包裝函式 push 到這個陣列中,這個函式接收 2 個引數,payload 表示額外引數 ,cb 表示回撥函式(實際上我們並沒有使用它)。這個函式執行的時候會呼叫 action 的回撥函式,傳入一個 context 物件,這個物件包括了 store 的 commit 和 dispatch 方法、getter、當前模組的 state 和 rootState 等等。接著對這個函式的返回值做判斷,如果不是一個 Promise 物件,則呼叫 Promise.resolve(res) 給res 包裝成了一個 Promise 物件。這裡也就解釋了為何 Vuex 的原始碼依賴 Promise,這裡對 Promise 的判斷也和簡單,參考程式碼 src/util.js,對 isPromise 的判斷如下:

export function isPromise (val) {
  return val && typeof val.then === 'function'
}複製程式碼

其實就是簡單的檢查物件的 then 方法,如果包含說明就是一個 Promise 物件。

接著判斷 store._devtoolHook,這個只有當用到 Vuex devtools 開啟的時候,我們才能捕獲 promise 的過程中的 。 action 的包裝函式最後返回 res ,它就是一個地地道道的 Promise 物件。來看個例子:

actions: {
  checkout ({ commit, state }, payload) {
    // 把當前購物車的商品備份起來
    const savedCartItems = [...state.cart.added]
    // 傳送結帳請求,並愉快地清空購物車
    commit(types.CHECKOUT_REQUEST)
    // 購物 API 接收一個成功回撥和一個失敗回撥
    shop.buyProducts(
      products,
      // 成功操作
      () => commit(types.CHECKOUT_SUCCESS),
      // 失敗操作
      () => commit(types.CHECKOUT_FAILURE, savedCartItems)
    )
  }
}複製程式碼

這裡我們定義了一個 action,通過剛才的 registerAction 方法,我們註冊了這個 action,這裡的 commit 就是 store 的 API 介面,可以通過它在 action 裡提交一個 mutation。state 對應的就是當前模組的 state,我們在這個 action 裡即可以同步提交 mutation,也可以非同步提交。接下來我們會從原始碼分析的角度來介紹這個 action 的回撥是何時被呼叫的,引數是如何傳遞的。

我們有必要知道 action 的回撥函式的呼叫時機,在 Vuex 中,action 的呼叫是通過 store 例項的 API 介面 dispatch 來呼叫的,來看一下 dispatch 函式的定義:

 dispatch (type, payload) {
  // check object-style dispatch
   if (isObject(type) && type.type) {
     payload = type
     type = type.type
   }
   const entry = this._actions[type]
   if (!entry) {
     console.error(`[vuex] unknown action type: ${type}`)
     return
   }
   return entry.length > 1
     ? Promise.all(entry.map(handler => handler(payload)))
     : entry[0](payload)
 }複製程式碼

dispatch 支援2個引數,type 表示 action 的型別,payload 表示額外的引數。前面幾行程式碼和 commit 介面非常類似,都是找到對應 type 下的 action 物件陣列,唯一和 commit 不同的地方是最後部分,它對 action 的物件陣列長度做判斷,如果長度為 1 則直接呼叫 entry[0](payload), 這個方法就是之前定義的 wrappedActionHandler(payload, cb),執行它就相當於執行了 registerAction 註冊的回撥函式,並把當前模組的 context 和 額外引數 payload 作為引數傳入。所以我們在 action 的回撥函式裡,可以拿到當前模組的上下文包括 store 的 commit 和 dispatch 方法、getter、當前模組的 state 和 rootState,可見 action 是非常靈活的。

wrapGetters

瞭解完 registerAction 後,我們來看看 wrapGetters的定義:

function wrapGetters (store, moduleGetters, modulePath) {
  Object.keys(moduleGetters).forEach(getterKey => {
    const rawGetter = moduleGetters[getterKey]
    if (store._wrappedGetters[getterKey]) {
      console.error(`[vuex] duplicate getter key: ${getterKey}`)
      return
    }
    store._wrappedGetters[getterKey] = function wrappedGetter (store) {
      return rawGetter(
        getNestedState(store.state, modulePath), // local state
        store.getters, // getters
        store.state // root state
      )
    }
  })
}複製程式碼

wrapGetters 是對 store 的 getters 初始化,它接受 3個 引數, store 表示當前 Store 例項,moduleGetters 表示當前模組下的所有 getters, modulePath 對應模組的路徑。細心的同學會發現,和剛才的 registerMutation 以及 registerAction 不同,這裡對 getters 的迴圈遍歷是放在了函式體內,並且 getters 和它們的一個區別是不允許 getter 的 key 有重複。

這個函式做的事情就是遍歷 moduleGetters,把每一個 getter 包裝成一個方法,新增到 store._wrappedGetters 物件中,注意 getter 的 key 是不允許重複的。在這個包裝的方法裡,會執行 getter 的回撥函式,並把當前模組的 state,store 的 getters 和 store 的 rootState 作為它引數。來看一個例子:

export const cartProducts = state => {
  return state.cart.added.map(({ id, quantity }) => {
    const product = state.products.all.find(p => p.id === id)
    return {
      title: product.title,
      price: product.price,
      quantity
    }
  })
}複製程式碼

這裡我們定義了一個 getter,通過剛才的 wrapGetters 方法,我們把這個 getter 新增到 store._wrappedGetters 物件裡,這和回撥函式的引數 state 對應的就是當前模組的 state,接下來我們從原始碼的角度分析這個函式是如何被呼叫,引數是如何傳遞的。

我們有必要知道 getter 的回撥函式的呼叫時機,在 Vuex 中,我們知道當我們在元件中通過 this.$store.getters.xxxgetters 可以訪問到對應的 getter 的回撥函式,那麼我們需要把對應 getter 的包裝函式的執行結果繫結到 `this.$store 上。這部分的邏輯就在 resetStoreVM 函式裡。我們在 Store 的建構函式中,在執行完 installModule 方法後,就會執行 resetStoreVM 方法。來看一下它的定義:

resetStoreVM

function resetStoreVM (store, state) {
  const oldVm = store._vm

  // bind store public getters
  store.getters = {}
  const wrappedGetters = store._wrappedGetters
  const computed = {}
  Object.keys(wrappedGetters).forEach(key => {
    const fn = wrappedGetters[key]
    // use computed to leverage its lazy-caching mechanism
    computed[key] = () => fn(store)
    Object.defineProperty(store.getters, key, {
      get: () => store._vm[key]
    })
  })

  // use a Vue instance to store the state tree
  // suppress warnings just in case the user has added
  // some funky global mixins
  const silent = Vue.config.silent
  Vue.config.silent = true
  store._vm = new Vue({
    data: { state },
    computed
  })
  Vue.config.silent = silent

  // enable strict mode for new vm
  if (store.strict) {
    enableStrictMode(store)
  }

  if (oldVm) {
    // dispatch changes in all subscribed watchers
    // to force getter re-evaluation.
    store._withCommit(() => {
      oldVm.state = null
    })
    Vue.nextTick(() => oldVm.$destroy())
  }
}複製程式碼

這個方法主要是重置一個私有的 _vm 物件,它是一個 Vue 的例項。這個 _vm 物件會保留我們的 state 樹,以及用計算屬性的方式儲存了 store 的 getters。來具體看看它的實現過程。我們把這個函式拆成幾個部分來分析:

 const oldVm = store._vm

  // bind store public getters
  store.getters = {}
  const wrappedGetters = store._wrappedGetters
  const computed = {}
  Object.keys(wrappedGetters).forEach(key => {
    const fn = wrappedGetters[key]
    // use computed to leverage its lazy-caching mechanism
    computed[key] = () => fn(store)
    Object.defineProperty(store.getters, key, {
      get: () => store._vm[key]
    })
  })複製程式碼

這部分留了現有的 store._vm 物件,接著遍歷 store._wrappedGetters 物件,在遍歷過程中,依次拿到每個 getter 的包裝函式,並把這個包裝函式執行的結果用 computed 臨時變數儲存。接著用 es5 的 Object.defineProperty 方法為 store.getters 定義了 get 方法,也就是當我們在元件中呼叫this.$store.getters.xxxgetters 這個方法的時候,會訪問 store._vm[xxxgetters]。我們接著往下看:

// use a Vue instance to store the state tree
// suppress warnings just in case the user has added
 // some funky global mixins
 const silent = Vue.config.silent
 Vue.config.silent = true
 store._vm = new Vue({
   data: { state },
   computed
 })
 Vue.config.silent = silent

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

這部分的程式碼首先先拿全域性 Vue.config.silent 的配置,然後臨時把這個配置設成 true,接著例項化一個 Vue 的例項,把 store 的狀態樹 state 作為 data 傳入,把我們剛才的臨時變數 computed 作為計算屬性傳入。然後再把之前的 silent 配置重置。設定 silent 為 true 的目的是為了取消這個 _vm 的所有日誌和警告。把 computed 物件作為 _vm 的 computed 屬性,這樣就完成了 getters 的註冊。因為當我們在元件中訪問 this.$store.getters.xxxgetters 的時候,就相當於訪問 store._vm[xxxgetters],也就是在訪問 computed[xxxgetters],這樣就訪問到了 xxxgetters 對應的回撥函式了。這段程式碼最後判斷 strict 屬性決定是否開啟嚴格模式,我們來看看嚴格模式都幹了什麼:

function enableStrictMode (store) {
  store._vm.$watch('state', () => {
    assert(store._committing, `Do not mutate vuex store state outside mutation handlers.`)
  }, { deep: true, sync: true })
}複製程式碼

嚴格模式做的事情很簡單,監測 store._vm.state 的變化,看看 state 的變化是否通過執行 mutation 的回撥函式改變,如果是外部直接修改 state,那麼 store._committing 的值為 false,這樣就丟擲一條錯誤。再次強調一下,Vuex 中對 state 的修改只能在 mutation 的回撥函式裡。

回到 resetStoreVM 函式,我們來看一下最後一部分:

if (oldVm) {
  // dispatch changes in all subscribed watchers
  // to force getter re-evaluation.
  store._withCommit(() => {
    oldVm.state = null
  })
  Vue.nextTick(() => oldVm.$destroy())
}複製程式碼

這裡的邏輯很簡單,由於這個函式每次都會建立新的 Vue 例項並賦值到 store._vm 上,那麼舊的 _vm 物件的狀態設定為 null,並呼叫 $destroy 方法銷燬這個舊的 _vm 物件。

那麼到這裡,Vuex 的初始化基本告一段落了,初始化核心就是 installModule 和
resetStoreVM 函式。通過對 mutations 、actions 和 getters 的註冊,我們瞭解到 state 的是按模組劃分的,按模組的巢狀形成一顆狀態樹。而 actions、mutations 和 getters 的全域性的,其中 actions 和 mutations 的 key 允許重複,但 getters 的 key 是不允許重複的。官方推薦我們給這些全域性的物件在定義的時候加一個名稱空間來避免命名衝突。
從原始碼的角度介紹完 Vuex 的初始化的玩法,我們再從 Vuex 提供的 API 方向來分析其中的原始碼,看看這些 API 是如何實現的。

Vuex API 分析

Vuex 常見的 API 如 dispatch、commit 、subscribe 我們前面已經介紹過了,這裡就不再贅述了,下面介紹的一些 Store 的 API,雖然不常用,但是瞭解一下也不錯。

watch(getter, cb, options)

watch 作用是響應式的監測一個 getter 方法的返回值,當值改變時呼叫回撥。getter 接收 store 的 state 作為唯一引數。來看一下它的實現:

watch (getter, cb, options) {
    assert(typeof getter === 'function', `store.watch only accepts a function.`)
    return this._watcherVM.$watch(() => getter(this.state), cb, options)
  }複製程式碼

函式首先斷言 watch 的 getter 必須是一個方法,接著利用了內部一個 Vue 的例項物件 `this._watcherVM 的 $watch 方法,觀測 getter 方法返回值的變化,如果有變化則呼叫 cb 函式,回撥函式的引數為新值和舊值。watch 方法返回的是一個方法,呼叫它則取消觀測。

registerModule(path, module)

registerModule 的作用是註冊一個動態模組,有的時候當我們非同步載入一些業務的時候,可以通過這個 API 介面去動態註冊模組,來看一下它的實現:

registerModule (path, module) {
    if (typeof path === 'string') path = [path]
    assert(Array.isArray(path), `module path must be a string or an Array.`)
    this._runtimeModules[path.join('.')] = module
    installModule(this, this.state, path, module)
    // reset store to update getters...
    resetStoreVM(this, this.state)
  }複製程式碼

函式首先對 path 判斷,如果 path 是一個 string 則把 path 轉換成一個 Array。接著把 module 物件快取到 this._runtimeModules 這個物件裡,path 用點連線作為該物件的 key。接著和初始化 Store 的邏輯一樣,呼叫 installModule 和 resetStoreVm 方法安裝一遍動態注入的 module。

unregisterModule(path)

和 registerModule 方法相對的就是 unregisterModule 方法,它的作用是登出一個動態模組,來看一下它的實現:

unregisterModule (path) {
    if (typeof path === 'string') path = [path]
    assert(Array.isArray(path), `module path must be a string or an Array.`)
    delete this._runtimeModules[path.join('.')]
    this._withCommit(() => {
      const parentState = getNestedState(this.state, path.slice(0, -1))
      Vue.delete(parentState, path[path.length - 1])
    })
    resetStore(this)
  }複製程式碼

函式首先還是對 path 的型別做了判斷,這部分邏輯和註冊是一樣的。接著從 this._runtimeModules 裡刪掉以 path 點連線的 key 對應的模組。接著通過 this._withCommit 方法把當前模組的 state 物件從父 state 上刪除。最後呼叫 resetStore(this) 方法,來看一下這個方法的定義:

function resetStore (store) {
  store._actions = Object.create(null)
  store._mutations = Object.create(null)
  store._wrappedGetters = Object.create(null)
  const state = store.state
  // init root module
  installModule(store, state, [], store._options, true)
  // init all runtime modules
  Object.keys(store._runtimeModules).forEach(key => {
    installModule(store, state, key.split('.'), store._runtimeModules[key], true)
  })
  // reset vm
  resetStoreVM(store, state)
}複製程式碼

這個方法作用就是重置 store 物件,重置 store 的 _actions、_mutations、_wrappedGetters 等等屬性。然後再次呼叫 installModules 去重新安裝一遍 Module 對應的這些屬性,注意這裡我們的最後一個引數 hot 為true,表示它是一次熱更新。這樣在 installModule 這個方法體類,如下這段邏輯就不會執行

function installModule (store, rootState, path, module, hot) {
  ... 
  // 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, state || {})
    })
  }
  ...
}複製程式碼

由於 hot 始終為 true,這裡我們就不會重新對狀態樹做設定,我們的 state 保持不變。因為我們已經明確的刪除了對應 path 下的 state 了,要做的事情只不過就是重新註冊一遍 muations、actions 以及 getters。

回撥 resetStore 方法,接下來遍歷 this._runtimeModules 模組,重新安裝所有剩餘的 runtime Moudles。最後還是呼叫 resetStoreVM 方法去重置 Store 的 _vm 物件。

hotUpdate(newOptions)

hotUpdate 的作用是熱載入新的 action 和 mutation。 來看一下它的實現:

hotUpdate (newOptions) {
  updateModule(this._options, newOptions)
  resetStore(this)
}複製程式碼

函式首先呼叫 updateModule 方法去更新狀態,其中當前 Store 的 opition 配置和要更新的 newOptions 會作為引數。來看一下這個函式的實現:

function updateModule (targetModule, newModule) {
  if (newModule.actions) {
    targetModule.actions = newModule.actions
  }
  if (newModule.mutations) {
    targetModule.mutations = newModule.mutations
  }
  if (newModule.getters) {
    targetModule.getters = newModule.getters
  }
  if (newModule.modules) {
    for (const key in newModule.modules) {
      if (!(targetModule.modules && targetModule.modules[key])) {
        console.warn(
          `[vuex] trying to add a new module '${key}' on hot reloading, ` +
          'manual reload is needed'
        )
        return
      }
      updateModule(targetModule.modules[key], newModule.modules[key])
    }
  }
}複製程式碼

首先我們對 newOptions 物件的 actions、mutations 以及 getters 做了判斷,如果有這些屬性的話則替換 targetModule(當前 Store 的 options)對應的屬性。最後判斷如果 newOptions 包含 modules 這個 key,則遍歷這個 modules 物件,如果 modules 對應的 key 不在之前的 modules 中,則報一條警告,因為這是新增一個新的 module ,需要手動重新載入。如果 key 在之前的 modules,則遞迴呼叫 updateModule,熱更新子模組。

呼叫完 updateModule 後,回到 hotUpdate 函式,接著呼叫 resetStore 方法重新設定 store,剛剛我們已經介紹過了。

replaceState

replaceState的作用是替換整個 rootState,一般在用於除錯,來看一下它的實現:

replaceState (state) {
    this._withCommit(() => {
      this._vm.state = state
    })
  }複製程式碼

函式非常簡單,就是呼叫 this._withCommit 方法修改 Store 的 rootState,之所以提供這個 API 是由於在我們是不能在 muations 的回撥函式外部去改變 state。

到此為止,API 部分介紹完了,其實整個 Vuex 原始碼下的 src/index.js 檔案裡的程式碼基本都過了一遍。

輔助函式

Vuex 除了提供我們 Store 物件外,還對外提供了一系列的輔助函式,方便我們在程式碼中使用 Vuex,提供了操作 store 的各種屬性的一系列語法糖,下面我們來一起看一下:

mapState

mapState 工具函式會將 store 中的 state 對映到區域性計算屬性中。為了更好理解它的實現,先來看一下它的使用示例:

// vuex 提供了獨立的構建工具函式 Vuex.mapState
import { mapState } from 'vuex'
export default {
  // ...
  computed: mapState({
    // 箭頭函式可以讓程式碼非常簡潔
    count: state => state.count,
    // 傳入字串 'count' 等同於 `state => state.count`
    countAlias: 'count',
    // 想訪問區域性狀態,就必須藉助於一個普通函式,函式中使用 `this` 獲取區域性狀態
    countPlusLocalState (state) {
      return state.count + this.localCount
    }
  })
}複製程式碼

當計算屬性名稱和狀態子樹名稱對應相同時,我們可以向 mapState 工具函式傳入一個字串陣列。

computed: mapState([
  // 對映 this.count 到 this.$store.state.count
  'count'
])複製程式碼

通過例子我們可以直觀的看到,mapState 函式可以接受一個物件,也可以接收一個陣列,那它底層到底幹了什麼事呢,我們一起來看一下原始碼這個函式的定義:

export function mapState (states) {
  const res = {}
  normalizeMap(states).forEach(({ key, val }) => {
    res[key] = function mappedState () {
      return typeof val === 'function'
        ? val.call(this, this.$store.state, this.$store.getters)
        : this.$store.state[val]
    }
  })
  return res
}複製程式碼

函式首先對傳入的引數呼叫 normalizeMap 方法,我們來看一下這個函式的定義:

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

這個方法判斷引數 map 是否為陣列,如果是陣列,則呼叫陣列的 map 方法,把陣列的每個元素轉換成一個 {key, val: key}的物件;否則傳入的 map 就是一個物件(從 mapState 的使用場景來看,傳入的引數不是陣列就是物件),我們呼叫 Object.keys 方法遍歷這個 map 物件的 key,把陣列的每個 key 都轉換成一個 {key, val: key}的物件。最後我們把這個物件陣列作為 normalizeMap 的返回值。

回到 mapState 函式,在呼叫了 normalizeMap 函式後,把傳入的 states 轉換成由 {key, val} 物件構成的陣列,接著呼叫 forEach 方法遍歷這個陣列,構造一個新的物件,這個新物件每個元素都返回一個新的函式 mappedState,函式對 val 的型別判斷,如果 val 是一個函式,則直接呼叫這個 val 函式,把當前 store 上的 state 和 getters 作為引數,返回值作為 mappedState 的返回值;否則直接把 this.$store.state[val] 作為 mappedState 的返回值。

那麼為何 mapState 函式的返回值是這樣一個物件呢,因為 mapState 的作用是把全域性的 state 和 getters 對映到當前元件的 computed 計算屬性中,我們知道在 Vue 中 每個計算屬性都是一個函式。

為了更加直觀地說明,回到剛才的例子:

import { mapState } from 'vuex'
export default {
  // ...
  computed: mapState({
    // 箭頭函式可以讓程式碼非常簡潔
    count: state => state.count,
    // 傳入字串 'count' 等同於 `state => state.count`
    countAlias: 'count',
    // 想訪問區域性狀態,就必須藉助於一個普通函式,函式中使用 `this` 獲取區域性狀態
    countPlusLocalState (state) {
      return state.count + this.localCount
    }
  })
}複製程式碼

經過 mapState 函式呼叫後的結果,如下所示:

import { mapState } from 'vuex'
export default {
  // ...
  computed: {
    count() {
      return this.$store.state.count
    },
    countAlias() {
      return this.$store.state['count']
    },
    countPlusLocalState() {
      return this.$store.state.count + this.localCount
    }
  }
}複製程式碼

我們再看一下 mapState 引數為陣列的例子:

computed: mapState([
  // 對映 this.count 到 this.$store.state.count
  'count'
])複製程式碼

經過 mapState 函式呼叫後的結果,如下所示:

computed: {
  count() {
    return this.$store.state['count']
  }
}複製程式碼

mapGetters

mapGetters 工具函式會將 store 中的 getter 對映到區域性計算屬性中。它的功能和 mapState 非常類似,我們來直接看它的實現:

export function mapGetters (getters) {
  const res = {}
  normalizeMap(getters).forEach(({ key, val }) => {
    res[key] = function mappedGetter () {
      if (!(val in this.$store.getters)) {
        console.error(`[vuex] unknown getter: ${val}`)
      }
      return this.$store.getters[val]
    }
  })
  return res
}複製程式碼

mapGetters 的實現也和 mapState 很類似,不同的是它的 val 不能是函式,只能是一個字串,而且會檢查 val in this.$store.getters 的值,如果為 false 會輸出一條錯誤日誌。為了更直觀地理解,我們來看一個簡單的例子:

import { mapGetters } from 'vuex'
export default {
  // ...
  computed: {
    // 使用物件擴充套件操作符把 getter 混入到 computed 中
    ...mapGetters([
      'doneTodosCount',
      'anotherGetter',
      // ...
    ])
  }
}複製程式碼

經過 mapGetters 函式呼叫後的結果,如下所示:

import { mapGetters } from 'vuex'
export default {
  // ...
  computed: {
    doneTodosCount() {
      return this.$store.getters['doneTodosCount']
    },
    anotherGetter() {
      return this.$store.getters['anotherGetter']
    }
  }
}複製程式碼

再看一個引數 mapGetters 引數是物件的例子:

computed: mapGetters({
  // 對映 this.doneCount 到 store.getters.doneTodosCount
  doneCount: 'doneTodosCount'
})複製程式碼

經過 mapGetters 函式呼叫後的結果,如下所示:

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

mapActions

mapActions 工具函式會將 store 中的 dispatch 方法對映到元件的 methods 中。和 mapState、mapGetters 也類似,只不過它對映的地方不是計算屬性,而是元件的 methods 物件上。我們來直接看它的實現:

export function mapActions (actions) {
  const res = {}
  normalizeMap(actions).forEach(({ key, val }) => {
    res[key] = function mappedAction (...args) {
      return this.$store.dispatch.apply(this.$store, [val].concat(args))
    }
  })
  return res
}複製程式碼

可以看到,函式的實現套路和 mapState、mapGetters 差不多,甚至更簡單一些, 實際上就是做了一層函式包裝。為了更直觀地理解,我們來看一個簡單的例子:

import { mapActions } from 'vuex'
export default {
  // ...
  methods: {
    ...mapActions([
      'increment' // 對映 this.increment() 到 this.$store.dispatch('increment')
    ]),
    ...mapActions({
      add: 'increment' // 對映 this.add() to this.$store.dispatch('increment')
    })
  }
}複製程式碼

經過 mapActions 函式呼叫後的結果,如下所示:

import { mapActions } from 'vuex'
export default {
  // ...
  methods: {
    increment(...args) {
      return this.$store.dispatch.apply(this.$store, ['increment'].concat(args))
    }
    add(...args) {
      return this.$store.dispatch.apply(this.$store, ['increment'].concat(args))
    }
  }
}複製程式碼

mapMutations

mapMutations 工具函式會將 store 中的 commit 方法對映到元件的 methods 中。和 mapActions 的功能幾乎一樣,我們來直接看它的實現:

export function mapMutations (mutations) {
  const res = {}
  normalizeMap(mutations).forEach(({ key, val }) => {
    res[key] = function mappedMutation (...args) {
      return this.$store.commit.apply(this.$store, [val].concat(args))
    }
  })
  return res
}複製程式碼

函式的實現幾乎也和 mapActions 一樣,唯一差別就是對映的是 store 的 commit 方法。為了更直觀地理解,我們來看一個簡單的例子:

import { mapMutations } from 'vuex'
export default {
  // ...
  methods: {
    ...mapMutations([
      'increment' // 對映 this.increment() 到 this.$store.commit('increment')
    ]),
    ...mapMutations({
      add: 'increment' // 對映 this.add() 到 this.$store.commit('increment')
    })
  }
}複製程式碼

經過 mapMutations 函式呼叫後的結果,如下所示:

import { mapActions } from 'vuex'
export default {
  // ...
  methods: {
    increment(...args) {
      return this.$store.commit.apply(this.$store, ['increment'].concat(args))
    }
    add(...args) {
      return this.$store.commit.apply(this.$store, ['increment'].concat(args))
    }
  }
}複製程式碼

外掛

Vuex 的 store 接收 plugins 選項,一個 Vuex 的外掛就是一個簡單的方法,接收 store 作為唯一引數。外掛作用通常是用來監聽每次 mutation 的變化,來做一些事情。

在 store 的建構函式的最後,我們通過如下程式碼呼叫外掛:

import devtoolPlugin from './plugins/devtool'

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

我們通常例項化 store 的時候,還會呼叫 logger 外掛,程式碼如下:

import Vue from 'vue'
import Vuex from 'vuex'
import createLogger from 'vuex/dist/logger'

Vue.use(Vuex)

const debug = process.env.NODE_ENV !== 'production'

export default new Vuex.Store({
  ...
  plugins: debug ? [createLogger()] : []
})複製程式碼

在上述 2 個例子中,我們分別呼叫了 devtoolPlugin 和 createLogger() 2 個外掛,它們是 Vuex 內建外掛,我們接下來分別看一下他們的實現。

devtoolPlugin

devtoolPlugin 主要功能是利用 Vue 的開發者工具和 Vuex 做配合,通過開發者工具的皮膚展示 Vuex 的狀態。它的原始碼在 src/plugins/devtool.js 中,來看一下這個外掛到底做了哪些事情。

const devtoolHook =
  typeof window !== 'undefined' &&
  window.__VUE_DEVTOOLS_GLOBAL_HOOK__

export default function devtoolPlugin (store) {
  if (!devtoolHook) return

  store._devtoolHook = devtoolHook

  devtoolHook.emit('vuex:init', store)

  devtoolHook.on('vuex:travel-to-state', targetState => {
    store.replaceState(targetState)
  })

  store.subscribe((mutation, state) => {
    devtoolHook.emit('vuex:mutation', mutation, state)
  })
}複製程式碼

我們直接從對外暴露的 devtoolPlugin 函式看起,函式首先判斷了devtoolHook 的值,如果我們瀏覽器裝了 Vue 開發者工具,那麼在 window 上就會有一個 __VUE_DEVTOOLS_GLOBAL_HOOK__ 的引用, 那麼這個 devtoolHook 就指向這個引用。

接下來通過 devtoolHook.emit('vuex:init', store) 派發一個 Vuex 初始化的事件,這樣開發者工具就能拿到當前這個 store 例項。

接下來通過 devtoolHook.on('vuex:travel-to-state', targetState => { store.replaceState(targetState) })監聽 Vuex 的 traval-to-state 的事件,把當前的狀態樹替換成目標狀態樹,這個功能也是利用 Vue 開發者工具替換 Vuex 的狀態。

最後通過 store.subscribe((mutation, state) => { devtoolHook.emit('vuex:mutation', mutation, state) }) 方法訂閱 store 的 state 的變化,當 store 的 mutation 提交了 state 的變化, 會觸發回撥函式——通過 devtoolHook 派發一個 Vuex mutation 的事件,mutation 和 rootState 作為引數,這樣開發者工具就可以觀測到 Vuex state 的實時變化,在皮膚上展示最新的狀態樹。

loggerPlugin

通常在開發環境中,我們希望實時把 mutation 的動作以及 store 的 state 的變化實時輸出,那麼我們可以用 loggerPlugin 幫我們做這個事情。它的原始碼在 src/plugins/logger.js 中,來看一下這個外掛到底做了哪些事情。

// Credits: borrowed code from fcomb/redux-logger

import { deepCopy } from '../util'

export default function createLogger ({
  collapsed = true,
  transformer = state => state,
  mutationTransformer = mut => mut
} = {}) {
  return store => {
    let prevState = deepCopy(store.state)

    store.subscribe((mutation, state) => {
      if (typeof console === 'undefined') {
        return
      }
      const nextState = deepCopy(state)
      const time = new Date()
      const formattedTime = ` @ ${pad(time.getHours(), 2)}:${pad(time.getMinutes(), 2)}:${pad(time.getSeconds(), 2)}.${pad(time.getMilliseconds(), 3)}`
      const formattedMutation = mutationTransformer(mutation)
      const message = `mutation ${mutation.type}${formattedTime}`
      const startMessage = collapsed
        ? console.groupCollapsed
        : console.group

      // render
      try {
        startMessage.call(console, message)
      } catch (e) {
        console.log(message)
      }

      console.log('%c prev state', 'color: #9E9E9E; font-weight: bold', transformer(prevState))
      console.log('%c mutation', 'color: #03A9F4; font-weight: bold', formattedMutation)
      console.log('%c next state', 'color: #4CAF50; font-weight: bold', transformer(nextState))

      try {
        console.groupEnd()
      } catch (e) {
        console.log('—— log end ——')
      }

      prevState = nextState
    })
  }
}

function repeat (str, times) {
  return (new Array(times + 1)).join(str)
}

function pad (num, maxLength) {
  return repeat('0', maxLength - num.toString().length) + num
}複製程式碼

外掛對外暴露的是 createLogger 方法,它實際上接受 3 個引數,它們都有預設值,通常我們用預設值就可以。createLogger 的返回的是一個函式,當我執行 logger 外掛的時候,實際上執行的是這個函式,下面來看一下這個函式做了哪些事情。

函式首先執行了 let prevState = deepCopy(store.state) 深拷貝當前 store 的 rootState。這裡為什麼要深拷貝,因為如果是單純的引用,那麼 store.state 的任何變化都會影響這個引用,這樣就無法記錄上一個狀態了。我們來了解一下 deepCopy 的實現,在 src/util.js 裡定義:

function find (list, f) {
  return list.filter(f)[0]
}

export function deepCopy (obj, cache = []) {
  // just return if obj is immutable value
  if (obj === null || typeof obj !== 'object') {
    return obj
  }

  // if obj is hit, it is in circular structure
  const hit = find(cache, c => c.original === obj)
  if (hit) {
    return hit.copy
  }

  const copy = Array.isArray(obj) ? [] : {}
  // put the copy into cache at first
  // because we want to refer it in recursive deepCopy
  cache.push({
    original: obj,
    copy
  })

  Object.keys(obj).forEach(key => {
    copy[key] = deepCopy(obj[key], cache)
  })

  return copy
}複製程式碼

deepCopy 並不陌生,很多開源庫如 loadash、jQuery 都有類似的實現,原理也不難理解,主要是構造一個新的物件,遍歷原物件或者陣列,遞迴呼叫 deepCopy。不過這裡的實現有一個有意思的地方,在每次執行 deepCopy 的時候,會用 cache 陣列快取當前巢狀的物件,以及執行 deepCopy 返回的 copy。如果在 deepCopy 的過程中通過 find(cache, c => c.original === obj) 發現有迴圈引用的時候,直接返回 cache 中對應的 copy,這樣就避免了無限迴圈的情況。

回到 loggerPlugin 函式,通過 deepCopy 拷貝了當前 state 的副本並用 prevState 變數儲存,接下來呼叫 store.subscribe 方法訂閱 store 的 state 的變。 在回撥函式中,也是先通過 deepCopy 方法拿到當前的 state 的副本,並用 nextState 變數儲存。接下來獲取當前格式化時間已經格式化的 mutation 變化的字串,然後利用 console.group 以及 console.log 分組輸出 prevState、mutation以及 nextState,這裡可以通過我們 createLogger 的引數 collapsed、transformer 以及 mutationTransformer 來控制我們最終 log 的顯示效果。在函式的最後,我們把 nextState 賦值給 prevState,便於下一次 mutation。

總結

Vuex 2.0 的原始碼分析到這就告一段落了,最後我再分享一下看原始碼的小心得:對於一個庫或者框架原始碼的研究前,首先了解他們的使用場景、官網文件等;然後一定要用他,至少也要寫幾個小 demo,達到熟練掌握的程度;最後再從入口、API、使用方法等等多個維度去了解他內部的實現細節。如果這個庫過於龐大,那就先按模組和功能拆分,一點點地消化。

最後還有一個問題,有些同學會問,原始碼那麼枯燥,我們分析學習它的有什麼好處呢?首先,學習原始碼有助於我們更深入掌握和應用這個庫或者框架;其次,我們還可以學習到原始碼中很多程式設計技巧,可以遷移到我們平時的開發工作中;最後,對於一些高階開發工程師而言,我們可以學習到它的設計思想,對將來有一天我們也去設計一個庫或者框架是非常有幫助的,這也是提升自身能力水平的非常好的途徑。


歡迎關注DDFE
GITHUB:github.com/DDFE
微信公眾號:微信搜尋公眾號“DDFE”或掃描下面的二維碼

Vuex 2.0 原始碼分析

相關文章