Vue.js源码学习 —— 选项的合并策略

如果您是刚开始准备阅读Vue.js的源码,建议先看一下本系列的Vue.js源码学习 —— 起步,相信会对您后面的阅读有很大帮助。

这篇是接着上一篇Vue.js源码学习 —— 合并选项前的预处理,也建议您先看一下,然后再来看这篇会轻松很多。

这篇文章要看的代码主要位于文件core/util/options.js中。

我没有在文章中大量的贴源码,一定要将源码clone到本地哦。

预备知识

Vue.options

上一篇Vue.js源码学习 —— 合并选项前的预处理中有说过Vue.options的内容,这里再复习一下,为了便于在举例子时说明结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
components: {
KeepAlive,
Transition,
TransitionGroup
},
directives: {
model,
show
},
filters: {},
_base: ƒ Vue(options)
}

extend函数

需要了解工具函数extend的实现,因为在合并的过程中会经常使用到。extend函数位于文件shared/util.js中,代码如下:

1
2
3
4
5
6
7
8
9
/**
* Mix properties into target object.
*/
export function extend (to: Object, _from: ?Object): Object {
for (const key in _from) {
to[key] = _from[key]
}
return to
}

extend函数接收两个对象tofrom,并将from中的键值对混入到to中,最后返回to。这背后的几点含义:

  • tofrom中有相同key时,from中的值会覆盖to中的值。
  • to中不在from中的key仍然保留。
  • from中不在to中的键值对,会被添加到to中。

所以下面的合并选项中,如果用到了extend函数的,就是采用了覆盖的策略。

各个选项的合并策略

从文件的最上面开始看吧。

自定义合并选项的策略

1
2
3
4
5
6
/**
* Option overwriting strategies are functions that handle
* how to merge a parent option value and a child option
* value into the final value.
*/
const strats = config.optionMergeStrategies

config.optionMergeStrategies是提供给开发者使用的,我们可以自定义父选项和子选项要怎样合并,通常用于自定义的选项的合并。可以在官方文档#自定义选项合并策略一节了解具体如何使用。

el和propsData选项的合并策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* Options with restrictions
*/
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)
}
}

elpropsData两个选项只能用于Vue的实例对象中,也就是说不能直接在Vue或其子类的构造器选项中设置elpropsData,开发环境下会给出警告信息。

propsData 用于在创建实例时传递 props。主要作用是方便测试。

data选项的合并策略

data选项此时并没有真正执行合并,只是返回了一个函数,真正地合并过程在该函数中。当合并发生在创建子类构造器时,会返回mergedDataFn函数,当合并发生在创建实例对象时,会返回mergedInstanceDataFn函数。

这相当于是延迟了合并data选项。因为data中的计算有可能依赖了props选项中的内容,所以需要在初始化props之后,才能去真正地执行data的合并。

先从下面这段代码开始看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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没有值时,也就是没有传vm时,合并发生在创建子类构造器时。我们知道创建子类构造器使用Vue.extend,看一下它的实现,发现确实是没有传的:

1
2
3
4
Sub.options = mergeOptions(
Super.options,
extendOptions
)

而在Vue.prototype._init中始终都会传vm

当合并发生在创建子类构造器时,那data选项的类型必须是一个函数,如果不是,开发环境下会给出警告信息。

为什么此时data选项的类型必须是一个函数呢?

通过前面的分析,我们知道构造器的选项会与其所有实例对象的选项合并,就会导致data在所有的实例对象中共享。如果data是一个普通对象,就会导致一个使用了该组件的实例对象改变了data中的值时,其他使用该组件的实例对象获取到的data也会受影响。data是函数时,所有实例对象拥有的是函数返回值的副本,所以不会有影响。

再接着看mergeDataOrFn函数,在vm没有值,也就是合并发生在创建子类构造器时,是直接将childValparentVal返回的,并没有用函数来包装,所以开发者此时一定要传函数。

还是通过例子一步一步来看,先举个合并发生在创建子类构造器时的例子:

1
2
3
4
5
6
// data option in constructors
const Parent = Vue.extend({
data: function() {
return { foo: 'foo' }
}
})

此时parentVue构造器的options,因为Vue.options中没有data选项,所以parentValundefinedchildVal是我们给Parent传的data,因此执行了mergeDataOrFn函数中的下面这段:

1
2
3
if (!parentVal) {
return childVal
}

接着上面,再加上一个继承自Parent的子类:

1
2
const Child = Parent.extend({
})

经过上一步的合并后,此时Parent.options.data有值了,也就是parentVal有值了。但是因为我们没有在Child中传data选项,所以childValundefined,此时执行了mergeDataOrFn函数中的下面这段:

1
2
3
if (!childVal) {
return parentVal
}

现在给上面的Child传个data选项:

1
2
3
4
5
const Child = Parent.extend({
data: function() {
return { bar: 'bar' }
}
})

就会执行到下面的语句:

1
2
3
4
5
6
7
8
9
10
11
// 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
)
}

此时data返回了mergedDataFn函数。该函数中又调用了mergeData函数,传给它的参数,如果data是函数,就是data执行后的返回结果。如果data是对象,就是该对象。总之传给mergeData函数的是两个对象了。

再看当合并发生在创建实例对象时,直接举个parentValchildVal都有值的例子:

1
2
3
4
5
6
7
8
9
10
11
12
// data option in instance object
const VueChild = Vue.extend({
data: function(context) {
return { foo: 'foo'}
}
})

const vm = new VueChild({
data: function(context) {
return { bar: 'bar' }
}
})

此时data返回的是mergedInstanceDataFn函数。该函数也是调用了mergeData函数,给它的传参处理逻辑与构造器是很像,区别在于如果data是函数,在mergedDataFn函数中调用data传的是this,而mergedInstanceDataFn函数中调用data传的是vm。所以data选项在是函数时,还可以接收一个参数。

再看mergeData函数内部的实现:

1
2
3
4
5
function mergeData (to: Object, from: ?Object): Object {
if (!from) return to
// ...
return to
}

整个函数做的事情就是将from对象中的属性合并到to对象中,最后将to返回。先获取from对象中的所有keys,然后分了几种情况处理:

  1. 如果key正在被观察,不做处理。

  2. 如果key不在to对象中,将调用set函数将该属性设置到to对象中。这个set函数位于observer/index.js中,因为里面牵扯到响应系统的东西,所以暂时先不细看。

  3. 如果keyto对象中:

    • 如果在from中和to中的属性值都是对象,并且没有指向一个相同的对象,就递归调用mergeData函数进一步合并。
    • 其他情况时(属性值是原始值或数组等)都保留了to对象中的。

举个例子,可以放到浏览器中调试看看执行的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// mergeData test
let obj = {
firstName: 'Ida',
lastName: 'Zhang'
}
const VueChild = Vue.extend({
data: function(context) {
return {
foo: 'foo',
obj: obj,
list: [1, 3, 5]
}
}
})

const vm = new VueChild({
data: function(context) {
return {
foo: 'bar',
obj: obj,
list2: [2, 4, 6]
}
}
})

合并后vm.$options.data如下:

1
2
3
4
5
6
7
8
9
10
11
{
data: {
foo: 'bar',
obj: {
firstName: 'Ida',
lastName: 'Zhang'
},
list: [1, 3, 5],
list2: [2, 4, 6]
}
}

实例生命周期钩子选项的合并策略

从下面这段代码开始看:

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

LIFECYCLE_HOOKSshared/constants.js文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const LIFECYCLE_HOOKS = [
'beforeCreate',
'created',
'beforeMount',
'mounted',
'beforeUpdate',
'updated',
'beforeDestroy',
'destroyed',
'activated',
'deactivated',
'errorCaptured',
'serverPrefetch'
]

这里列出了所有的实例对象生命周期的钩子函数,这表示所有的钩子函数用的都是一样的合并策略。在构造器中有这些钩子函数时,而开发者又传入了同样的钩子函数,会将两个一样的钩子函数合并成一个数组,重新作为选项的值。即使构造器中没有相同的钩子函数,也会将传入的钩子函数放入数组中,重新作为选项的值。

实现实例对象生命周期钩子选项的合并策略的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
}

mergeHook函数会将parentValchildVal进行合并,返回的始终是一个放钩子函数的数组。dedupeHooks函数是对数组去重的。parentVal是来自构造器的选项,从它的类型注释上可以看出,它有值时肯定是一个数组。还是来举例说明这是什么情况下出现的。

