Vue2源码学习笔记之选项合并的策略

上一章的代码规范化了各个选项的值,主要是为了接下来更好的进行合并选项。 下面开始就是真正进行选项合并的代码。 具体要怎么合并呢?不同的字段的合并策略是一样的吗?

目录
  1. mergeField
  2. strats
    1. defaultStrat
    2. data选项的合并策略
    3. mergeHook生命周期函数的合并策略
    4. mergeAssets资源选项的合并策略
    5. watch选项的合并策略
    6. props、methods、inject、computed选项的合并策略
    7. provide选项的合并策略
    8. extends和mixins的合并策略
  3. 合并策略总结
  4. optionMergeStrategies自定义合并策略
    1. $options合并后选项的使用
  5. 参考资料

上一章的代码规范化了各个选项的值,主要是为了接下来更好的进行合并选项。

下面开始就是真正进行选项合并的代码。

具体要怎么合并呢?不同的字段的合并策略是一样的吗?

mergeField#

继续看最后剩下的代码,这里开始进行真正的选项合并。

const options = {};
let key;
 
// ! 遍历默认配置
for (key in parent) {
  mergeField(key);
}
 
// ! 遍历用户配置
for (key in child) {
  if (!hasOwn(parent, key)) {
    mergeField(key);
  }
}
 
// ! 根据 key 值的不同,使用不同的合并策略
function mergeField(key) {
  const strat = strats[key] || defaultStrat;
  options[key] = strat(parent[key], child[key], vm, key);
}
return options;

首先声明一个变量options为空对象,最后再返回它,这说明选项合并后的值是对象类型。然后遍历 Vue 的默认选项parent,调用mergeField函数并传入key值。再遍历用户选项child,还是使用mergeField函数并传入key值,但是和上面不同的是要求key必须是parent中没有的字段。

为什么要这样设计呢?看mergeField函数就知道了。

在使用策略函数合并时都会把值传入,所以不需要重复调用。

mergeField函数是合并选项字段的方法,首先根据key的值选择它的专用的合并策略,如果没有专用的合并策略就使用默认的合并策略。

这里的strats是一个合并策略函数组成的对象,对象里面的key对应选项字段中的key,而它的value对应的是字段的合并策略函数。再调用策略函数的时候,需要把parent字段的值、child选项字段的值以及实例对象vmkey作为参数一起传入。然后把调用的结果(即合并后的选项)赋值给一开始声明的options对象中。options对象中的key也对应选项字段的key,它的值就是合并选项后的值。

这就是合并选项的逻辑,比较简单,就是根据选项中不同的字段选择不同的合并策略。不过难点并不在这里,而是在合并选项的策略函数上。

下面来看下各个字段的合并策略函数。

strats#

defaultStrat#

先看下默认的合并策略defaultStrat函数的代码。在mergeField函数中,我们知道如果strats中没有找到key的合并策略,就会使用默认的合并策略函数。

代码在core/util/options.js文件中。

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

它的逻辑比较简单,如果没有用户选项childVal,就使用默认选项parentVal,否则使用用户选项childVal

使用默认合并策略的字段有elpropsData这些字段。

if (process.env.NODE_ENV !== "production") {
  strats.el = strats.propsData = function (parent, child, vm, key) {
    if (!vm) {
      warn(
        `option "${key}" can only be used during instance ` +
          "creation with the `new` keyword."
      );
    }
    return defaultStrat(parent, child);
  };
}

示例代码

// 默认选项
el: '#app',
propsData: {
  name: 'Hale'
}
 
// 用户选项
el: '#root'
 
// 合并后选项
el: '#root',
propsData: {
  name: 'Hale'
}

data选项的合并策略#

接下来看下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);
};

函数逻辑分为两种情况,有实例对象vm和没有vm

当我们使用Vue.extend方法生成子类时,这个子类是一个构造函数,当它的options作为用户选项childVal时,它是没有vm的。

不过不管有没有实例对象,它都是使用mergeDataOrFn函数来进行选项合并的。

另外,子类组件的data选项如果不是函数类型,在非生产环境时会报错,然后直接返回parentVal选项。

