VUE源码系列五:组件是怎样生成的?(附详细源码解析)

3,784 阅读5分钟

前言

Vue的核心思想就是组件化,我们去开发一个页面,就是拿一堆组件去搭建,像堆积木一样,因此,今天我们来分析一下Vue组件是如何创建的,以及它的内部工作原理。

案例

import Vue from 'vue'
import App from './App.vue'

var app = new Vue({
  el: '#app',
  // 这里的 h 是 createElement 方法
  render: h => h(App)
})

相信大家对上边对代码应该不陌生,h就相当于createElement方法,而它接收的是一个组件App,OK,让我们来逐一分析一哈。

前情回顾

我们在源码系列一(juejin.cn/post/684490…) 中有分析到,创建元素用createElement方法,createElement调用_createElement,而_createElement有这么一段逻辑
源码: /src/core/vdom/create-element.js

if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    // ...
    if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      /*如果是一个已注册的组件名,创建一个组件类的VNode */
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      /*如果是普通的字符串,创建一个普通的VNode节点 */
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    vnode = createComponent(tag, data, context, children)
  }

createElement方法接收一个tag参数,在内部会去判断tag标签的类型,从而去决定是创建一个普通的VNode还是一个组件类VNode;来看一下createComponent方法的实现:

正文

createComponent

源码:src/core/vdom/create-component.js

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  /* Vue,在Vue初始化的时候有这么一段逻辑Vue.options._base = Vue */
  const baseCtor = context.$options._base

  /* 构造子类构造函数 */
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }

  data = data || {}

  /* 安装组件钩子 */
  installComponentHooks(data)

  const name = Ctor.options.name || tag
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )

  return vnode
}

核心流程只有3步,我们分别来解析:

一、构造子类构造函数

先看一个示例,我们通常创建组件就是创建一个对象:

import HelloWorld from './components/HelloWorld'

export default {
  name: 'app',
  components: {
    HelloWorld
  }
}

因为组件是一个对象,因此会走

Ctor = baseCtor.extend(Ctor)

我们来看Vue.extend()
源码: src/core/global-api/extend.js

  Vue.cid = 0
  let cid = 1
  /* 构造一个Vue子类 */
  Vue.extend = function (extendOptions: Object): Function {
    extendOptions = extendOptions || {}
    /* Vue赋值给Super */
    const Super = this
    const SuperId = Super.cid
    const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
    /* 缓存中已经存在该子类,直接取出返回 */
    if (cachedCtors[SuperId]) {
      return cachedCtors[SuperId]
    }
    /* 组件名称 */
    const name = extendOptions.name || Super.options.name
    const Sub = function VueComponent (options) {
      /* 走 Vue 实例的初始化逻辑 */
      this._init(options)
    }
    /* 继承Vue */
    Sub.prototype = Object.create(Super.prototype)
    Sub.prototype.constructor = Sub
    Sub.cid = cid++
    Sub.options = mergeOptions(
      Super.options,
      extendOptions
    )
    Sub['super'] = Super
    /* 初始化props和computed属性 */
    if (Sub.options.props) {
      initProps(Sub)
    }
    if (Sub.options.computed) {
      initComputed(Sub)
    }
    if (name) {
      Sub.options.components[name] = Sub
    }
    Sub.superOptions = Super.options // 父级Options
    Sub.extendOptions = extendOptions // 组件对象
    /* 对Sub做缓存,避免多次执行Vue.extend的时候对同一个子组件重复构造 */
    cachedCtors[SuperId] = Sub
    return Sub
  }

Vue.extend 的作用就是构造一个 Vue 的子类,它使用一种非常经典的原型继承的方式把一个纯对象转换一个继承于 Vue 的构造器 Sub 并返回,然后对 Sub 这个对象本身扩展了一些属性,如扩展 options、添加全局 API 等;并且对配置中的 props 和 computed 做了初始化工作;最后对于这个 Sub 构造函数做了缓存,避免多次执行 Vue.extend 的时候对同一个子组件重复构造。

二、安装组件钩子函数

installComponentHooks(data)

installComponentHooks:

function installComponentHooks (data: VNodeData) {
  const hooks = data.hook || (data.hook = {})
  /* hooksToMerge就是Object.keys(componentVNodeHooks) */
  for (let i = 0; i < hooksToMerge.length; i++) {
    const key = hooksToMerge[i]
    const existing = hooks[key]
    const toMerge = componentVNodeHooks[key]
    if (existing !== toMerge && !(existing && existing._merged)) {
      hooks[key] = existing ? mergeHook(toMerge, existing) : toMerge
    }
  }
}