1
2
3
4
5
const VueChild = Vue.extend({
created: function() {
console.log('VueChild created called')
}
})

此时parentValVue.options.created,所以是undefined。而childVal就是我们上面传的created,经过mergeHook函数合并后,VueChild.options.created变成下面这样:

1
2
3
{
created: f ()
}

然后我们再创建一个VueChild的实例对象:

1
2
3
4
5
const vm = new VueChild({
created: function() {
console.log('vm created called')
}
})

此时parentValVueChild.options.created,所以parentVal一旦有值就是已经合并过了,变成了一个数组。此时childVal还是我们上面传的created。这样合并后vm.options.created就变成了:

1
2
3
{
created: [f (), f ()]
}

资源选项的合并策略

从下面这段代码开始看:

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

ASSET_TYPES定义在shared/constants.js文件中:

1
2
3
4
5
export const ASSET_TYPES = [
'component',
'directive',
'filter'
]

所以这里的资源选项是指:componentsdirectivesfilters。这几个选项都是哈希表,并使用一样的合并策略。将构造器中的选项作为合并后的选项的原型,开发者传入的选项正常混入到合并后的选项中。我们已经知道了Vue.options中一开始就有这几个选项,所以它们会被合并到所有实例对象或子类构造器的这个几个选项的原型中。这就使得内置的组件和指令不需要开发者显示地注册,就能正常使用了。

如果传了这几个选项,必须是对象类型的,否则在开发环境下会给警告。

实现资源选项合并策略的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Assets
*
* When a vm is present (instance creation), we need to do
* a three-way merge between constructor options, instance
* options and parent options.
*/
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
}
}

const res = Object.create(parentVal || null)也就相当于res.__proto__ = parentVal || null

下面还是通过举个例子一步一步来看一下合并过程,你可以直接放在浏览器中打断点看看哦。

1
2
3
4
5
6
7
8
// merge components options
const ButtonCounter = Vue.component('button-counter', {
data: function () {
return {
count: 0
}
}
})

这里parentValVue.options.componentschildValundefined,因为我们没有传入components选项。经过mergeAssets函数合并后,ButtonCounter.options.components此时为:

1
2
3
4
5
6
7
8
9
{
'components': {
'__proto__': {
KeepAlive,
Transition,
TransitionGroup
}
}
}

再使用ButtonCounter组件创建一个Vue实例对象:

1
2
3
4
5
6
const vm = new Vue({
el: '#app',
components: {
'ButtonCounter': ButtonCounter
}
})

此时还是Vue.options.components与我们传入的components选项合并。因为Vue.component的作用就是注册一个全局组件,所以现在Vue.options.components中多了一个button-counter组件,经过mergeAssets函数合并后vm.options.components现在为:

1
2
3
4
5
6
7
8
9
10
11
{
'components': {
ButtonCounter,
'__proto__': {
KeepAlive,
Transition,
TransitionGroup,
'button-counter'
}
}
}

这里举的是components的例子,directivesfilters也是一样的合并过程。

这也就解释了在合并选项前的预处理#传参一节中说到的,与Vue.options合并后的app.$options中的componentsdirectives的样子。

watch选项的合并策略

watch选项也是一个哈希表,key是需要观察的表达式,value是对应回调函数。value也可以是方法名,或者包含选项的对象。

watch选项的合并策略是,在开发者传入的watch选项中的key如果和构造器的watch选项中的key一样时,会将两者合并成一个数组,并重新作为该key对应的value。这就是使得在被观察的key发生改变时,所有watchkey的地方都会得到执行。

实现watch选项的合并策略的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/**
* Watchers.
*
* Watchers hashes should not overwrite one
* another, so we merge them as arrays.
*/
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
}

nativeWatch的定义在core/util/env.js中,内容如下。看注释也可以知道因为火狐浏览器中的Object原型上本来就有watch函数,为了跟Vue中的watch选项不混淆,当组件选项是浏览器原生的watch函数时,就重置为undefined

1
2
// Firefox has a "watch" function on Object.prototype...
export const nativeWatch = ({}).watch

剩下的代码明面意思不难看懂,我们还是通过例子来看一下每种情况合并后的结果。先看parentValundefinedchildVal有值的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
// merge watch options
const VueChild = Vue.extend({
data: function() {
return {
foo: 'foo'
}
},
watch: {
foo: function (val, oldVal) {
console.log('new: %s, old: %s', val, oldVal)
}
}
})

