Vue源码分析之参数合并策略

1,218 阅读6分钟

这是我参与更文挑战的第18天,活动详情查看: 更文挑战

前言

在前边学习创建Vue实例的时候,初始化vm对象的时候会合并参数,合并参数有很多合并策略,今天就来看下Vue具体是怎么进行参数合并的。

回顾下mergeOptions中最后是如何处理options的,定义在src/core/util/options中:

export function mergeOptions (
  parent: Object,
  child: Object,
  vm?: Component
): Object {
  ...

  const options = {}
  let key
  for (key in parent) {
    mergeField(key)
  }
  for (key in child) {
    if (!hasOwn(parent, key)) {
      mergeField(key)
    }
  }
  function mergeField (key) {
    const strat = strats[key] || defaultStrat
    options[key] = strat(parent[key], child[key], vm, key)
  }
  return options
}

可以看到mergeField函数针对不同的key返回了不同的策略,它里边有两个变量:strats与defalutStrat,它们保存了具体的合并策略。

defaultStrat

默认的合并策略是先查属性在child上是否存在,不存在则使用parent上的值。

const defaultStrat = function (parentVal: any, childVal: any): any {
  return childVal === undefined
    ? parentVal
    : childVal
}

strats

先整体来看下strats都有什么:

image.png

看的出来,主要是生命周期钩子函数,我们Vue组件中定义的data,computed,filters,methods,props,watch等

看一下strats的初始定义,就是一个空的Object

const strats = config.optionMergeStrategies

optionMergeStrategies: Object.create(null)

生命周期函数合并

先来看下生命周期函数怎么合并的:

LIFECYCLE_HOOKS.forEach(hook => {
  strats[hook] = mergeHook
})

function mergeHook (
  parentVal: ?Array<Function>,
  childVal: ?Function | ?Array<Function>
): ?Array<Function> {
  const res = childVal
    ? parentVal
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
        ? childVal
        : [childVal]
    : parentVal
  return res
    ? dedupeHooks(res)
    : res
}

function dedupeHooks (hooks) {
  const res = []
  for (let i = 0; i < hooks.length; i++) {
    if (res.indexOf(hooks[i]) === -1) {
      res.push(hooks[i])
    }
  }
  return res
}

这里有个多层次嵌套三元运算符,首先判断childVal是否存在,如果不存在,则返回parentVal;

如果childVal存在,则判断parentVal是否存在,如果存在,则将childVal合并到parentVal返回

parentVal不存在,则先判断childVal是不是数组,如果是直接返回childVal数组,否则将childVal包装成数组返回。

最后通过dedupeHooks函数将合并后的钩子整合成一个数组返回。

data合并

strats.data = function (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    if (childVal && typeof childVal !== 'function') {
      process.env.NODE_ENV !== 'production' && warn(
        'The "data" option should be a function ' +
        'that returns a per-instance value in component ' +
        'definitions.',
        vm
      )

      return parentVal
    }
    return mergeDataOrFn(parentVal, childVal)
  }

  return mergeDataOrFn(parentVal, childVal, vm)
}

data合并主要调用了mergeDataOrFn,接下来看下这个函数:

export function mergeDataOrFn (
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    // in a Vue.extend merge, both should be functions
    if (!childVal) {
      return parentVal
    }
    if (!parentVal) {
      return childVal
    }
    // when parentVal & childVal are both present,
    // we need to return a function that returns the
    // merged result of both functions... no need to
    // check if parentVal is a function here because
    // it has to be a function to pass previous merges.
    return function mergedDataFn () {
      return mergeData(
        typeof childVal === 'function' ? childVal.call(this, this) : childVal,
        typeof parentVal === 'function' ? parentVal.call(this, this) : parentVal
      )
    }
  } else {
    return function mergedInstanceDataFn () {
      // instance merge
      const instanceData = typeof childVal === 'function'
        ? childVal.call(vm, vm)
        : childVal
      const defaultData = typeof parentVal === 'function'
        ? parentVal.call(vm, vm)
        : parentVal
      if (instanceData) {
        return mergeData(instanceData, defaultData)
      } else {
        return defaultData
      }
    }
  }
}

如果不存在vm实例(通过Vue.extend创建),则先判断是否存在childVal,如果不存在返回parentVal;接下来判断是否存在parentVal,如果不存在则直接返回childVal。如果两者都存在,则调用mergeData

如果存在vm(通过new Vue创建),在执行childVal函数和parentVal函数,this指向vm;最后直接调用mergeata合并

看下mergeData函数:

function mergeData (to: Object, from: ?Object): Object {
  if (!from) return to
  let key, toVal, fromVal

  const keys = hasSymbol
    ? Reflect.ownKeys(from)
    : Object.keys(from)

  for (let i = 0; i < keys.length; i++) {
    key = keys[i]
    // in case the object is already observed...
    if (key === '__ob__') continue
    toVal = to[key]
    fromVal = from[key]
    if (!hasOwn(to, key)) {
      set(to, key, fromVal)
    } else if (
      toVal !== fromVal &&
      isPlainObject(toVal) &&
      isPlainObject(fromVal)
    ) {
      mergeData(toVal, fromVal)
    }
  }
  return to
}

该函数接受两个参数to(childVal)和from(parentVal)

首先通过Reflect.ownKeys和Object.keys来判断是否有symbol属性;但是有一点要注意,Reflect.ownKeys返回对象上所有的自身属性,包括不可枚举的和Symbol,而Object.keys只枚举对象自身的可枚举属性,不包含Symbol。所以如果对象种有不可枚举属性,建议不要用这种判断方式。关于对象属性可以点这里

