解密Vuex: 從原始碼開始

玩弄心裡的鬼發表於2018-05-21

很多時候我們在開發一個Vue專案的時候,用一個Vue例項封裝的EventBus來處理事件的傳遞從而達到元件間狀態的共享。但是隨著業務的複雜度提升,元件間共享的狀態變得難以追溯和維護。因此我們需要將這些共享的狀態通過一個全域性的單例物件儲存下來,在通過指定的方法去更新狀態更新元件。

回顧基礎知識

既然都說vuex是解決元件間資料通訊的一種方式,那我們先來回顧下元件間通訊的幾種方法:

props傳值

這種方法我們可以直接將父元件的值傳遞給子元件,並在子元件中呼叫。很明顯,props是一種單向的資料繫結,並且子元件不能去修改props的值。在vue1.x中可以通過.async來實現雙向繫結,但是這種雙向的繫結很難去定位資料錯誤的來源,在vue2.3.0版本又加回了.async。

// 父元件
<Child name="hahaha" />

// 子元件
<div>{{name}}</div>
// ...
props: ['name']
// ...
複製程式碼

$on $emit

如果子元件向父元件傳遞資料,我們可以通過$emit$on,在子元件註冊事件,在父元件監聽事件並作回撥。

// 父元件
<Child @getName="getNameCb" />
// ...
getNameCb(name) {
  console.log('name');
}

// 子元件
someFunc() {
  this.$emit('getName', 'hahahah');
}
複製程式碼

EventBus

前面兩種方式很容易就解決了父子元件的通訊問題,但是很難受的是,處理兄弟元件或者是祖孫元件的通訊時你需要一層一層的去傳遞props,一層一層的去$emit。那麼其實就可以使用EventBus了,EventBus實際上是一個Vue的例項,我們通過Vue例項的$emit$on來進行事件的釋出訂閱。但是問題也很明顯,過多的使用EventBus也會造成資料來源難以追溯的問題,並且不及時通過$off登出事件的化,也會發生很多奇妙的事情。

import EventBus from '...';

// 某一個元件
// ...
mounted() {
  EventBus.$on('someevent', (data) => {
    // ...
  })
}
// ...

// 某另一個元件
// ...
someFunc() {
  EventBus.$emit('someevent', 'hahahah');
}
// ...
複製程式碼

Vuex

接下來就是我們要講的Vuex了,以上這些問題Vuex都可以解決,Vuex也是Vue官方團隊維護的Vue全家桶中的一員,作為Vue的親兒子,Vuex毫無疑問是非常適合Vue專案的了。但是Vuex也不是完美的,毫無疑問在應用中加一層Store或多或少的都會增加學習和維護的成本,並且說白了一個小專案沒幾個元件,Vuex只會增加你的程式碼量,酌情使用吧。下面就進入到我們Vuex原始碼學習的正文了。

剖析原理

解密Vuex: 從原始碼開始

  • state:這裡的state是一個單一的狀態樹;
  • mutations:在這裡將觸發同步事件,可以直接修改state;
  • actions:通過commit提交mutation,並且可以執行非同步操作;
  • getters:這張圖省略了getter,可以通過getter獲取狀態,同時也將被轉化為vuex內部vue例項(_vm)的computed屬性,從而實現響應式;

回顧一下Vuex的設計原理。我們把元件間共享的狀態儲存到Vuex的state中,並且元件會根據這個state的值去渲染。當需要更新state的時候,我們在元件中呼叫Vuex提供的dispatch方法去觸發action,而在action中去通過commit方法去提交一個mutation,最後通過mutation去直接修改state,元件監聽到state的更新最後更新元件。需要注意的有,mutaion不能執行非同步操作,非同步操作需要放到action中去完成;直接修改state的有且僅有mutation。(具體的使用方法筆者就不去囉嗦了,官方文件寫的很詳細,還有中文版,為啥不看...)

在筆者看來,Vuex的作用是用來解決元件間狀態的共享,使專案更加利於維護,同樣也是貫徹單向資料流這個理念。但其實從功能上講,Vuex也像是一個前端的“資料庫”,我們在使用Vuex時很像是後端同學對庫的增刪改查。

在Vue的專案中,我們也可以去使用Redux等來處理共享的狀態,甚至是可以自己簡單封裝一個工具來處理狀態,畢竟引入Vuex對開發同學來說也是有一定成本的。但是歸根到底都是單向資料流的思想,一通則百通。

插個題外話,筆者在研究Vue ssr的時候不想去用Vuex做前後端狀態共享,於是基於EventBus的思想對Vue例項進行了封裝也同樣實現了Vuex的功能,有興趣的同學可以看下。戳這裡

剖析原始碼