mergeDataOrFn

下面看下mergeDataOrFn函数的代码,这里的逻辑同样分为两种情况,分别为有实例对象vm和没有vm

先看下没有实例对象vm的代码,即合并子类的选项。

export function mergeDataOrFn(
  parentVal: any,
  childVal: any,
  vm?: Component
): ?Function {
  if (!vm) {
    if (!childVal) {
      return parentVal;
    }
    if (!parentVal) {
      return childVal;
    }
    return function mergedDataFn() {
      return mergeData(
        typeof childVal === "function" ? childVal.call(this, this) : childVal,
        typeof parentVal === "function" ? parentVal.call(this, this) : parentVal
      );
    };
  } else {
    // ...
  }
}

首先是两个if语句,先看第一个。如果没有子选项childVal,就返回父选项parentVal。比如我们在扩展时没有设置data选项的值。像下面这种情况,就会采用这种逻辑。

const Stor = Vue.extend({});

可能有人会问既然子选项childVal中没有设置data选项,父选项parentVal通常也不会有data的值,都没有data这个字段,也就不存在去调用data的策略函数了。

为什么还要返回parentValparentVal可能存在吗?

答案是肯定的,如下示例代码中,在使用二次extend时,Child子类的parentVal是存在的,就是在创建Parent子类时传入的data的值。但是在创建Child子类时没有传入data的值,所以ChildchildVal是没有的。那么对于Child的子类的创建的函数,存在parentVal而不存在childVal

const Parent = Vue.extend({
  data() {
    return { test1: 1 };
  },
});
const Child = Parent.extend({});

接下来再看第二个if语句。如果没有parentVal,就返回childVal。其实这种情况在实际开发中应该是出现最多的。所以parentValchildVal必定有其中一个,否则就不会执行data选项合并策略了。

接下来查看parentValchildVal都存在的情况的逻辑代码。

return function mergedDataFn() {
  return mergeData(
    typeof childVal === "function" ? childVal.call(this, this) : childVal,
    typeof parentVal === "function" ? parentVal.call(this, this) : parentVal
  );
};

这里返回一个函数mergedDataFn,这个函数调用了mergeData来处理data选项的合并,它需要传入childValparentVal这两个参数。注意参数传入的顺序,先子后父。另外传入的两个参数只能是对象类型。

在 Vue 实例组件中data的值都是函数类型的,它是一个返回普通对象的函数。这时需要调用这个函数,输出对象类型的值。在使用call调用的时候传入了两个this。第一个this是指向data函数的作用域。而第二个thisdata函数的参数,指向生成的实例对象。

接下来看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的情况,也就是说上面代码是实例对象初始化时合并选项的代码。

这样也返回一个叫mergedInstanceDataFn函数。函数中需要先获取一个childValparentVal生成的对象类型的值。如果选项是函数,需要调用这个函数获取对象类型的值,在调用时传入的参数是两个vm,即实例对象。第一个vm表示this的指向是实例对象,第二个vmdata函数的参数。

在组件中,data是一个函数,它其实有很多种写法。

data () {
  return {
    testData: this.propsData
  }
}
 
// 另外的写法
data(vm) {
  return {
    testData: vm.propsData
  }
}
 
// 还可以使用解构
data({ propsData }) {
  return {
    testData: propsData
  }
}

继续看下面的代码。

if (instanceData) {
  return mergeData(instanceData, defaultData);
} else {
  return defaultData;
}

判端childVal输出的值是否存在,如果存在,就调用mergeData函数把它和parentVal输出的值进行合并,不存在就直接返回parentVal输出的值。

下面看下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;
}

mergeData函数接收两个参数,tofrom,从参数名中可以看出是把第二个参数合并到第一个参数中。

然后再看函数的返回值为to,即返第一个参数合并的值,所以为什么我们在上面调用它的时候传入参数的顺序是先子后父了,即先传入childVal再传入parentVal因为是以用户选项childVal为主

看代码第一句if (!from) return to,如果没有第二个参数from,直接返回第一个参数to

