Vue.js源码学习 —— 合并选项前的预处理

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

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

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

预备知识

选项 指的是在创建Vue的实例对象或“子类”构造器时传入的每项键值对。因为Vue内部是用一个options变量接收的,所以通常就把这些传入的键值对叫选项了。可以在Vue官方API文档中查看暴露给开发者使用的选项。

合并选项 指的是合并构造器中的选项和创建实例对象时传入的选项。因为大部分官方API文档中的选项都是即可以传入构造器中,也可以传入实例对象中的。所以就会有合并选项的过程,不然有重复的情况怎么办呢。

因为这些选项都是开发者传入的,所以至少需要确保传入选项的类型、选项的命名等是对的。还有像propsinject这样的选项,Vue既允许传入数组类型也允许传入对象类型,那在Vue内部就必须要将其统一成一种类型。后续的处理过程都会依赖于各个选项,所以首选确保它们是可用的非常重要。

创建Vue实例时调用的Vue.prototype._init方法进行初始化,位于core/instance/init.js文件中,我们这篇文章会大致看一下它内部做的事情,然后主要分析合并选项的部分。合并选项调用的是mergeOptions函数,来自文件core/util/options.js中。

创建Vue的“子类”构造器通常使用的是Vue.extendAPI,它的实现位于文件core/global-api/extend.js中。今天不打算分析它,但是后面举例子的时候要用到,所以先大致了解一下。Vue.extend的内部也会发生一次选项合并,与初始化实例对象时使用的是同一个函数,代码如下:

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

Super.options是父类的构造器选项,extendOptions是开发者传入的选项,合并后的选项就是子类构造器的选项了。

无论是子类构造器还是实例对象,那最最开始要去合并的肯定是开发者传入的选项,和Vue自身的选项Vue.options啦,所以咱们得先了解一下Vue.options中最开始都有什么选项。这些选项的注册在initGlobalAPI(Vue)中,位于文件core/global-api/index.js中:

1
2
3
4
5
6
7
8
Vue.options = Object.create(null)
ASSET_TYPES.forEach(type => {
Vue.options[type + 's'] = Object.create(null)
})

// this is used to identify the "base" constructor to extend all plain-object
// components with in Weex's multi-instance scenarios.
Vue.options._base = Vue

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

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

可以看到一开始注册的是资源选项componentsdirectivesfilters,这几个选项是和平台特性相关的,所以此处还是直接将它们设置成了空对象。然后各个平台自己去添加这几个项目中的具体内容。我们研究web端的,经过了文件platform/web/runtime/index.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)
}

先铺垫到这里,下面看一下Vue.prototype._init方法内的大致实现过程。

Vue.prototype._init

initMixin函数为Vue.prototype增加了_init方法,这个方法主要做的事情如下:

  • 定义了常量vm,指向this

    因为Vue设计时借鉴了MVVM模式,所以作者把指向Vue实例的变量命名为了vm

  • 全局变量uid自增1,赋值给了vm._uid,这使得开发者的项目中的每个Vue实例都会有一个唯一uid。

  • 开发环境下记录Vue实例初始化的耗时,在Vue.js源码学习 —— 起步中已经讲过了这个耗时记录,这里不赘述了。

  • vm._isVue 设置为true,代码注释中写了,为了避免该对象被响应系统观察到,等看响应部分的代码时再具体看它有啥用。

  • 调用mergeOptions函数合并选项,这里是今天要重点看的,也是初始化过程中很重要的一步,下面细讲。

  • vm._selfvm._renderProxy设置为当前对象,估计也是后面哪块要用到了,现在这里留个疑问,后面遇到再来解释。

  • 调用了各个init**的初始化函数,每个初始化函数放到看那一部分的代码时再说。

  • beforeCreatecreated两个回调函数被调用了。为什么这样放置这两个函数的调用时机,我们后面看完各部分的初始化过程,看看能否得到答案。

  • 执行vm.$mount挂载vm,这个过程可以单独开一篇详细说了,暂时先放下。