首先我們將掛載完Vuex例項的Vue例項列印出來看看掛載完增加了哪些東西。

解密Vuex: 從原始碼開始
這裡不同於vue-router會在Vue的例項上增加很多的自定義屬性,有的僅僅是一個$store屬性,指向初始化的Vuex例項。

專案結構

拿到一個專案的原始碼我們要先去瀏覽他它的目錄結構:

解密Vuex: 從原始碼開始
其中src是我們的原始碼部分:

  • helpers.js是Vuex的一些基礎API,例如mapState、mapActions這些;
  • index.js和index.esm.js是我們的入口檔案,不同的是index.esm.js採用了EcmaScript Module編寫;
  • mixin.js是對mixin封裝的一個函式;
  • module是Vuex中module相關邏輯的原始碼;
  • plugins中封裝了我們常用的devtool和log相關的邏輯;
  • store.js是主要邏輯,這裡封裝了一個Store類;
  • util.js是對一些工具函式的封裝;

應用入口

通常在構建包含Vuex的程式的時候會這麼寫:

import Vue from 'vue';
import Vuex from 'vuex';

Vue.use(Vuex);

const store = new Vuex({
  state: {...},
  mutations: {...},
  actions: {...},
});

new Vue({
  store,
  template,
}).$mount('#app')  
複製程式碼

用過redux的小夥伴可以發現Vuex採用的是物件導向化的配置方式,不同於redux那種“偏函式式的初始化”,能更容易的讓開發者理解。並且Vuex是以外掛的形式安裝在Vue例項上。

安裝外掛

在store.js中定義了一個符合Vue外掛機制的匯出函式install,並且封裝了一個beforeCreate的mixin。

原始碼位置:/src/store.js /src/mixin.js

// store.js
// ...
// 繫結一個Vue例項;
// 不用將Vue打包進專案便可以使用Vue的提供的一些靜態方法;
let Vue
// ...
// Vue 外掛機制
export function install (_Vue) {
  if (Vue && _Vue === Vue) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(
        '[vuex] already installed. Vue.use(Vuex) should be called only once.'
      )
    }
    return
  }
  Vue = _Vue
  // 封裝mixin掛載$store
  applyMixin(Vue)
}
// mixin.js
export default function (Vue) {
  // 獲取版本號
  const version = Number(Vue.version.split('.')[0])
  if (version >= 2) {
    Vue.mixin({ beforeCreate: vuexInit })
  } else {
    // 相容低版本的Vue
    const _init = Vue.prototype._init
    Vue.prototype._init = function (options = {}) {
      options.init = options.init
        ? [vuexInit].concat(options.init)
        : vuexInit
      _init.call(this, options)
    }
  }
  // 封裝mixin;  
  // 繫結$store例項;
  // 子元件的$store也始終指向根元件掛載的store例項;
  function vuexInit () {
    const options = this.$options
    // store injection
    if (options.store) {
      // store可能是一個工廠函式,vue ssr中避免狀態交叉汙染通常會用工廠函式封裝store;
      this.$store = typeof options.store === 'function'
        ? options.store() 
        : options.store
    } else if (options.parent && options.parent.$store) {
      // 子元件從其父元件引用$store屬性,巢狀設定
      this.$store = options.parent.$store
    }
  }
}
複製程式碼

這裡其實做的很簡單就是在beforeCreate鉤子中為Vue例項繫結了一個$store屬性指向我們定義的Store例項上。此外也可以看到Vuex也採用了很常見的匯出一個Vue例項,從而不將Vue打包進專案就能使用Vue提供的一些方法。

例項化Store

例項化Store類,我們先來看Store類的建構函式:

原始碼位置:/src/store.js