可以看出这个方法就是将componentVNodeHooks里的钩子函数添加进data.hook,从而做到修改data供后边使用; 看一下componentVNodeHooks都包含了哪些钩子函数:

const componentVNodeHooks = {
  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    // ...
  },

  prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
    // ...
  },

  insert (vnode: MountedComponentVNode) {
    // ...
  },

  destroy (vnode: MountedComponentVNode) {
    // ...
  }
}

因此整个 installComponentHooks 的过程就是把 componentVNodeHooks 的钩子函数合并到 data.hook 中,在 VNode 执行 patch 的过程中执行相关的钩子函数,后边我们会介绍 patch。

三、实例化 VNode

  const name = Ctor.options.name || tag
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
  // ...
  return vnode

很简单,就是实例化VNode,不知道VNode是啥的可以看系列一(juejin.cn/post/684490…)
注意⚠️:组件的Vnode和普通元素节点的 vnode 不同,组件的 vnode 是没有 children 的;

小结:我们分析了 createComponent 的实现,了解到它在渲染一个组件的时候的 3 个关键逻辑:构造子类构造函数,安装组件钩子函数和实例化 vnode。createComponent 后返回的是组件 vnode,它也一样走到 vm._update 方法,系列一我们有讲到,进而执行了 patch 函数,接下来我们来分析patch

patch

当我们通过 createComponent 创建了组件 VNode,接下来会走到 vm._update,执行 vm.__patch__ 去把 VNode 转换成真正的 DOM 节点。系列一已经分析过了,那是针对一个普通的 VNode 节点,接下来我们来看看组件的 VNode 会有哪些不一样的地方。

patch 的过程会调用 createElm 创建元素节点,回顾一下 createElm 的实现 源码:src/core/vdom/patch.js

function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    /* 判断是否是组件 */
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      return
    }

看一下createComponent的实现:

function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
    let i = vnode.data
    if (isDef(i)) {
      const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        i(vnode, false /* hydrating */)
      }
      if (isDef(vnode.componentInstance)) {
        initComponent(vnode, insertedVnodeQueue)
        insert(parentElm, vnode.elm, refElm)
        if (isTrue(isReactivated)) {
          reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
        }
        return true
      }
    }
  }

我们上边讲到的,vnode.data里边混入了许多钩子函数,而createComponent最后返回一个布尔值,判断当是组件的时候就返回true。我们仔细分析

let i = vnode.data
if (isDef(i)) {
  if (isDef(i = i.hook) && isDef(i = i.init)) {
    i(vnode, false /* hydrating */)
    // ...
  }
}

i如果有hook并且赋值为i.init,那么我们看一下钩子函数init,
源码:src/core/vdom/create-component.js

const componentVNodeHooks = {
  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      )
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
  }
  //...
}

init就是通过createComponentInstanceForVnode创建一个Vue实例,然后调用 $mount 方法挂载子组件

export function createComponentInstanceForVnode (
  vnode: any, // 组件VNode
  parent: any, 
): Component {
  const options: InternalComponentOptions = {
    _isComponent: true,
    _parentVnode: vnode,
    parent
  }
  const inlineTemplate = vnode.data.inlineTemplate
  if (isDef(inlineTemplate)) {
    options.render = inlineTemplate.render
    options.staticRenderFns = inlineTemplate.staticRenderFns
  }
  return new vnode.componentOptions.Ctor(options)
}

内部构建一个内部参数options,_isComponent 为 true 表示它是一个组件,parent 表示当前激活的组件实例
最后执行 new vnode.componentOptions.Ctor(options)。这里的 vnode.componentOptions.Ctor 对应的就是子组件的构造函数,我们上边分析了它实际上是继承于 Vue 的一个构造器 Sub,相当于 new Sub(options)
因此此处是执行子组件实例化的时机,那么就会执行实例化的方法_init,回顾一下系列一的部分:
源码: src/core/instance/init.js

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

可以看到,组件和普通元素的options整合过程是不同的,看一下initInternalComponent

