🚩Vue源码——组件是如何注册和使用

1,565 阅读5分钟

最近参加了很多场面试,几乎每场面试中都会问到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.componentVue.directiveVue.filter

所以当typecomponent时,就是初始化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.extenddefinition(组件的选项对象)生成一个继承于 Vue 的构造函数赋值给definition。 在通过this.options.components[id] = definition,把生成的构造函数挂载到Vue.options.components上。

如果参数definition不是对象,直接挂载到Vue.options.components上。

最后返回生成的构造函数definition

以上要记住 全局组件生成的构造函数挂载在Vue.options.components,下面来分析全局组件之所以是全局的关键的实现逻辑。

先回顾一下🚩Vue源码——组件如何渲染成最终的DOM的知识点。

vm._render过程中,通过createComponent方法创建组件vnode时会接收一个参数CtorCtor可以是个函数(组件构造函数),也可以是个对象(组件选项对象),如果是对象,则用Vue.extend创建一个继承 Vue 的构造函数再赋值给Ctor,执行installComponentHooks安装组件钩子函数,最后把Ctor挂载到vnodecomponentOptions属性上。

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.prev-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的参数straa-aa,第一次执行时cache['aa-aa']为 undefined,故执行fn(str)赋值给cache['aa-aa']并返回aaAa,第二次执行时cache['aa-aa']有值为aaAa,直接返回该值。

再回到resolveAsset方法中,此时参数optionscontext.$optionscontext是上下文相当this,参数typecomponents,先通过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
    };
    

    以上逻辑中Superthis,也就是 Vue 这个构造函数。执行Sub.options = mergeOptions(Super.options,extendOptions)后把 Vue 构造函数上的options合并到 Sub(组件 bb)构造函数上的options,这样组件 bb 构造函数上的options就能访问到 Vue 构造函数上的options

  • 步骤二: 在组件 bb 构造函数实例化时

    执行this._init(options)进行初始化,此时options._isComponenttrue,故执行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的值,这就是全局注册的组件能在任意地方使用的原因。