下面主要来看合并选项的部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
// merge options
if (options && options._isComponent) {
// optimize internal component instantiation
// since dynamic options merging is pretty slow, and none of the
// internal component options needs special treatment.
initInternalComponent(vm, options)
} else {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)
}

if (options && options._isComponent)这句判断,_isComponent它是一个内部选项。搜索整个项目会发现_isComponent置为true出现在了create-component.js中,跳过去可以发现,_isComponent是在Vue创建组件时用到的。和我们今天要研究的合并选项没有关系,具体的细节暂时先放下。

else的部分才是真正地处理合并选项,合并后的结果会赋值给vm.$options,我们知道实例对象的$options是开发者可以使用的。

接下来就主要看一下mergeOptions函数的实现。

mergeOptions

该函数的定义如下:

1
2
3
4
5
6
7
8
9
10
11
/**
* Merge two option objects into a new one.
* Core utility used in both instantiation and inheritance.
*/
export function mergeOptions (
parent: Object,
child: Object,
vm?: Component
): Object {
// ...省略具体实现
}

传参

正如前面看到的,在创建实例对象时的传参如下:

1
2
3
4
5
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
)

mergeOptions函数需要传入3个参数,第1个参数传的是当前实例对象的构造器的选项,第2个参数options就是创建Vue实例时传入的选项,第3个参数就是当前实例自身。

那第1个参数为什么不直接传vm.constructor.options呢?

其实如果没有继承的情况下,比如const vm = new Vue(options)时,第1个参数直接传vm.constructor.options就行了。那resolveConstructorOptions函数的作用是什么呢?这个函数的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function resolveConstructorOptions (Ctor: Class<Component>) {
let options = Ctor.options
if (Ctor.super) {
const superOptions = resolveConstructorOptions(Ctor.super)
const cachedSuperOptions = Ctor.superOptions
if (superOptions !== cachedSuperOptions) {
// super option changed,
// need to resolve new options.

// ...省略具体实现
}
}
return options
}

构造器有super属性时,说明当前构造器继承了另一个构造器。我们知道使用Vue.extend可以创建一个Vue的子类构造器。

resolveConstructorOptions函数的作用主要是在父类构造器的选项发生改变时,重新获取构造器当前构造器的选项。这其实是在修复issues中的bug#4976这个bug。这个bug导致了vue-class-component的hot-reload不起作用,也无法使用CSS Modules。我们目前应该还不会遇到父类构造器的选项发生改变的情况。

咱们用一个非常简单的例子,先对合并选项有一个整体的印象。

1
2
3
4
5
6
7
8
// merge options with no super
const options = {
el: '#app',
data: {
message: 'Hello Vue!'
}
}
const app = new Vue(options)

上面的例子中app的构造器就是Vue,所以发生合并的就是Vue.options和例子中定义的options。在“预备知识”的部分说了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)
}

例子中的options的内容如下:

1
2
3
4
5
6
{
el: '#app',
data: {
message: 'Hello Vue!'
}
}

合并后的结果会赋值给app.$options,所以此时app.$options的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
components: {
'__proto__': {
KeepAlive,
Transition,
TransitionGroup
}
},
data: ƒ mergedInstanceDataFn(),
directives: {
'__proto__': {
model,
show
}
},
el: '#app',
filters: {},
_base: ƒ Vue(options)
}

可以看到选项都合并到一起了,但似乎并不是简单的将两个对象的键值对合并在一起,后面说“合并选项的策略”时就会揭晓答案。

传参了解到这差不多了,下面到mergeOptions的函数内部看看如何实现的。

校验组件的名称

1
2
3
if (process.env.NODE_ENV !== 'production') {
checkComponents(child)
}

开发环境下会先校验开发者传入的components选项中的组件名称是否符合要求。校验的规则在validateComponentName函数中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function validateComponentName (name: string) {
if (!new RegExp(`^[a-zA-Z][\\-\\.0-9_${unicodeRegExp.source}]*$`).test(name)) {
warn(
'Invalid component name: "' + name + '". Component names ' +
'should conform to valid custom element name in html5 specification.'
)
}
if (isBuiltInTag(name) || config.isReservedTag(name)) {
warn(
'Do not use built-in or reserved HTML elements as component ' +
'id: ' + name
)
}
}

