好嗨詳細的vuex原始碼分析之API 是如何實現

Spdino發表於2018-10-22

源自:blog.csdn.net/sinat_17775…

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、使用方法等等多個維度去了解他內部的實現細節。如果這個庫過於龐大,那就先按模組和功能拆分,一點點地消化。

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

相關文章