constructor (options = {}) {
    // 如果window上有Vue例項,直接安裝外掛;
    if (!Vue && typeof window !== 'undefined' && window.Vue) {
      install(window.Vue)
    }
    if (process.env.NODE_ENV !== 'production') {
      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(this instanceof Store, `store must be called with the new operator.`)
    }
    // 例項化store時傳入的配置項;
    const {
      plugins = [],
      strict = false
    } = options

    // store internal state
    // 收集commit
    this._committing = false
    // 收集action
    this._actions = Object.create(null)
    // action訂閱者
    this._actionSubscribers = []
    // 收集mutation
    this._mutations = Object.create(null)
    // 收集getter
    this._wrappedGetters = Object.create(null)
    // 收集module
    this._modules = new ModuleCollection(options)
    this._modulesNamespaceMap = Object.create(null)
    this._subscribers = []
    // 用以處理狀態變化的Vue例項
    this._watcherVM = new Vue()
    // 將dispatch和commit呼叫的this指向Store例項;
    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
    // 獲取state
    const state = this._modules.root.state
    // 主要作用就是生成namespace的map,掛載action、mutation、getter;
    installModule(this, state, [], this._modules.root)
    // 通過vm重設store,新建Vue物件使用Vue內部的響應式實現註冊state以及computed
    resetStoreVM(this, state)
    // 使用外掛
    plugins.forEach(plugin => plugin(this))
    if (Vue.config.devtools) {
      devtoolPlugin(this)
    }
複製程式碼

可以看出整個建構函式中,主要就是宣告一些基礎的變數,然後最主要的就是執行了intsllModule函式來註冊Module和resetStoreVM來使Store具有“響應式”。 至於ModuleCollection相關的程式碼我們暫且不去深究,知道他就是一個Module的收集器,並且提供了一些方法即可。

接下來看這兩個主要的方法,首先是installModule,在這個方法中回去生成名稱空間,然後掛載mutation、action、getter:

原始碼位置:/src/store.js

function installModule (store, rootState, path, module, hot) {
  const isRoot = !path.length
  const namespace = store._modules.getNamespace(path)
  // 生成name 和 Module 的 Map
  if (module.namespaced) {
    store._modulesNamespaceMap[namespace] = module
  }
  if (!isRoot && !hot) {
    const parentState = getNestedState(rootState, path.slice(0, -1))
    const moduleName = path[path.length - 1]
    // 為module註冊響應式;
    store._withCommit(() => {
      Vue.set(parentState, moduleName, module.state)
    })
  }
  const local = module.context = makeLocalContext(store, namespace, path)
  // 掛載mutation
  module.forEachMutation((mutation, key) => {
    const namespacedType = namespace + key
    registerMutation(store, namespacedType, mutation, local)
  })
  // 掛載action
  module.forEachAction((action, key) => {
    const type = action.root ? key : namespace + key
    const handler = action.handler || action
    registerAction(store, type, handler, local)
  })
  // 掛載getter
  module.forEachGetter((getter, key) => {
    const namespacedType = namespace + key
    registerGetter(store, namespacedType, getter, local)
  })
  // 遞迴安裝Module
  module.forEachChild((child, key) => {
    installModule(store, rootState, path.concat(key), child, hot)
  })
}

// ...
// 註冊mutation
function registerMutation (store, type, handler, local) {
  // 在_mutations中找到對應type的mutation陣列
  // 如果是第一次建立,就初始化為一個空陣列
  const entry = store._mutations[type] || (store._mutations[type] = [])
  // push一個帶有payload引數的包裝過的函式
  entry.push(function wrappedMutationHandler (payload) {
    handler.call(store, local.state, payload)
  })
}
// 註冊action
function registerAction (store, type, handler, local) {
  // 根據type找到對應的action; 
  const entry = store._actions[type] || (store._actions[type] = [])
  // push一個帶有payload引數的包裝過的函式
  entry.push(function wrappedActionHandler (payload, cb) {
    let res = handler.call(store, {
      dispatch: local.dispatch,
      commit: local.commit,
      getters: local.getters,
      state: local.state,
      rootGetters: store.getters,
      rootState: store.state
    }, payload, cb)
    // 如果 res 不是 promise 物件 ,將其轉化為promise物件
    // 這是因為store.dispatch 方法裡的 Promise.all()方法。
    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
    }
  })
}
// 註冊getter
function registerGetter (store, type, rawGetter, local) {
  if (store._wrappedGetters[type]) {
    if (process.env.NODE_ENV !== 'production') {
      console.error(`[vuex] duplicate getter key: ${type}`)
    }
    return
  }
  // 將定義的getter全部儲存到_wrappedGetters中;
  store._wrappedGetters[type] = function wrappedGetter (store) {
    return rawGetter(
      local.state, // local state
      local.getters, // local getters
      store.state, // root state
      store.getters // root getters
    )
  }
}
複製程式碼

在Vuex的module中,我們是可以拆分很多個module出來的,每一個拆分出來的module又可以當作一個全新的module掛載在父級module上,因此這時候就需要一個path變數來區分層級關係了,我們可以根據這個path來去拿到每一次module下的state、mutation、action等。

接下來是resetStoreVM這個方法,在這個方法中,為store繫結了一個指向新的Vue例項的_vm屬性,同時傳入了state和computed,computed就是我們在store中設定的getter。

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

  // bind store public getters
  store.getters = {}
  const wrappedGetters = store._wrappedGetters
  const computed = {}
  // 為每一個getter設定get;
  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
    })
  })

  // 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繫結Vue例項並註冊state和computed
  store._vm = new Vue({
    data: {
      $$state: state
    },
    computed
  })
  Vue.config.silent = silent

  // enable strict mode for new vm
  if (store.strict) {
    enableStrictMode(store)
  }
  // 去除繫結舊vm
  if (oldVm) {
    if (hot) {
      // dispatch changes in all subscribed watchers
      // to force getter re-evaluation for hot reloading.
      store._withCommit(() => {
        oldVm._data.$$state = null
      })
    }
    Vue.nextTick(() => oldVm.$destroy())
  }
}
複製程式碼

