# Mutation

# mutation 使用方法

更改 Vuex 的 store 中的状态的唯一方法是提交 mutation。Vuex 中的 mutation 非常类似于事件:每个 mutation 都有一个字符串的 事件类型 (type) 和 一个 回调函数 (handler)。这个回调函数就是我们实际进行状态更改的地方,并且它会接受 state 作为第一个参数。我们来看一个例子:

const store = new Vuex.Store({
  state: {
    name: 'Jerry',
  },
  mutations: {
    changeName(state, newName) {
      state.name = newName
    }
  }
})

这个 Store 中定义了一个名为 changeName 的 mutation。我们可以在 Vue 组件中这样来使用:

export default {
  methods: {
    change() {
      this.$store.commit('changeName', 'Kobe')
    }
  },
}

确实跟事件系统很像,这里相当于“触发”一个 changeName 的事件,并且传递 'Kobe' 作为参数。Store 接收到“事件”以后,会执行 changeName 方法来修改 state 中的 name 属性的值。

# mutation 实现原理

# mutation 的注册

还是老样子,我们还是先看一下 Store 的构造函数,首先初始化了这两个变量:

this._committing = false
this._mutations = Object.create(null)

接着重新绑定了 commit 方法的作用域:

this.commit = function boundCommit (type, payload, options) {
  return commit.call(store, type, payload, options)
}

然后就是我们熟悉的代码:

installModule(this, state, [], this._modules.root)

它的内部会调用 makeLocalContext 来生成一个 local 对象,之前两节都分析过了,因为此时的 noNamespacetrue,所以这个 local 对象的 commit 属性的值就是 store.commit。此时的 local 的值为:

{
  commit: store.commit,
  dispatch: store.dispatch,
  getters: () => store.getters,
  state: () => getNestedState(store.state, path)
}

接着就开始注册 mutation 了:

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

module.forEachMutation 与上一节提到的 module.forEachGetter 类似,也是 Module 类上面的方法:

forEachMutation (fn) {
  if (this._rawModule.mutations) {
    forEachValue(this._rawModule.mutations, fn)
  }
}

这里的 this._rawModule 就是我们的 options,它定义了一个 changeName 的 mutation,所以会进 if 判断里面,执行:

forEachValue(this._rawModule.mutations, fn)

也就是遍历这个 mutation 对象,用 fn 去操作。这里的 fn 就是:

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

这个匿名函数了。因为我们的 namespace 为空,所以这里的 namespaceType 就是 key 的值,也就是 changeName,接着执行 registerMutation 去注册我们的 mutation。我们来看一下注册的实现逻辑:

function registerMutation (store, type, handler, local) {
  const entry = store._mutations[type] || (store._mutations[type] = [])
  entry.push(function wrappedMutationHandler (payload) {
    handler.call(store, local.state, payload)
  })
}

首先定义我们根据 type ,也就是我们的 mutation 的名称来获取对应的方法,如果没有的话,初始化成一个空数组;接着,往这个数组里面添加一个函数 wrappedMutationHandler,这个函数接收一个 payload 作为参数,函数里面调用了:

handler.call(store, local.state, payload)

这里的 handler 就是我们自己在初始化 Store 时定义的 changeName 方法,这里我们也可以看出来,为什么 mutation 方法接收的参数,第一个是 state,第二个是 payload 了。

到这里,mutation 的初始化其实就已经完成了,做的事情其实很简单,就是往 store._mutations 中添加这个方法。

# mutation 的提交

有了 mutation 以后,我们就可以通过提交 mutation 的方式来修改 state 中某个属性的值了,我们结合一开始的例子看一下:

this.$store.commit('changeName', 'Kobe')

调用的这个 commit 方法,其实就是绑定后的 boundCommit 方法,实际调用的还是 commit.call(store, type, payload, options) 方法,只不过强行绑定了 store 作为函数运行作用域而已,我们来看看这个 commit 方法的实现:

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) {
    if (process.env.NODE_ENV !== 'production') {
      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 (
    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'
    )
  }
}