然后获取第二个参数fromkeys,判断是否支持 ES6 的Promise的语法,如果支持则使用Reflect.ownKeys(from),不支持就使用对象的方法Object.keys(from)

它们两个有什么区别吗?使用Object.keys只会返回可枚举的key值,而Reflect.ownKeys会返回所有的key值。

然后去遍历keys,如果key'__ob__时,这是侦听对象(后面讲响应式系统时再说明),跳过这次循环。

在看下to选项是否有key这个值,如果没有的话,使用set方法把它加入到to中。这里的set和全局 APIVue.set是同一个函数,可以把data选项的属性变成响应式。

另外,还需要判断,在相同key时,tofrom的值是否相同,如果不相同且它们都是普通对象时,递归调用mergeData函数进行深度合并。

小结mergeData合并策略是以to选项为主。对于from选项,只有to选项中不存在的key值,才会被添加到to选项中。如果存在相同的key时,当它的值是原型类型时,选取to的值,即to的值覆盖掉from的值。当它的值是对象类型时,使用递归进行深度合并。

另外还有一点非常重要,那就是合并后的data选项还是一个函数

示例代码

// from
data() {
  return {
    age: 20,
    name: 'Hale',
    friends: {
      Beijing: 'xiaoming',
      Shenzhen: 'xiaohong'
    }
  }
}
 
// to
data() {
  return {
    age: 18,
    friends: {
      Beijing: 'Jack',
      Shanghai: 'Amy'
    }
  }
}
 
// after merge
data() {
  return {
    age: 18,
    name: 'Hale',
    friends: {
      Beijing: 'Jack',
      Shanghai: 'Amy',
      Shenzhen: 'xiaohong'
    }
  }
}

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;
}

先看函数的参数,parentVal是一个由函数组成的数组,而childVal可能是函数或者是由函数组成的数组,它们都是可选的。

再看代码,没有使用if语句,而是使用了三组三元操作符。

首先判断是否有childVal,没有的话就返回parentVal

childVal的话,再判断是否有parentVal,如果有parentVal,就把childVal合并到parentVal中组成新的数组。如果没有parentVal,判断childVal的类型是否是数组,如果是数组,就直接返回它本身。如果不是数组,就把它包装成数组并返回。总之合并后的res一定是个数组。

最后输出结果是还需要调用dedupeHooks函数去除重复的钩子。函数遍历里数组里面的函数,如果多个函数来自同一个引用地址,则会把其它相同引用的函数排除掉。

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;
}

从上面的合并策略中可以得知:在组件实例中,创建的钩子可以是函数,也可以是多个函数组成的数组。

小结mergeHook合并策略是把同类的所有的钩子都合并放到一个数组中。

但是官方文档中并没有说明这种情况,但是经过实践,是可以这么做的,如下面的代码,输出按照顺序指向函数的打印结构。

export default {
  created: [
    function fn1() {
      console.log("created hook fn1");
    },
    function fn2() {
      console.log("created hook fn2");
    },
    function fn3() {
      console.log("created hook fn3");
    },
  ],
};

继续看下面代码,使用生命周期函数合并策略的字段那肯定就是生命周期钩子,比如beforeCreatemounted等。

LIFECYCLE_HOOKS.forEach((hook) => {
  strats[hook] = mergeHook;
});
 
export const LIFECYCLE_HOOKS = [
  "beforeCreate",
  "created",
  "beforeMount",
  "mounted",
  "beforeUpdate",
  "updated",
  "beforeDestroy",
  "destroyed",
  "activated",
  "deactivated",
  "errorCaptured",
  "serverPrefetch",
];

示例代码

// parentVal
created: [function f1() {}, function f2() {}],
 
// childVal
created: function f3() {
  console.log('f3')
}
 
// after merge
created: [
  function f1() {},
  function f2() {},
  function f3() {
  	console.log('f3')
	}
]

mergeAssets资源选项的合并策略#

在 Vue 的实例选项中,componentsdirectivesfilters被认为是资源。

为什么呢?因为这些选项都是可以作为第三方库来提供的。

资源选项的合并策略的函数是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;
  }
}