组件的命名需要满足两个if中判断的条件:

  1. 以字母开头,大小写都可以,后面可以是-.、数字、_或特定的unicode值的字符,跳到unicodeRegExp的定义处可以看到具体有哪些字符。组件的命名几乎和html5规范中要求的一样,只是这里允许使用大写字母。

  2. 不能是内置的标签名,跳到isBuiltInTag函数,发现目前内置的标签名是slotcomponent

    1
    2
    3
    4
    /**
    * Check if a tag is a built-in tag.
    */
    export const isBuiltInTag = makeMap('slot,component', true)

    也不能用保留标签,isReservedTag函数是一个和平台相关的函数,我们现在看的是浏览器环境下的Vue的实现,isReservedTag的实现在文件platform/web/util/element.js中。组件的名称不能是HTML标签,也不能是SVG标签。

    1
    2
    3
    export const isReservedTag = (tag: string): ?boolean => {
    return isHTMLTag(tag) || isSVG(tag)
    }

如果像下面这样,用了不符合要求的标签名(此处用了slot这个内置的标签名),在开发环境下会给出警告信息。

1
2
3
4
5
6
// invalid component name
const app = new Vue({
components: {
slot: Vue.component()
}
})

合并另一个构造器的选项

1
2
3
if (typeof child === 'function') {
child = child.options
}

这里说明在创建Vue实例时也可以传Vue构造器的子类,然会合并这个子类构造器的options。举个例子看一下:

1
2
3
4
5
6
7
8
9
10
// options is function
const Suber = Vue.extend({
data: function() {
return { message: 'Hello Vue!' }
}
})
console.log(Suber.options)
// {components: {…}, directives: {…}, filters: {…}, _base: ƒ, data: ƒ, …}

const vm = new Vue(Suber)

Vue.extend内部会合并Vue.options和开发者传入的options,所以Suber.options此时如下:

1
{components: {…}, directives: {…}, filters: {…}, _base: ƒ, data: ƒ, …}

创建实例对象时将Suber传入,就会执行child = child.options这句了。看来当我们传入一个构造器去创建实例对象时,用到的只是这个构造器的选项。

规范props选项

因为Vue.js允许开发者传入props选项时,即可以是数组类型,也可以是对象类型。所以在合并选项之前要将其规范成对象。对象中每个键值对中的值也会规范成一个对象,并且该对象中包含type属性。实现过程在normalizeProps函数中。

1
2
3
function normalizeProps (options: Object, vm: ?Component) {
// ...省略具体实现
}

如果选项中没有props会直接返回。然后判断props的类型,如果不是数组或对象,在开发环境下会给出警告。

props选项是数组的情况:

1
2
3
4
// props is array
const app = new Vue({
props: ['height']
})

数组中的元素必须是字符串,否则开发环境下会给出警告信息。

调用camelize函数将数组中的字符串改为驼峰形式。上面的props会规范成如下形式:

1
2
3
4
5
props: {
height: {
type: null
}
}

props选项是对象的情况:

1
2
3
4
5
6
7
8
9
10
// props is object
const app = new Vue({
props: {
height: Number,
age: {
type: Number,
default: 0
}
}
})

同样调用camelize函数将props对象中的key改为驼峰形式。上面的props会被规范成如下形式:

1
2
3
4
5
6
7
8
9
props: {
height: {
type: Number
},
age: {
type: Number,
default: 0
}
}

规范inject选项

Vue.js同样也允许开发者在传入inject选项时,即可以是数组类型也可以是对象类型的。同样也是会统一规范成对象。对象中每个键值对中的值也是一个对象,并且含有from属性。实现过程在normalizeInject函数中:

1
2
3
function normalizeInject (options: Object, vm: ?Component) {
// ...省略实现过程
}

如果选项中没有inject会直接返回。inject选项如果不是是数组或对象,会在开发环境下会给出警告信息。