上面的例子在合并watch选项时,parentValVue.options.watch,所以是undefined的。childVal是我们传入的watch选项。此时会执行if (!parentVal) return childValchildVal直接返回,所以合并后VueChild.options.watch如下:

1
2
3
4
5
{
watch: {
foo: f (val, oldVal)
}
}

再接着看parentVal有值,但是childValundefined的情况,沿用上面的例子:

1
2
3
4
5
6
const vm = new VueChild({
el: '#app',
data: {
bar: 'bar'
}
}

此时parentValVueChild.options.watch,肯定有值啦,childVal是我们传入的选项,上面的例子中没有传watch选项,所以childValundefined的。这会执行if (!childVal) return Object.create(parentVal || null),返回以parentVal为原型创建的对象。合并后vm.$options.watch为:

1
2
3
4
5
6
7
{
watch: {
'__proto__': {
foo: f (val, oldVal)
}
}
}

现在给上面的例子中传入一个watch选项,就会使得parentValchildVal都有值了:

1
2
3
4
5
6
7
8
9
10
11
const vm = new VueChild({
el: '#app',
data: {
bar: 'bar'
},
watch: {
bar: function(val, oldVal) {
console.log('new: %s, old: %s', val, oldVal)
}
}
})

这时会先将parentVal中的内容放入结果中,然后再将childVal中的内容混入进去,在混入的过程中,childVal中的属性值都会变成数组,如果与parentVal中有重复的就合并在一个数组中。parentVal中没有与childVal中重合的部分自然就还保留是函数(在“预备知识”中介绍过extend函数的作用了)。上面的例子合并后vm.$options.watch为:

1
2
3
4
5
6
{
watch: {
bar: [f],
foo: f (val, oldVal)
}
}

props、methods、inject、computed选项的合并策略

propsmethodsinjectcomputed这几个选项也是哈希表,并且用的是一样的合并策略。在构造器和开发者传入的这些选项中,如果有相同的key,会用实例对象中该key的值覆盖掉构造器中该key的值。

这几个选项的类型必须是对象,否则在开发环境下会给出警告。

propsinject虽然允许开发者设置为数组,但是前面在选项规范化的时候,Vue内部已经将其规范成了对象。

代码中实现这几个选项的合并策略的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Other object hashes.
*/
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
}

上面的代码不多解释了,我们还是直接将其对应到实际开发中的场景,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// merge props
const VueChild = Vue.extend({
props: {
foo: {
type: String,
default: ''
},
bar: {
type: String,
default: ''
}
}
})

此时parentVal来自Vue.options.props,所以是undefinedchildVal是我们上面传的props选项,是有值的。此时执行if (!parentVal) return childVal,直接将childVal作为结果返回。所以合并后的VueChild.options.props如下:

1
2
3
4
5
6
7
8
9
10
11
12
{
props: {
foo: {
type: String,
default: ''
},
bar: {
type: String,
default: ''
}
}
}

接着上面的例子,创建一个VueChild的实例对象:

1
2
3
4
5
6
7
8
const vm = new VueChild({
props: {
foo: {
type: String,
default: 'foo'
}
}
})

此时parentVal来自VueChild.options.props,已经有值了。childVal是我们传入的props选项,也是有值的。此时会先创建一个空对象,先将parentVal中的键值对放入进去,然后再将childVal中的键值对混入进这个对象。此时得到vm.$options.props如下:

1
2
3
4
5
6
7
8
9
10
11
12
{
props: {
foo: {
type: String,
default: 'foo'
},
bar: {
type: String,
default: ''
}
}
}

这里举的是props选项的例子,其他几个选项的合并过程也一样。

provide选项的合并策略

provide选项的合并使用的是mergeDataOrFn函数,这个在说data选项时已经介绍过了,就不赘述了。

选项的合并基本就这些了,基本覆盖了在官方API文档列出的所有选项。在说每个合并策略时,我都举了简单但却能说明问题的例子,希望你能到浏览器中真的去调试一遍,这样加深理解。我们也留了很多很多坑在里面,看后面的代码再慢慢补上。

本文作者:意林
本文链接:http://shinancao.cn/2020/01/10/Vue-Source-Code-02/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!