组件注册

在 Vue.js 中,除了它内置的组件如 keep-alivecomponenttransitiontransition-group 等,其它用户自定义组件在使用前必须注册。很多同学在开发过程中可能会遇到如下报错信息:

'Unknown custom element: <xxx> - did you register the component correctly?
 For recursive components, make sure to provide the "name" option.'

一般报这个错的原因都是我们使用了未注册的组件。Vue.js 提供了 2 种组件的注册方式,全局注册和局部注册。接下来我们从源码分析的角度来分析这两种注册方式。

全局注册

要注册一个全局组件,可以使用 Vue.component(tagName, options)。例如:

Vue.component('my-component', {
  // 选项
})

那么,Vue.component 函数是在什么时候定义的呢,它的定义过程发生在最开始初始化 Vue 的全局函数的时候,代码在 src/core/global-api/assets.js 中:

import { ASSET_TYPES } from 'shared/constants'
import { isPlainObject, validateComponentName } from '../util/index'

export function initAssetRegisters (Vue: GlobalAPI) {
  /**
   * Create asset registration methods.
   */
  ASSET_TYPES.forEach(type => {
    Vue[type] = function (
      id: string,
      definition: Function | Object
    ): Function | Object | void {
      if (!definition) {
        return this.options[type + 's'][id]
      } else {
        /* istanbul ignore if */
        if (process.env.NODE_ENV !== 'production' && 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 上 。ASSET_TYPES 的定义在 src/shared/constants.js 中:

export const ASSET_TYPES = [
  'component',
  'directive',
  'filter'
]

所以实际上 Vue 是初始化了 3 个全局函数,并且如果 typecomponentdefinition 是一个对象的话,通过 this.opitons._base.extend, 相当于 Vue.extend 把这个对象转换成一个继承于 Vue 的构造函数,最后通过 this.options[type + 's'][id] = definition 把它挂载到 Vue.options.components 上。

由于我们每个组件的创建都是通过 Vue.extend 继承而来,我们之前分析过在继承的过程中有这么一段逻辑:

Sub.options = mergeOptions(
  Super.options,
  extendOptions
)

也就是说它会把 Vue.options 合并到 Sub.options,也就是组件的 options 上, 然后在组件的实例化阶段,会执行 merge options 逻辑,把 Sub.options.components 合并到 vm.$options.components 上。

然后在创建 vnode 的过程中,会执行 _createElement 方法,我们再来回顾一下这部分的逻辑,它的定义在 src/core/vdom/create-element.js 中:

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // ...
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  // ...
}

这里有一个判断逻辑 isDef(Ctor = resolveAsset(context.$options, 'components', tag)),先来看一下 resolveAsset 的定义,在 src/core/utils/options.js 中:

/**
 * Resolve an asset.
 * This function is used because child instances need access
 * to assets defined in its ancestor chain.
 */
export function resolveAsset (
  options: Object,
  type: string,
  id: string,
  warnMissing?: boolean
): any {
  /* istanbul ignore if */
  if (typeof id !== 'string') {
    return
  }
  const assets = options[type]
  // check local registration variations first
  if (hasOwn(assets, id)) return assets[id]
  const camelizedId = camelize(id)
  if (hasOwn(assets, camelizedId)) return assets[camelizedId]
  const PascalCaseId = capitalize(camelizedId)
  if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId]
  // fallback to prototype chain
  const res = assets[id] || assets[camelizedId] || assets[PascalCaseId]
  if (process.env.NODE_ENV !== 'production' && warnMissing && !res) {
    warn(
      'Failed to resolve ' + type.slice(0, -1) + ': ' + id,
      options
    )
  }
  return res
}

这段逻辑很简单,先通过 const assets = options[type] 拿到 assets,然后再尝试拿 assets[id],这里有个顺序,先直接使用 id 拿,如果不存在,则把 id 变成驼峰的形式再拿,如果仍然不存在则在驼峰的基础上把首字母再变成大写的形式再拿,如果仍然拿不到则报错。这样说明了我们在使用 Vue.component(id, definition) 全局注册组件的时候,id 可以是连字符、驼峰或首字母大写的形式。

那么回到我们的调用 resolveAsset(context.$options, 'components', tag),即拿 vm.$options.components[tag],这样我们就可以在 resolveAsset 的时候拿到这个组件的构造函数,并作为 createComponent 的钩子的参数。

局部注册

Vue.js 也同样支持局部注册,我们可以在一个组件内部使用 components 选项做组件的局部注册,例如:

import HelloWorld from './components/HelloWorld'

export default {
  components: {
    HelloWorld
  }
}

其实理解了全局注册的过程,局部注册是非常简单的。在组件的 Vue 的实例化阶段有一个合并 option 的逻辑,之前我们也分析过,所以就把 components 合并到 vm.$options.components 上,这样我们就可以在 resolveAsset 的时候拿到这个组件的构造函数,并作为 createComponent 的钩子的参数。

注意,局部注册和全局注册不同的是,只有该类型的组件才可以访问局部注册的子组件,而全局注册是扩展到 Vue.options 下,所以在所有组件创建的过程中,都会从全局的 Vue.options.components 扩展到当前组件的 vm.$options.components 下,这就是全局注册的组件能被任意使用的原因。

总结

通过这一小节的分析,我们对组件的注册过程有了认识,并理解了全局注册和局部注册的差异。其实在平时的工作中,当我们使用到组件库的时候,往往更通用基础组件都是全局注册的,而编写的特例场景的业务组件都是局部注册的。了解了它们的原理,对我们在工作中到底使用全局注册组件还是局部注册组件是有这非常好的指导意义的。