如果有parentVal则以它作为原型对象创建对象res,没有的话以null为原型对象创建对象res

然后判断是否有childVal,如果有的话使用extend把它扩展到res中。如果没有的话,直接返回res

另外,有childVal且在非生产环境时,会判断childVal的对象中的值是不是一个普通对象,如果不是会报错。

小结mergeAssets合并策略先创建以parentVal为原型创建对象res,然后再使用extend函数去扩展childVal,这样如果存在相同的key时,childVal选项就不会覆盖掉parentVal选项的值。因为parentVal的是值保存在res的隐式原型对象__proto__字段中。

extend函数代码如下:

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

使用资源选项的合并策略的字段当然就是我们上面提到的资源选项componentsdirectivesfilters

ASSET_TYPES.forEach(function (type) {
  strats[type + "s"] = mergeAssets;
});
 
export const ASSET_TYPES = ["component", "directive", "filter"];

示例代码

// parentVal
components: {
  CompA,
  CompB
}
 
// childVal
components: {
  CompC
}
 
// after merge
components: {
  CompC,
  __proto__: {
  	CompA,
 		CompB,
  }
}

watch选项的合并策略#

下面查看watch选项的合并策略的代码,代码比较长,一段一段分析

首先看最前面的两行代码。

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;
  // ...
};

首先判断,这两个选项是否全等于nativeWatch,它是个什么呢?看下面的代码。

// Firefox has a "watch" function on Object.prototype...
export const nativeWatch = {}.watch;

通过注释和查阅资料得知,Firefox 浏览器会给每个对象的原型中添加watch属性,这样就和 Vue 里面的watch属性名称产生冲突。所以需要判断一下,当 Vue 的watch属性和 FireFox 浏览器定义的watch的值相同时,会把watch设置为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;

如果不存在childVal,则返回以parentVal或者null为原型创建的对象。

在非生产环境中,还会判断下childVal的值是否是一个普通对象,如果不是会报错。

如果不存在parentVal,则直接返回childVal

如果childValparentVal都存在,继续看下面的代码。

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;

先什么一个变量ret存储函数的返回值。然后把parentVal扩展到ret对象中,这样ret就拥有parentVal的全部属性。

然后遍历childVal对象,声明一个变量parent存储ret[key]的值,注意这个值可能不存在。声明变量child存储childVal[key]的值,这个值是一定存在的。

判断如果parent存在且不是数组时,把parent包装成一个数组。最后开始给ret赋值,它的key就是每次遍历childValkey。在每次遍历的过程中,如果存在parent就把child合并到parent中组成新的数组。

如果不存在parent,判断child是不是一个数组,如果是的话,就直接返回它本身。如果不是的话,把它包装成数组并返回。最后返回ret这个对象。

总结watch的合并策略和其他的策略不同,它不会去除相同key的值,而是把它们的值组合在一起。也就是说如果有多个监听同一个值的函数,那么当这个值变化时,所有的监听函数都会生效。

示例代码

// parentVal
watch: {
  test: function() {
    console.log('test parentVal')
  }
}
 
// childVal
watch: {
  test: function() {
    console.log('test childVal')
  }
}
 
// after merge
watch: {
  test: [
    function() {
    	console.log('test parentVal')
  	},
    function() {
    	console.log('test childVal')
  	}
  ]
}
 
// test change
vm.test = 2
// test parentVal
// test childVal

propsmethodsinjectcomputed选项的合并策略#

看下面一段代码。

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;
    };

这是对propsmethodsinjectcomputed字段的合并选项的策略。

代码逻辑比较简单。首先当childVal存在且在非生产环境中时,会判断childVal的值是否为普通对象,如果不是会报错。然后判断parentVal是否存在,如果不存在,则返回childVal

再声明一个没有原型的对象ret,使用parentVal扩展它。

最后判断如果有childVal,也把childVal扩展到ret中。如果没有的话,直接返回ret

小结,这个合并策略就是很简单的扩展,先创建一个没有原型的对象ret,先使用parentVal扩展给它,然后再使用childVal扩展它。如果选项中存在相同的key,后面的值会覆盖掉前面的值。