/* 渲染组件的时候整合options */
export function initInternalComponent (vm: Component, options: InternalComponentOptions) {
  const opts = vm.$options = Object.create(vm.constructor.options)
  const parentVnode = options._parentVnode
  opts.parent = options.parent
  opts._parentVnode = parentVnode

  const vnodeComponentOptions = parentVnode.componentOptions
  opts.propsData = vnodeComponentOptions.propsData
  opts._parentListeners = vnodeComponentOptions.listeners
  opts._renderChildren = vnodeComponentOptions.children
  opts._componentTag = vnodeComponentOptions.tag

  if (options.render) {
    opts.render = options.render
    opts.staticRenderFns = options.staticRenderFns
  }
}

这个过程我们重点记住以下几个点即可:opts.parent = options.parent、opts._parentVnode = parentVnode,它们是把之前我们通过 createComponentInstanceForVnode 函数传入的几个参数合并到内部的选项 $options 里了 再来看一下 _init 函数最后执行的代码:

if (vm.$options.el) {
   vm.$mount(vm.$options.el)
}

此处就是挂载组件,会调用 mountComponent 方法,进而执行 vm._render() 方法:

Vue.prototype._render = function (): VNode {
  const vm: Component = this
  const { render, _parentVnode } = vm.$options
  /* 当前组件的父VNode */
  vm.$vnode = _parentVnode
  let vnode
  try {
    /*  当前组件渲染的虚拟node */
    vnode = render.call(vm._renderProxy, vm.$createElement)
  }
  /* 设置当前组件的父组件 */
  vnode.parent = _parentVnode
  return vnode
}

这里的 _parentVnode 就是当前组件的父 VNode,而 render 函数生成的 vnode 当前组件的渲染 vnode,vnode 的 parent 指向了 _parentVnode,也就是 vm.$vnode,它们是一种父子的关系。
我们知道在执行完 vm._render 生成 VNode 后,接下来就要执行 vm._update 去渲染 VNode 了,在系列一中都讲过了。来看一下组件渲染的过程中有哪些需要注意的,vm._update 的定义在 src/core/instance/lifecycle.js 中

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const restoreActiveInstance = setActiveInstance(vm)
    /* 当前组件虚拟dom */
    vm._vnode = vnode
    
    if (!prevVnode) {
      /* 初始化渲染 */
      vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
    } else {
      /* 更新阶段 */
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
    /* 可以看出vm.$vnode是vm._vnode的父组件 */
    if (vm.$vnode && vm.$parent && vm.$vnode === vm.$parent._vnode) {
      vm.$parent.$el = vm.$el
    }
  }

调用vm.__patch__生成真实节点,实际上调用了patch

function patch (oldVnode, vnode, hydrating, removeOnly) {
  // ...
  let isInitialPatch = false
  const insertedVnodeQueue = []

  if (isUndef(oldVnode)) {
    isInitialPatch = true
    createElm(vnode, insertedVnodeQueue)
  }
}

又回到了刚开始的过程,之前分析过负责渲染成 DOM 的函数是 createElm;
源码:src/core/vdom/patch.js

/* 将虚拟节点创建成真实节点,并插入到它的父节点中 */
  function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    /*  createComponent创建子组件,当前返回值为false */
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      return
    }

    const data = vnode.data
    const children = vnode.children
    const tag = vnode.tag
    if (isDef(tag)) {
      /* 调用平台 DOM 的操作去创建一个占位符元素 */
      vnode.elm = vnode.ns
        ? nodeOps.createElementNS(vnode.ns, tag)
        : nodeOps.createElement(tag, vnode)
      setScope(vnode)
    } else if (isTrue(vnode.isComment)) {
      vnode.elm = nodeOps.createComment(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    } else {
      vnode.elm = nodeOps.createTextNode(vnode.text)
      insert(parentElm, vnode.elm, refElm)
    }
  }

注意,这里我们传入的 vnode 是组件渲染的 vnode,也就是我们之前说的 vm._vnode,如果组件的根节点是个普通元素,那么 vm._vnode 也是普通的 vnode,这里 createComponent(vnode, insertedVnodeQueue, parentElm, refElm) 的返回值是 false。接下来的过程就系列一的步骤一样了,先创建一个父节点占位符,然后再遍历所有子 VNode 递归调用 createElm,在遍历的过程中,如果遇到子 VNode 是一个组件的 VNode,则重复本篇文章开始的过程,这样通过一个递归的方式就可以完整地构建了整个组件树