Vue源码学习2.5:组件注册

909 阅读5分钟

建议PC端观看,移动端代码高亮错乱

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

一般报这个错的原因都是我们使用了未注册的组件。Vue 提供了全局局部两种注册方式。

1. components策略

在这之前先了解在 mergeOptions 中的 components 策略,关于配置合并相关概念在之前的章节已经介绍过了。

// src/core/util/options.js

// components合并策略
function mergeAssets (
  parentVal: ?Object,
  childVal: ?Object,
  vm?: Component,
  key: string
): Object 
{
  const res = Object.create(parentVal || null)
  if (childVal) {
    // ...
    return extend(res, childVal)
  } else {
    return res
  }
}

// ASSET_TYPES: ['component', 'directive', 'filter']
ASSET_TYPES.forEach(function (type{
  strats[type + 's'] = mergeAssets
})

可以看到 components 的合并策略很简单,返回一个 res 对象,其属性分别是 childVal,其原型是 parentVal

简单回顾初始化时 mergeOptions 的逻辑:

// src/core/instance/init.js

Vue.prototype._init = function (options?: Object{
  // merge options
  if (options && options._isComponent) {
    initInternalComponent(vm, options)
  } else {
    vm.$options = mergeOptions(
      resolveConstructorOptions(vm.constructor), // Vue.options
      options || {},
      vm
    )
  }
  // ...
}

对于根实例来说,当执行完 else 逻辑后:vm.$options.components.__proto__ === Vue.options.components

对于组件实例来说,当我们通过 Vue.extend 创建构造函数时:

// src/core/global-api/extend.js

Vue.extend = function (extendOptions: Object): Function {
  // ...
  Sub.options = mergeOptions(
    Super.options,
    extendOptions
  )
  // ...
  
  // 启用递归自查
  if (name) {
    Sub.options.components[name] = Sub
  }
  
  // ...
  return Sub
}

通过 mergeOptionsVue.options 合并到 Sub.options 上。

Vue.options.components 长这样:

合并之后, Sub.options.components 长这样:

可以发现 Sub.options.components.__proto__ === Vue.options.components

回到我们的 Vue.extend 中去,还会执行这个逻辑:

// 启用递归自查
if (name) {
    Sub.options.components[name] = Sub
}

通过指向自身,实现自查找,所以此时我们的 Sub.options.components 长这样:

然后在后续组件的实例化阶段,会执行 initInternalComponent,其中的关键逻辑是:

vm.$options = Object.create(vm.constructor.options)

这样就可以通过 vm.$options.components 访问到 Sub.options.components

最后用一张图总结就是:

2. 全局注册

全局注册的例子:

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

看下 Vue.component 函数的定义,代码在 src/core/global-api/assets.js 中:

// src/core/global-api/assets.js

export function initAssetRegisters (Vue: GlobalAPI{
  // ASSET_TYPES: ['component', 'directive', 'filter']
  ASSET_TYPES.forEach(type => {
    Vue[type] = function (
      id: string,
      definition: Function | Object
    
): Function | Object | void 
{
      if (!definition) {
        return this.options[type + 's'][id]
      } else {
        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)
        }
        // ...
        this.options[type + 's'][id] = definition
        return definition
      }
    }
  })
}

Vue.component核心逻辑就是通过 Vue.extend 创建组件子类构造函数并挂载到 Vue.options.components[id]

至于为什么 this.options._baseVue,在这里已经介绍过了

3. 局部注册

当传入 createElement 的参数是组件字符串时,需要使用 components 选项注册局部组件

const App = {
  name: 'app',
  render(h) {
    return h('div', {}, 'hello vue')
  },
}

new Vue({
  el: '#app',
  render(h) {
    return h('App')
  },
  components: { App }
})

我们再来回顾一下 _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
  if (typeof tag === 'string') {
    let Ctor
    // ...
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefinedundefined, context
      )
    } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      vnode = new VNode(
        tag, data, children,
        undefinedundefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  // ...
}
  • tag 是一个字符串时:
    • 普通的 html 标签,直接创建 vnode
    • 已注册的组件字符串,解析得到组件构造函数,再通过 createComponent 创建组件的占位符 vnode
    • 创建一个未知的 vnode
  • tag 直接是一个组件对象时,通过 createComponent 创建组件的占位符 vnode

下面来看看 tag 是组件字符串的情况:

通过 resolveAssettag 字符串解析得到组件构造函数或组件对象(因为全局注册 Vue.component 函数中已经创建好了构造函数,而局部注册时需要额外的在 createComponent 中通过组件对象创建构造函数):

Ctor = resolveAsset(context.$options, 'components', tag)

resolveAsset 这个定义在 src/core/utils/options.js 中:

// src/core/utils/options.js

export function resolveAsset (
  options: Object// vm.$options
  typestring// components
  id: string,
  warnMissing?: boolean
): any 
{
  if (typeof id !== 'string') {
    return
  }
  const assets = options[type// vm.$options.components
  
  // 局部注册优先:在对象自身查找
  if (hasOwn(assets, id)) return assets[id]
  // camelize: app-child => appChild
  const camelizedId = camelize(id)
  if (hasOwn(assets, camelizedId)) return assets[camelizedId]
  // capitalize: appChild => AppChild
  const PascalCaseId = capitalize(camelizedId)
  if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId]
  
  // 去原型链找全局注册
  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
}

其中 camelize 函数简化后:

const camelizeRE = /-(\w)/g
function camelize(str) {
  return str.replace(camelizeRE, (_, c) => c ? c.toUpperCase() : '')
}

其中 capitalize 函数简化后:

function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1)
})

resolveAsset 的主要逻辑如下:

  • vm.$options.components 对象的自身分别查找局部注册的组件构造函数
    • vm.$options.components[id]
    • vm.$options.components[camelizedId]
    • vm.$options.components[PascalCaseId]
  • vm.$options.components 的原型上分别查找全局注册的组件构造函数
    • vm.$options.components.__proto__[id]
    • vm.$options.components.__proto__[camelizedId]
    • vm.$options.components.__proto__[PascalCaseId]

resolveAsset 我们还能得到如下知识点:

  • 当组件被定义为连字符时,我们只能在字符串模板使用<my-comp><my-comp>
  • 当组件被定义为驼峰时,我们可以在字符串模板使用 <my-comp><my-comp><myComp><myComp>
  • 当组件被定义为首字母大写时,我们可以在字符串模板使用 <my-comp><my-comp><myComp><myComp><MyComp><MyComp>

总结

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