首先执行的是 unifyObjectStyle 方法,它其实也是针对参数做规范化,我们来看看它的实现:


function unifyObjectStyle (type, payload, options) {
  if (isObject(type) && type.type) {
    options = payload
    payload = type
    type = type.type
  }

  if (process.env.NODE_ENV !== 'production') {
    assert(typeof type === 'string', `expects string as the type, but found ${typeof type}.`)
  }

  return { type, payload, options }
}

由于我们调用 commit 的方式有两种:commit(type, payload, options) 或者 commit({type, palyload}, options),所以这个函数会针对两种不同的情况做统一化的操作。可以看出来,如果第一个参数是对象类型的话,那么就说明是通过第二种方式调用的,这时候要手动的把参数向后移位,再给 type 赋值(转化成第一种的方式),最后返回这个统一后的对象 { type, payload, options }

拿到这个对象以后,再继续往下看:

const mutation = { type, payload }
const entry = this._mutations[type]
if (!entry) {
  if (process.env.NODE_ENV !== 'production') {
    console.error(`[vuex] unknown mutation type: ${type}`)
  }
  return
}

这里把上面生成的统一化对象中的 { type, payload } 合并成一个 mutation 对象,接着获取我们的 mutation 函数,它是一个数组。如果没有找到这个 mutation 的话,会在开发环境报错提示。接着就是我们重点要介绍的一段逻辑了:

this._withCommit(() => {
  entry.forEach(function commitIterator (handler) {
    handler(payload)
  })
})

这里的代码实际上在执行我们的 mutation 方法,但是是用 this._withCommit 包裹的一层。这个函数也是定义在 Store 构造函数中,我们来看看它是干什么的:

_withCommit (fn) {
  const committing = this._committing
  this._committing = true
  fn()
  this._committing = committing
}

它接收一个函数作为参数,然后获取我们当前 store 的 _committing 状态,这个我们一开始就介绍了,它的初始值为 false。然后设置它的值为 true,表示当前正在提交 mutation,紧接着就开始执行我们传入的参数,执行完以后复位这个 _committing 状态。其实这种模式跟我们的切面编程有点类似,就是在 fn 函数的前后执行一些切面逻辑。

好了,再回到例子中,我们的 fn 对应的就是这个函数:

() => {
  entry.forEach(function commitIterator (handler) {
    handler(payload)
  })
}

这个函数的作用,就是遍历我们 entry 中的 mutation,然后执行 handler 函数,并把 payload 作为参数传进去。

这里的 handler,其实就是我们上面介绍的这个函数:

function wrappedMutationHandler (payload) {
  handler.call(store, local.state, payload)
}

也就是会执行我们的定义好的 changeName 方法,去改变 state 中的值。到这里,是不是有一些豁然开朗的感觉呢?

# mapMutations 使用方法

同样的, mutation 也有自己的 map 方法 mapMutations,可以将组件中的 methods 映射为 store.commit 调用。例如:

export default {
  // ...
  methods: {
    ...Vuex.mapMutations({
      change: 'changeName'
    })
  }
}

# mapMutations 实现原理

我们来看看它的实现:

export const mapMutations = normalizeNamespace((namespace, mutations) => {
  const res = {}
  if (process.env.NODE_ENV !== 'production' && !isValidMap(mutations)) {
    console.error('[vuex] mapMutations: mapper parameter must be either an Array or an Object')
  }
  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
})

和之前一样的套路,首先检查 mutations 的类型是不是数组或者对象,如果不是的话会在开发环境下报一个错误提示。接着,将 mutations 统一化成数组去遍历。在遍历中,把 key 赋值给 res 作为添加的属性,对应的值就是一个 mappedMutation 函数。在这个函数中,我们重点要理解它的返回值这行代码:

return typeof val === 'function'
        ? val.apply(this, [commit].concat(args))
        : commit.apply(this.$store, [val].concat(args))

对应我们的例子,这里的 val 其实是 "changeName" 这个字符串,很显然它执行的是 commit.apply(this.$store, [val].concat(args)),翻译一下就是:

commit.apply(this.$store, ['changeName'].concat(args))

也就是在 this.$store 的作用域下,执行 commit('changeName', ...args),是不是就是我们直接调用 this.$store.commit('changeName', ...args) 一样了?

还有另一种情况,就是我们此时 val 是一个函数,也就是说们的 mapMutations 的参数可以这么写:




 
 
 




export default {
  methods: {
    ...Vuex.mapMutations({
      change: commit => {
        commit('changeName', 'Jack')
      }
    })
  },
}

虽然不常用,但是确实是可以这么做的。这时候在源码中就会执行 val.apply(this, [commit].concat(args)),也就是把 commit 作为第一个参数放到这个 val 的参数列表中,这样我们就可以在函数中去提交这个 mutation 了。

# State 必须通过 mutation 来修改吗?

文章一开头提到了,更改 Vuex 的 store 中的状态的唯一方法是提交 mutation(这句话引用自 Vuex 官网,不是我说的)。但是真的是这样么?我们直接修改 store.state 中的某个属性,能成功修改吗?比如像这样:

this.$store.state.name = 'Jack'

读者不妨先结合之前分析过的 state 的原理思考一下 ...

OK,公布答案,其实这样是完全可以的。先说一下为什么可以修改,因为我们的 state ,最终会被转化为 Vue 实例的 data 属性中的 $$state,即我们的 store._vm.$$state,这是一个响应式的对象,我们修改这个对象中的属性值,就相当于我们在修改 Vue 中的 data 中的某个属性一样,没什么区别,所以当然可以啦。

但是这样是极不推荐的,Vuex 这样的库主要解决的就是混乱的数据管理问题,把应用的状态集中化。试想我们的 A 组件,B 组件,C 组件等都通过this.$store.state.name = xxx 的方式来修改数据的话,我们的代码会很难维护,因为你无法轻易的找到数据是在哪里被修改的。但是通过提交 mutation 的方式,我们不仅可以将状态数据的修改都集中到某一个 mutation 函数中,还可以借助调试工具,一步追踪数据修改的源头,快速定位。

所以,最佳实践就是我们对 state 的每一次的修改,都要通过提交 mutation 来完成。为了强制这一做法,Vuex 提供了一个严格模式的选项,我们可以在初始化的时候传入:

const store = new Vuex.Store({
  // ...
  strict: true
})

在严格模式下,无论何时发生了状态变更且不是由 mutation 函数引起的,将会抛出错误。这能保证所有的状态变更都能被调试工具跟踪到。我们来看一下它的实现原理。

# Strict 实现原理

首先,在我们的 Store 构造函数中,会解构出我们定义的 options 的属性:

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

// ...

// strict mode
this.strict = strict

可以看到,如果我们没有设置的话,默认是 false,然后保存到实例属性 strict 上。

接着在我们的 resetStoreVM 方法中,会根据这个 strict 的值来确定是否启用严格模式:

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

如果为 true 的话,那么就会执行 enableStrictMode 方法来开启严格模式,我们来看看是如何开启的:

function enableStrictMode (store) {
  store._vm.$watch(function () { return this._data.$$state }, () => {
    if (process.env.NODE_ENV !== 'production') {
      assert(store._committing, `do not mutate vuex store state outside mutation handlers.`)
    }
  }, { deep: true, sync: true })
}

这里你需要对 Vue 源码中响应式模块有些了解了,这里实际上是调用 Vue 实例上的 $watch 方法来监控 this._data.$$state,我们稍微介绍一下这个 $watch 方法的原理:

Vue.prototype.$watch = function (
  expOrFn,
  cb,
  options
) {
  var vm = this;
  if (isPlainObject(cb)) {
    return createWatcher(vm, expOrFn, cb, options)
  }
  options = options || {};
  options.user = true;
  var watcher = new Watcher(vm, expOrFn, cb, options);
  if (options.immediate) {
    try {
      cb.call(vm, watcher.value);
    } catch (error) {
      handleError(error, vm, ("callback for immediate watcher \"" + (watcher.expression) + "\""));
    }
  }
  return function unwatchFn () {
    watcher.teardown();
  }
};

它接受三个参数,第一个是要计算的表达式或者函数,对应的是一个匿名函数:

function () { return this._data.$$state }

第二个是回调函数,对应的是一个箭头函数:

() => {
  if (process.env.NODE_ENV !== 'production') {
    assert(store._committing, `do not mutate vuex store state outside mutation handlers.`)
  }
}

第三个是附加选项,就是关于这个 watcher 的一些配置信息,对应的是:{ deep: true, sync: true }

执行过程中会走到我们的 var watcher = new Watcher(vm, expOrFn, cb, options);,这里的 options 添加了一个 user: true 的属性,表明这是用户定义的 Watcher,不是 Vue 内部的。

当创建 Watcher 的时候,expOrFn 会作为 watcher 的 getter,我们就是通过这个 getter 来获取监听对象的;cb 作为回调函数,会在我们使用 getter 求值后调用;options 中的 deep 和 sync 都为 true 表明这是一个深度监听的同步 Watcher。

大体的流程是这样的,我们注册了一个 深度监听的同步 Watcher,它会监听 this._data.$$state 也就是我们 Store 中的 state ,当它发生改变的时候, watcher 的 update 方法会触发,因为他是同步的,所以会立即执行 watcher 的 run 方法,在这里再通过调用 get 方法去执行我们的 getter 也就是 function () { return this._data.$$state } 这个匿名函数。之后,也就是重要的是我们这时候的回调函数会执行:

() => {
  if (process.env.NODE_ENV !== 'production') {
    assert(store._committing, `do not mutate vuex store state outside mutation handlers.`)
  }
}

它会检查这时候的 store._committing 的值,如果是 false 的话,那么就会再开发环境下报错,提示用户不能用其他方法去修改 state 的值。这个变量我们在分析 commit 方法实现的时候提到过,commit 会调用 _withCommit 包裹一层,再来回顾一下:

_withCommit (fn) {
  const committing = this._committing
  this._committing = true
  fn()
  this._committing = committing
}

可以看到,在执行我们 mutation 之前,就将这个 _committing 设置为 true 了,所以你如果是通过提交 mutation 来修改 state 的话,肯定会走到这一步的;但是如果你是通过其他的方法,比如直接修改 state.name 的数据,那么这个值就还是 false,那么就会出发断言执行,达到警告的目的。简而言之,这个 _committing 就相当于一个标记位。是不是很巧妙呢~

当然,相信你也看出来了,这个 strict 模式的代价是比较大的,因为它创建了一个 深度&同步 的 watcher,所以当你的 state 数据量很大的时候,我们的 watcher 计算的代价也是比较高的,所以为了性能考虑,不推荐在正式环境开启这个配置,可以结合环境来动态的设置它的值:

const store = new Vuex.Store({
  // ...
  strict: process.env.NODE_ENV !== 'production'
})

# 总结

本节我们介绍了如何通过提交 mutation 的方式去修改 state 的属性,也分析了提交 mutation 这一过程的实现。我们在实际的开发中要遵守这样的约定。最后还分析了 strict 的作用,但是要注意的是,“严格模式会深度监测状态树来检测不合规的状态变更——请确保在发布环境下关闭严格模式,以避免性能损失”。下一节我们将介绍 Action 的使用。

上次更新: 6/3/2020, 2:33:30 PM