示例代码

// parentVal
props: {
  name: {
    type: String,
    default: 'Hale'
  }
},
methods: {
  fn1() {
    console.log('parentVal fn1')
  }
}
 
// childVal
props: {
  name: {
    type: String,
    default: 'Amy'
  }
},
methods: {
  fn2() {
    console.log('childVal fn2')
  }
}
 
// after merge
props: {
  name: {
    type: String,
    default: 'Amy'
  }
},
methods: {
  fn1() {
    console.log('parentVal fn1')
  },
  fn2() {
    console.log('childVal fn2')
  }
}

provide选项的合并策略#

看下provide选项的合并策略的代码。

strats.provide = mergeDataOrFn;

provide选项沿用了data选项的一样的合并策略,这里就不多赘述了。

extendsmixins的合并策略#

看下extendsmixins的合并策略。

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

学习完所有的合并策略后,再看extendsmixins的合并策略,这里在前面规范化的时候已经讲解过了。

我们都知道extendsmixins选项的作用是复用组件代码,那么选项里面的值就可能是上面出现的所有选项字段,所以需要使用递归进行深度合并,然后再次沿用上面的合并策略。

合并策略总结#

所有的合并策略已经已经讲解完毕,下面进行总结。

  • 对于elpropsData选项使用默认的合并策略defaultStrat
  • 对于data选项,使用mergeDataOrFn函数进行处理,最终结果是data选项将变成一个新的函数,这个函数返回mergeData函数处理的数据,mergeData是真正合并选项的函数。
  • 对于生命周期钩子选项,将合并成数组,使得父子选项中的钩子函数都能够被执行。
  • 对于directivesfilterscomponents资源选项,父子选项将以原型链的形式被处理,正是因为这样我们才能够在任何地方都使用内置组件、指令等。
  • 对于watch选项的合并处理,类似于生命周期钩子,如果父子选项都有相同的字段,把它们合并为数组,这样所有的观察者都将被执行。
  • 对于propsmethodsinjectcomputed选项,父选项始终可用,但是子选项会覆盖同名的父选项字段。
  • 对于provide选项,其合并策略使用与data选项相同的mergeDataOrFn函数。
  • 最后,以上没有提及到的选项都将使默认选项defaultStrat,只要子选项不是undefined就使用子选项,否则使用父选项。

optionMergeStrategies自定义合并策略#

Vue 在全局配置中还提供了一个可以自定义合并策略的选项optionMergeStrategies,让用户可以为一些非系统选项字段提供自定义的合并策略,而不是使用默认的合并策略。

比如为自定义字段customOption的提供自定义的合并策略``optionMergeStrategies`。

示例代码如下,现在在入口文件main.js中配置合并策略。

Vue.config.optionMergeStrategies.customOption = function (parent, child, vm) {
  return parent ? parent + child : child;
};

然后在实例组件中使用这个自定义的字段customOption

export default {
  customOption: 2,
  extends: {
    customOption: 1,
  },
  data() {
    return {
      a: 0,
    };
  },
  created() {
    console.log(this.$options.customOption); // 3
  },
};

$options合并后选项的使用#

使用mergeOptions后会返回一个对象,这个对象就是合并后的选项,在实例化中会把这个对象赋值给实例的$options属性。

vm.$options = mergeOptions(
  resolveConstructorOptions(vm.constructor), // ! Vue 初始化时的默认选项,比如默认的指令和组件等等
  options || {}, // ! 用户传入的配置
  vm // !  Vue 实例对象本身
);

vm.$options这个属性非常重要,在接下来设置其他的实例属性时至关重要,因为都会用到它。

在实例组件中也可以调用这个属性去拿到合并后的选项,包括我们自己输入的选项,这样我们可以更好的编写组件。

export default {
  name: 'Home',
  data() {
    return {
      a: 0
    }
  },
  methods: {
    test() {
      console.log('test method')
    }
  },
  created() {
    console.log(this.$options.name) // 'Home'
    console.log(this.$options.methods.test) // function test() { console.log('test method') 	}
}

参考资料#