遍历from对象中的所有属性,如果当前遍历的key已经是一个监听属性,则直接遍历下一个;否则从要合并的两个对象中拿出key对应的值,通过hasOwn函数(内部封装了hasOwnProperty)判断to对象自身是否存在同样的key,如果不存在,调用set函数绑定到to对象;

否则说明to对象本身就具有同样的key,则判断两个对象对应的值是否一致;如果不一致且两个对象都是Object,则递归执行mergeData。

export function isPlainObject (obj: any): boolean {
  return _toString.call(obj) === '[object Object]'
}

watch合并

watch代码相对data来说简单很多。

strats.watch = function (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  // work around Firefox's Object.prototype.watch...
  if (parentVal === nativeWatch) parentVal = undefined
  if (childVal === nativeWatch) childVal = undefined
  /* istanbul ignore if */
  if (!childVal) return Object.create(parentVal || null)
  if (process.env.NODE_ENV !== 'production') {
    assertObjectType(key, childVal, vm)
  }
  if (!parentVal) return childVal
  const ret = {}
  extend(ret, parentVal)
  for (const key in childVal) {
    let parent = ret[key]
    const child = childVal[key]
    if (parent && !Array.isArray(parent)) {
      parent = [parent]
    }
    ret[key] = parent
      ? parent.concat(child)
      : Array.isArray(child) ? child : [child]
  }
  return ret
}

首先是兼容性判断,火狐浏览器中,Object.prototype有个watch属性,所以要先排查watch对象不是浏览器自身的。(const nativeWatch = ({}).watch)

如果childVal为空,则调用Object.create创建以parentVal为原型对象的新对象,并返回该新对象

如果parentVal为空,则直接返回childVal

parentVal和childVal都不为空时,先调用extend(ret, parentVal)将parentVal浅复制给ret

接下来遍历childVal,如果当前项key在parent对象中也存在,将parent转化成数组,然后调用concat将child也一起拼接;否则返回数组格式的child给ret的key值。

看一下extend的实现:

export function extend (to: Object, _from: ?Object): Object {
  for (const key in _from) {
    to[key] = _from[key]
  }
  return to
}

props,methods,inject,computed合并

props,methods,inject,computed合并方式是一样的。内部主要是调用了extend方法。

strats.props =
strats.methods =
strats.inject =
strats.computed = function (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): ?Object {
  if (childVal && process.env.NODE_ENV !== 'production') {
    assertObjectType(key, childVal, vm)
  }
  if (!parentVal) return childVal
  const ret = Object.create(null)
  extend(ret, parentVal)
  if (childVal) extend(ret, childVal)
  return ret
}
  • 首先创建一个空对象ret,将parentVal复制过去
  • 接下来再将childVal复制到ret上,如果出现相同key值,直接覆盖。

provide合并

provide合并方式与data一致,都是调用mergeDataOrFn

strats.provide = mergeDataOrFn

components,directives,filters合并

components,directives,filters合并都是调用mergeAssets方法。

export const ASSET_TYPES = [
  'component',
  'directive',
  'filter'
]

ASSET_TYPES.forEach(function (type) {
  strats[type + 's'] = mergeAssets
})

看一下mergeAssets方法:

function mergeAssets (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): Object {
  const res = Object.create(parentVal || null)
  if (childVal) {
    process.env.NODE_ENV !== 'production' && assertObjectType(key, childVal, vm)
    return extend(res, childVal)
  } else {
    return res
  }
}

mergeAssets实现也不复杂,先让res继承parentVal,然后判断childVal是否有值,如果有调用extend合并,否则直接返回。

Vue.mixin

Vue官方为我们提供了mixin的方式来分发组件中的复用功能,混入对象可以包含Vue组件的任意选项。

var mixin = {
  ...
}

new Vue({
  mixins: [mixin],
  ...
})

文档也说明针对不同的选项,它具有不同的合并策略。

当组件与混入对象含有同名选项时,数据对象会递归合并,发生冲突时以组件数据优先。

同名钩子函数会合并为一个数组,混入对象的钩子函数在组件自身钩子函数之前。

methods,componets,directives合并为同一个对象,两个对象键名冲突时,取组件对象的键值对。

哈哈哈哈,看出来了把,这个完全符和我们上边的合并策略。重温一下这块的实现:

 if (!child._base) {
    if (child.extends) {
      parent = mergeOptions(parent, child.extends, vm)
    }
    if (child.mixins) {
      for (let i = 0, l = child.mixins.length; i < l; i++) {
        parent = mergeOptions(parent, child.mixins[i], vm)
      }
    }
  }

我们也可以直接调用Vue.mixin来实现一个全局混入,看一下官网的例子:

Vue.mixin({
  created: function () {
    var myOption = this.$options.myOption
    if (myOption) {
      console.log(myOption)
    }
  }
})

new Vue({
  myOption: 'hello!'
})

来看一下Vue.mixin的内部实现:

Vue.mixin = function (mixin: Object) {
    this.options = mergeOptions(this.options, mixin)
    return this
}

Vue.mixin就是直接调用mergeOption,该mixin对象是我们源码中的childVal参数,是最高优先级的,所以使用全局钩子mixin一定要小心。

Vue3改动

前边我们知道data的合并是会递归去合并对象的,是深层次的合并。但是Vue3对这块做了处理,mixin和extend在进行data合并的时候,都变成了浅层次的合并,不会去递归查找Object。

官方给的例子:

const Mixin = {
  data() {
    return {
      user: {
        name: 'Jack',
        id: 1
      }
    }
  }
}
const CompA = {
  mixins: [Mixin],
  data() {
    return {
      user: {
        id: 2
      }
    }
  }
}

Vue2.x中$data结果:

{
  user: {
    id: 2,
    name: 'Jack'
  }
}

Vue3中$data结果:

{
  user: {
    id: 2
  }
}