inject选项是数组的情况:

1
2
3
4
// inject is array
const Child = {
inject: ['foo']
}

会被规范成如下形式:

1
2
3
inject: {
foo: { from: 'foo' }
}

inject选项是对象的情况:

1
2
3
4
5
6
7
// inject is object
const Child = {
inject: {
foo: { default: 'foo' },
bar: 'bar'
}
}

会被规范成如下形式:

1
2
3
4
inject: {
bar: {from: 'bar'}
foo: {from: 'foo', default: 'foo'}
}

规范directives选项

先看个使用directives注册局部指令的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<div id="app" v-focus v-off></div>

// directives options
const app = new Vue({
el: '#app',
directives: {
focus: {
inserted: function (el) {
console.log('v-focus')
}
},
off: function (el) {
console.log('v-off')
}
}
})

我们知道自定义一个指令对象时,可以提供在几个钩子函数,这样Vue.js就知道要在组件生命周期中的哪个时机去执行这个指令了。但是开发者也可以不提供钩子函数,这时会规范成了在bindupdate时执行。实现过程在normalizeDirectives函数中:

1
2
3
function normalizeDirectives (options: Object) {
// ...省略具体实现
}

在选项中有directives时,会判断每个键值对的值是否是一个函数,如果是,就会将其值改为对象,key就是bindupdate,值都是该函数。

比如上面的off指令,会规范成如下形式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
directives: {
focus: {
inserted: function(el) {
console.log('v-focus')
}
},
off: {
bind: function(el) {
console.log('v-off')
},
update: function(el) {
console.log('v-off')
}
}
}

合并extends和mixins选项

再接着往下看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Apply extends and mixins on the child options,
// but only if it is a raw options object that isn't
// the result of another mergeOptions call.
// Only merged options has the _base property.
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)
}
}
}

child._base是用来判断child是否来自另一个mergeOptions函数调用的结果,如果有_base选项说明已经合并过了。如果没有,那child就是开发者最初传入的选项。

我们在“预备知识”小节已经介绍过Vue.options的内容,其中就有_base选项。也讲到合并最最开始都是和Vue.options合并的,所以只要合并了一次选项中就会有_base选项。

然后判断如果开发者传入了extends选项,就递归调用mergeOptions函数,将当前构造器选项和extends中的选项合并。mergeOptions函数会返回一个新的对象,所以这里会得到一个新的parent对象。

同样地,如果开发者传入了mixins选项,递归调用mergeOptions函数,将所有mixins中的选项最后都合并到parent上。注意,mixins是数组类型的,所以这里用了循环。

下面分别举个传入extendsmixins选项的例子,然后在浏览器中调试看一下合并后的结果:

1
2
3
4
5
6
7
8
9
// extends options
const CompA = {
props: ['height']
}
// 在没有调用 `Vue.extend` 时候继承 CompA
const vm = new Vue({
el: '#app',
extends: CompA
})

合并后的options如下:

1
2
3
4
5
6
7
8
// mixins options
var mixin = {
created: function () { console.log(1) }
}
var vm = new Vue({
created: function () { console.log(2) },
mixins: [mixin]
})

合并后的options如下:

可以看到合并后的选项中还有extendsmixins,如果不做是否合并过的判断,递归调用就会一直调用下去了。

合并选项

上面所有的操作还都只是在为接下来的合并选项做预处理,下面才开始真正地合并过程。

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

首先新创建一个空的options对象,最后返回的也是这个options对象,所以说mergeOptions函数返回的是一个新的合并后的对象。

先将parent中的选项放入options中,然后再将child中在parent里没有的选项放入options中。

如果parentchild有相同的key时该怎么处理呢?是覆盖还是合并?所以在mergeField函数中我们看到使用了一个全局变量strats,它是一个哈希表,以各个选项的名称为key,选项对应的合并策略为value

所以接下来的重点就是分析每个选项具体是怎样合并的了。这篇文章已经太长了,我会再开一篇来专门介绍选项的合并策略。

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