最近参加了很多场面试,几乎每场面试中都会问到Vue源码方面的问题。在此开一个系列的专栏,来总结一下这方面的经验,如果觉得对您有帮助的,不妨点个赞支持一下呗。
前言
在上一篇 🚩Vue源码——组件如何渲染成最终的DOM 中详细介绍了在 Vue 中组件如何渲染成最终的 DOM。而在 Vue 中,除了它内置的组件如 keep-alive、component、transition、transition-group 等,其它自定义组件在使用前必须注册。如果没有注册成功,就会在控制台报如下错误。
'Unknown custom element: <xxx> - did you register the component correctly?
For recursive components, make sure to provide the "name" option.'
在 Vue 中组件注册方式分为全局注册和局部注册,本文介绍这两种注册方式的实现原理。
一、全局注册
要注册一个全局组件,实现方式如下
Vue.component('my-component', {
// 组件选项
})
那么Vue.component
这个全局API,在源码中是怎么定义的。在 Vue 源码中是用 initGlobalAPI
方法来初始化全局 API 的。
var ASSET_TYPES = ['component','directive','filter'];
function initGlobalAPI(Vue) {
Vue.options = Object.create(null);
ASSET_TYPES.forEach(function(type) {
Vue.options[type + 's'] = Object.create(null);
});
Vue.options._base = Vue;
initAssetRegisters(Vue);
}
执行initAssetRegisters(Vue)
来定义Vue.component
这个全局API。
var ASSET_TYPES = ['component', 'directive', 'filter'];
function initAssetRegisters(Vue) {
ASSET_TYPES.forEach(function(type) {
Vue[type] = function(id, definition) {
if (!definition) {
return this.options[type + 's'][id]
} else {
if (type === 'component') {
validateComponentName(id);
}
if (type === 'component' && isPlainObject(definition)) {
definition.name = definition.name || id;
definition = this.options._base.extend(definition);
}
if (type === 'directive' && typeof definition === 'function') {
definition = {
bind: definition,
update: definition
};
}
this.options[type + 's'][id] = definition;
return definition
}
};
});
}
函数首先遍历 ASSET_TYPES,得到type
后挂载到 Vue 上 ,实际上是 Vue 初始化了3个全局APIVue.component
、Vue.directive
、Vue.filter
。
所以当type
为component
时,就是初始化Vue.component
这个全局API。把代码提取出来一下,
Vue.component = function(id, definition) {
if (!definition) {
return this.options.components[id]
} else {
validateComponentName(id);
if (isPlainObject(definition)) {
definition.name = definition.name || id;
definition = this.options._base.extend(definition);
}
this.options.components[id] = definition;
return definition
}
}
- 参数
id
:组件的名称 - 参数
definition
:组件的选项对象
在介绍Vue.component
的内部逻辑时,先从官网了解一下它的用法。
-
注册组件,传入一个扩展过的构造函数
Vue.component('my-component', Vue.extend({ /* ... */ }))
-
注册组件,传入一个选项对象 (自动调用 Vue.extend)
Vue.component('my-component', { /* ... */ })
-
获取注册的组件 (始终返回构造函数)
var MyComponent = Vue.component('my-component')
再回过来了解一下其实现逻辑
如果参数definition
不存在,直接返回this.options.components[id]
,如果名为id
的组件已经注册过则返回其构造函数,否则返回 undefined。
如果参数definition
存在,先用validateComponentName
判断参数id
(组件名)是否符合规范,再判断参数definition
是否是对象。
如果参数definition
是对象,则执行definition = this.options._base.extend(definition)
,其中this.options._base
为 Vue 构造函数,相当于Vue.extend
把definition
(组件的选项对象)生成一个继承于 Vue 的构造函数赋值给definition
。
在通过this.options.components[id] = definition
,把生成的构造函数挂载到Vue.options.components
上。
如果参数definition
不是对象,直接挂载到Vue.options.components
上。
最后返回生成的构造函数definition
。
以上要记住 全局组件生成的构造函数挂载在Vue.options.components
,下面来分析全局组件之所以是全局的关键的实现逻辑。
先回顾一下🚩Vue源码——组件如何渲染成最终的DOM的知识点。
在vm._render
过程中,通过createComponent
方法创建组件vnode
时会接收一个参数Ctor
,Ctor
可以是个函数(组件构造函数),也可以是个对象(组件选项对象),如果是对象,则用Vue.extend
创建一个继承 Vue 的构造函数再赋值给Ctor
,执行installComponentHooks
安装组件钩子函数,最后把Ctor
挂载到vnode
的componentOptions
属性上。
在vm.updata
过程中,会在createComponent
方法中执行组件钩子函数init
,在init
中调用createComponentInstanceForVnode
方法来返回一个组件实例化对象child
,实例化时会执行new vnode.componentOptions.Ctor(options)
,最后执行child.$mount
挂载渲染这个组件,此时就相当使用了这个组件。
从以上可知组件的使用关键在Ctor
这个参数。回到vm._render
过程中,createComponent
方法是在_createElement
方法中被调用,其方法中有这么一段逻辑。
if ( (!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag)) ) {
vnode = createComponent(Ctor, data, context, children, tag);
}
可以看到Ctor
是通过Ctor = resolveAsset(context.$options, 'components', tag)
来获取的,参数tag
是组件注册时定义的组件名,data.pre
是v-pre
指令的指,一般为true
。来看一下resolveAsset
内部逻辑。
var hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn(obj, key) {
return hasOwnProperty.call(obj, key)
}
function resolveAsset(options, type, id, warnMissing) {
if (typeof id !== 'string') {
return
}
var assets = options[type];
if (hasOwn(assets, id)) {
return assets[id]
}
var camelizedId = camelize(id);
if (hasOwn(assets, camelizedId)) {
return assets[camelizedId]
}
var PascalCaseId = capitalize(camelizedId);
if (hasOwn(assets, PascalCaseId)) {
return assets[PascalCaseId]
}
var res = assets[id] || assets[camelizedId] || assets[PascalCaseId];
if (warnMissing && !res) {
warn(
'Failed to resolve ' + type.slice(0, -1) + ': ' + id,
options
);
}
return res
}
先来看一下camelize
方法和capitalize
方法
function cached(fn) {
var cache = Object.create(null);
return (function cachedFn(str) {
var hit = cache[str];
return hit || (cache[str] = fn(str))
})
}
var camelizeRE = /-(\w)/g;
var camelize = cached(function(str) {
return str.replace(camelizeRE, function(_, c) {
return c ? c.toUpperCase() : '';
})
});
var capitalize = cached(function(str) {
return str.charAt(0).toUpperCase() + str.slice(1)
});
其中cached
是个高阶函数,另外利用闭包缓存函数的运行结果,减少不必要的函数运算。
高阶函数是至少满足下列一个条件的函数:1、接受一个或多个函数作为输入; 2、输出一个函数。
在cached
函数中,创建变量cache
,在返回函数cachedFn
使用变量cache
,构成闭包,变量cache
会一直存在缓存中。
在返回函数cachedFn
中,先获取cache[str]
,获取不到则执行fn(str)
赋值给变量cache[str]
,并返回。
例如执行camelize(aa-aa)
,此时cached
的参数fn
是
function(str) {
return str.replace(camelizeRE, function(_, c) {
return c ? c.toUpperCase() : '';
})
}
cachedFn
的参数str
为aa-aa
,第一次执行时cache['aa-aa']
为 undefined,故执行fn(str)
赋值给cache['aa-aa']
并返回aaAa
,第二次执行时cache['aa-aa']
有值为aaAa
,直接返回该值。
再回到resolveAsset
方法中,此时参数options
为context.$options
,context
是上下文相当this
,参数type
为components
,先通过var assets = context.$options.components
获取assets
,然后再尝试获取assets[id]
,id
的值是参数tag
是组件名。先直接使用id
获取,如果获取不到,则用camelize
方法把id
变成驼峰的形式再获取,如果仍然获取不到,则在用capitalize
方法把id
在驼峰的基础上把首字母再变成大写的形式再获取,如果仍然获取不到则报错。这样意味在使用Vue.component(id, definition)
全局注册组件的时候,组件名id
可以是连字符、驼峰或首字母大写的形式。
从对resolveAsset
方法分析,可发现Ctor
是通过context.$options.components[tag]
来获取,tag
为组件名。因为全局组件的构造函数挂载在Vue.options.components
,所以使用全局组件,要先把Vue.options
合并到context.$options
上,才能通过context.$options.components[tag]
来获取全局组件的构造函数赋值给Ctor
。
其中context
是上下文,在不同的组件使用场景下context
是不一样的,所以合并手段也不一样。
1、在普通标签中使用全局组件
写个简单的 demo ,在 div 标签中使用全局组件 aa
<template>
<div>
<aa></aa>
</div>
</template>
在创建vnode
(Virtual DOM)过程,会执行_createElement
方法。
function _createElement(context, tag, data, children, normalizationType) {
var vnode;
if ( (!data || !data.pre) &&
isDef(Ctor = resolveAsset(context.$options,'components',tag))
) {
vnode = createComponent(Ctor, data, context, children, tag);
}
return vnode
}
因为组件 aa 最外层的标签就是 div 普通标签,所以参数context
为 Vue 构造函数实例化的对象。只要在 Vue 实例化时,初始化中执行this._init(options)
中调用mergeOptions
方法把 Vue.options
合并到vm.$options
上,其中resolveConstructorOptions
方法是用来获取Vue.options
。就可以通过context.$options.components
获取到Vue.options.components
的值。
Vue.prototype._init = function(options) {
vm.$options = mergeOptions(
resolveConstructorOptions(vm.constructor),
options || {},
vm
);
};
2、在组件中使用全局组件
写个简单的 demo ,在组件 bb 中使用全局组件 aa ,在bb.vue文件中
<template>
<aa></aa>
</template>
因为组件 aa 是在组件 bb 中使用,所以参数context
为 组件 bb 构造函数实例化的对象。此时要把 Vue.options
合并到context.$options
上,要经历两个步骤,步骤一在用Vue.extend
方法继承 Vue 创建组件 bb 构造函数时,步骤二在组件 bb 构造函数实例化时。
-
步骤一: 在
Vue.extend
方法中Vue.extend = function(extendOptions) { extendOptions = extendOptions || {}; var Super = this; var Sub = function VueComponent(options) { this._init(options); }; Sub.prototype = Object.create(Super.prototype); Sub.prototype.constructor = Sub; Sub.options = mergeOptions( Super.options, extendOptions ); return Sub };
以上逻辑中
Super
是this
,也就是 Vue 这个构造函数。执行Sub.options = mergeOptions(Super.options,extendOptions)
后把 Vue 构造函数上的options
合并到 Sub(组件 bb)构造函数上的options
,这样组件 bb 构造函数上的options
就能访问到 Vue 构造函数上的options
。 -
步骤二: 在组件 bb 构造函数实例化时
执行
this._init(options)
进行初始化,此时options._isComponent
为true
,故执行initInternalComponent
方法 把组件 bb 构造函数上的options
合并到vm.$options
上。Vue.prototype._init = function(options) { if (options && options._isComponent) { initInternalComponent(vm, options); } }; function initInternalComponent(vm, options) { var opts = vm.$options = Object.create(vm.constructor.options); }
经过以上两个步骤就可以通过
context.$options.components
获取到Vue.options.components
的值。
二、局部组件
使用局部组件时,局部组件是在组件的选项对象中定义,例如组件 bb 中使用局部组件 aa
import aa from './components/aa'
export default {
components: {
aa
}
}
在创建组件 bb 构造函数时,会执行以下逻辑
Vue.extend = function(extendOptions) {
extendOptions = extendOptions || {};
var Super = this;
var Sub = function VueComponent(options) {
this._init(options);
};
Sub.prototype = Object.create(Super.prototype);
Sub.prototype.constructor = Sub;
Sub.options = mergeOptions(
Super.options,
extendOptions
);
return Sub
};
其中extendOptions
就是组件 bb 的选项对象,执行
Sub.options = mergeOptions(Super.options,extendOptions)
,把组件 bb 的选项对象合并到组件 bb 构造函数的options
。
然后组件 bb 构造函数实例化时初始化中执行initInternalComponent(vm, options)
,把构造函数的options
合并到vm.$options
。
这样就可以通过context.$options.components
获取到组件 bb 的选项对象的components
。
三、总结
组件的使用,关键在通过组件名称从context.$options.components
中获取组件构造函数或组件选项对象赋值给Ctor
,然后创建vnode
,渲染vnode
成为真实 DOM 。具体过程看🚩Vue源码——组件如何渲染成最终的DOM。
局部组件之所以只能在局部使用,是因为context.$options.components
只能获取定义在组件选项对象中的组件构造函数或组件选项对象。
而全局组件是把组件的构造函数挂载在Vue.options.components
上,在 普通标签中使用全局组件 还是在 组件中使用全局组件 的过程中,经过options
合并,都能通过context.$options.components
获取到Vue.options.components
的值,这就是全局注册的组件能在任意地方使用的原因。