最近参加了很多场面试,几乎每场面试中都会问到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的值,这就是全局注册的组件能在任意地方使用的原因。