为了账号安全,请及时绑定邮箱和手机立即绑定

Vuex 2.0 源码分析(下)

大家好,我叫黄轶,来自滴滴公共前端团队,最近在幕课网上线了一门 Vue.js 的实战课程——《Vue.js高仿饿了么外卖App 2016最火前端框架》,同时,我们团队最近写了一本书 ——《Vue.js 权威指南》,内容丰富,由浅入深。不过有一些同学反馈说缺少 Vuex 的介绍的章节。既然 Vue.js 2.0 已经正式发布了,我们也要紧跟步伐,和大家聊一聊 Vuex 2.0。本文并不打算讲官网已有的内容,而会通过源码分析的方式,让同学们从另外一个角度认识和理解 Vuex 2.0。

辅助函数

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、使用方法等等多个维度去了解他内部的实现细节。如果这个库过于庞大,那就先按模块和功能拆分,一点点地消化。

最后还有一个问题,有些同学会问,源码那么枯燥,我们分析学习它的有什么好处呢?首先,学习源码有助于我们更深入掌握和应用这个库或者框架;其次,我们还可以学习到源码中很多编程技巧,可以迁移到我们平时的开发工作中;最后,对于一些高级开发工程师而言,我们可以学习到它的设计思想,对将来有一天我们也去设计一个库或者框架是非常有帮助的,这也是提升自身能力水平的非常好的途径。

点击查看更多内容
62人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
Web前端工程师
手记
粉丝
1.8万
获赞与收藏
3713

关注作者,订阅最新文章

阅读免费教程

感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消