dispatch和commit

在Vuex中有兩個重要的操作,一個是dispatch,一個是commit,我們通過dispatch去觸發一個action,然後在action中我們通過提交commit去達到更新state的目的。下面就來看看這兩部門的原始碼。

原始碼位置:/src/store.js

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

    const mutation = { type, payload }
    // 找到type對應的mutation方法;
    const entry = this._mutations[type]
    if (!entry) {
      if (process.env.NODE_ENV !== 'production') {
        console.error(`[vuex] unknown mutation type: ${type}`)
      }
      return
    }
    // 執行mutation;
    this._withCommit(() => {
      entry.forEach(function commitIterator (handler) {
        handler(payload)
      })
    })
    // 通知訂閱者
    this._subscribers.forEach(sub => sub(mutation, this.state))

    if (
      process.env.NODE_ENV !== 'production' &&
      options && options.silent
    ) {
      console.warn(
        `[vuex] mutation type: ${type}. Silent option has been removed. ` +
        'Use the filter functionality in the vue-devtools'
      )
    }
  }
  
  dispatch (_type, _payload) {
    // check object-style dispatch
    // 檢驗值;
    const {
      type,
      payload
    } = unifyObjectStyle(_type, _payload)

    const action = { type, payload }
    // 獲取type對應的action;
    const entry = this._actions[type]
    if (!entry) {
      if (process.env.NODE_ENV !== 'production') {
        console.error(`[vuex] unknown action type: ${type}`)
      }
      return
    }
    // 通知action訂閱者;
    this._actionSubscribers.forEach(sub => sub(action, this.state))
    // 返回action
    return entry.length > 1
      ? Promise.all(entry.map(handler => handler(payload)))
      : entry[0](payload)
  }
複製程式碼

提供的靜態方法

Vuex為我們提供了一些靜態方法,都是通過呼叫繫結在Vue例項上的Store例項來操作我們的state、mutation、action和getter等。

原始碼位置:/src/helpers.js

//返回一個物件
//物件的屬性名對應於傳入的 states 的屬性名或者陣列元素
//執行這個函式的返回值根據 val 的不同而不同
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
})
// 返回一個物件
// 執行這個函式後將觸發指定的 mutation 
export const mapMutations = normalizeNamespace((namespace, mutations) => {
  const res = {}
  normalizeMap(mutations).forEach(({ key, val }) => {
    res[key] = function mappedMutation (...args) {
      // Get the commit method from store
      let commit = this.$store.commit
      if (namespace) {
        const module = getModuleByNamespace(this.$store, 'mapMutations', namespace)
        if (!module) {
          return
        }
        commit = module.context.commit
      }
      return typeof val === 'function'
        ? val.apply(this, [commit].concat(args))
        : commit.apply(this.$store, [val].concat(args))
    }
  })
  return res
})

export const mapGetters = normalizeNamespace((namespace, getters) => {
  const res = {}
  normalizeMap(getters).forEach(({ key, val }) => {
    // thie namespace has been mutate by normalizeNamespace
    val = namespace + val
    res[key] = function mappedGetter () {
      if (namespace && !getModuleByNamespace(this.$store, 'mapGetters', namespace)) {
        return
      }
      if (process.env.NODE_ENV !== 'production' && !(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
})

export const mapActions = normalizeNamespace((namespace, actions) => {
  const res = {}
  normalizeMap(actions).forEach(({ key, val }) => {
    res[key] = function mappedAction (...args) {
      // get dispatch function from store
      let dispatch = this.$store.dispatch
      if (namespace) {
        const module = getModuleByNamespace(this.$store, 'mapActions', namespace)
        if (!module) {
          return
        }
        dispatch = module.context.dispatch
      }
      return typeof val === 'function'
        ? val.apply(this, [dispatch].concat(args))
        : dispatch.apply(this.$store, [val].concat(args))
    }
  })
  return res
})
// 接受一個物件或者陣列,最後都轉化成一個陣列形式,陣列元素是包含key和value兩個屬性的物件
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)
  }
}
複製程式碼

結語

筆者沒有將全部的原始碼貼出來逐行分析,只是簡單的分析了核心邏輯的原始碼。總的來說Vuex原始碼不多,寫的很精練也很易懂,希望大家都能抽時間親自看看原始碼學習學習。

相關文章