Vue原理: Vue是怎么实例化一个组件的?

3,659 阅读8分钟

Hello, 这里是link😋, 看完了原生节点的挂载, 我们来看看Vue是怎么实例化一个组件的.本文中配备的流程图都可以在我的源码项目: vue-core-analyse中拿到哦, 欢迎Star✨

流程图

Vue 组件初始化 (1).png

Demo

我们结合平时组件的使用习惯, 来写两个demo

Vue.component全局注册

全局下注册一个HelloWorld组件

Vue.component('HelloWorld', {
    name: 'HelloWorld',
    template: '<div>{{ msg }}</div>',
    data() {
      return {
        msg: 'Hello-world'
      }
    }
  })

组件内使用

这个其实就是我用脚手架生成的一个文件, 然后在APP.vue 还加了一个HelloWorld组件

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

new Vue({
  el: '#app',
  render: h => h(App)
})
// App.vue
<template>
  <div id="app">
    <h1>普通节点</h1>
    <HelloWorld />
  </div>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
  name: 'App',
  components: {
    HelloWorld
  },
}
</script>
  • 这个树状图表明了它们的关系 Vue 组件初始化.png

Vue.component的原理

首先我们来看看component函数的定义, 他是通过Vuejs文件执行的时候, 通过initAssetRegisters(Vue)函数创建并且赋值到Vue构造函数上的

const ASSET_TYPES = [
  'component',
  'directive',
  'filter'
]
export function initAssetRegisters (Vue) {
  /**
   * 组件函数的初始化地
   */
  ASSET_TYPES.forEach(type => {
    /**
     *  Vue.component
     */
    Vue[type] = function (
      id,
      definition
    ) {
      if (type === 'component' && isPlainObject(definition)) {
        definition.name = definition.name || id // 赋值name
        // 原型链继承大法
        definition = this.options._base.extend(definition)
      }
      this.options[type + 's'][id] = definition
      return definition
    }
  })
}

这里我们略过directivefilter的逻辑, 可以看到当type === 'component'的时候会执行一个函数extend, 并且将组件的data等属性传进去.

我们可把component函数抽出来, 它大概就长这样:

Vue.component = function (id, definition) {
    definition.name = definition.name || id // 赋值name  
    definition = this.options._base.extend(definition) // 原型链继承大法
    
    this.options[type + 's'][id] = definition 
    return definition
}

_base其实就是Vue构造函数本身, 这个我在第一篇文章有提到, 这里其实就是用Vue.extend方法将definition对象转成一个构造函数, 方便后面用到这个组件的时候可以去通过 new 的形式实例化它.

由于非全局组件注册也会用到extend函数, 我们后面再来看它的原理.

组件内使用的原理

我们先来看看脚手架下, 第一个组件App是怎么生成的

import Vue from '../../../vue'
import App from './App.vue'
new Vue({
  el: '#app',
  render: h => h(App)
})

从这里可以看到我们给render函数h传进去了App组件, 这是因为我们整个项目是由webpack构建的, 在项目跑起来以后, 我们所有的*.vue页面都会被vue-loader, 转化为描述当前组件的一个对象 image.png

我在Vue是怎么初始化第一个标签的?梳理了new Vue实例化执行流程, 和h函数的原理, 如果你觉得以下内容有点跳跃性, 建议读一下这篇

new Vue()的执行

当第一个Vue实例化的时候, 按照初始化逻辑执行_init函数, 最终执行$mount函数. 并且Vue会发现我们已经有一个render函数了, 它就会直接使用这个render函数.

Vue实例化过程中, 会创建一个 Watcher 实例, 并且在Watcher通过一个函数来控制组件标签的生成与实现. 第一次会默认执行一次这个updateComponent, 而后续数据发生改变也会执行这个函数, 不过这些是响应式系统的内容了, 我们后续文章再来仔细研究

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

这个函数我们可以分成两部分看vm._render(), vm._update(). 前者负责标签与组件的生成, 后者负责将它们挂载到真实DOM树上


组件Vnode的生成

先来看看 vm._render()函数, 这个函数就是把我们自定义的render函数拿出来执行

  // vm._render()
  Vue.prototype._render = function (): VNode {
    const vm: Component = this
    const { render, _parentVnode } = vm.$options

    // render self
    let vnode  = render.call(vm._renderProxy, vm.$createElement)
    
    // set parent
    vnode.parent = _parentVnode
    return vnode
  }
  • 请注意这个vm.$createElement就是我们 render函数 接收到的 参数h 它做了两件事情:
  1. 执行我们在main.js传入的 render函数
  2. 返回 render函数 生成的虚拟节点VNode

那核心就在于接收到我们传入的App组件的vm.$createElement函数

vm.$createElement函数

h函数_createElement函数之间, 其实还套了几层其他函数, 用于对参数做处理, 但是我们无需关注, 这里我们只需要知道 tag参数 就是 我们在h函数传入的组件App

export function _createElement (
  context: Component, // 当前组件实例
  tag?: string | Class<Component> | Function | Object, // 函数 组件 标签
  data?: VNodeData,
  children?: any,
): VNode | Array<VNode> {
  let vnode, ns
  // tag标签是一个字符串
  if (typeof tag === 'string') {
    let Ctor
    // 这里会判断一下 标签 是否是一个 保留标签
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      // 项目初始化的时候
      vnode = createComponent(Ctor, data, context, children, tag)
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
}

通过图形梳理, 它的逻辑是这样的: 截屏2021-12-30 下午5.33.05.png

现在显然tag是一个组件对象, 所以它往左侧走. 接下来我们来分析createComponent做了什么

createComponent

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  // baseCtor Vue构造函数
  const baseCtor = context.$options._base

  // 原型链继承
  // 组件进来的时候会是一个对象, 需要重新走一遍继承
  // 但是全局注册过得组件就不需要, 初始化的时候已经继承了
  if (isObject(Ctor)) {
    Ctor = baseCtor.extend(Ctor)
  }

  // 注册一些组件管理钩子在占位符节点上
  installComponentHooks(data)

  // 返回一个占位符 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
}

从代码可以看出, 这个函数总共做了三件事情:

  1. 通过函数extendCtor组件对象转化成了Ctor组件构造函数
  2. installComponentHooks(data)挂载内联钩子
  3. 将当前组件构造函数转为Vnode并返回 image.png

extend函数的实现

extend函数实际是通过原型链继承的形式完成了组件函数的生成

重要的代码就3步:

  1. 定义一个Sub函数, 内部执行this._init方法, 也就是我们new Vue()默认执行的_init方法
  2. Sub.prototype = Object.create(Super.prototype)
  3. Sub.prototype.constructor = Sub

这3步就完成了原型链继承的核心

  Vue.cid = 0
  let cid = 1
  Vue.extend = function (extendOptions: Object): Function {
    // 当前组件的 options
    extendOptions = extendOptions || {}
    const Super = this // Vue = _base = this   Super 一般都是 Vue
    const SuperId = Super.cid
    const cachedCtors = extendOptions._Ctor || (extendOptions._Ctor = {})
    // 检查cache
    if (cachedCtors[SuperId]) {
      return cachedCtors[SuperId]
    }
    // 定义Sub函数内部执行_init方法
    const Sub = function VueComponent (options) {
      this._init(options) // ✨
    }
    // 原型链继承
    // 当前组件的构造函数原型指向 Vue的原型 (表明组件构造函数 是通过 Vue 实例化的)
    Sub.prototype = Object.create(Super.prototype) // ✨
    // 当前构造函数的原型 指向 构造函数
    Sub.prototype.constructor = Sub // ✨
    Sub.cid = cid++
    // 合并Vue 和 当前实例的配置.
    // 一般全局注册的组件, 全局混入等  都是通过这个函数合并到子组件内的
    Sub.options = mergeOptions(
      Super.options,
      extendOptions
    )
    Sub['super'] = Super
    // component 等创造组件的函数
    ASSET_TYPES.forEach(function (type) {
      Sub[type] = Super[type]
    })
    // 允许组件引用自己
    if (name) {
      Sub.options.components[name] = Sub
    }

    // 加入cache
    cachedCtors[SuperId] = Sub
    return Sub
  }
}

但是这个函数还有很多值得学习的地方, 比如cachedCtors缓存写法, 能够有效缓存Ctor, 以后同样的组件就不会再走一样的步骤了. 再比如mergeOptions也是Vue全局下复用性很高的函数, 内部通过一个策略模式能过将参数1的各种配置合并到参数2中, 比如全局Mixins, 全局引入的组件都是在这里实现合并的.后面讲到生命周期函数的时候, 我们再来看看这个函数的实现.

installComponentHooks

这个函数做的事情很简单, 就是将四个钩子(init, prepatch, insert, destroy)赋值到Vnode上, 在组件转为真实节点的时候会用到, 我们到时再来看它们的作用


在上图中, 还有一种情况我们没有分析, 就是我们传入的tag是一个字符串的时候, 也有可能是一个组件.

例如: 我们在HTML标签中书写的一个HelloWorld组件(如上面的Demo). 这种情况就会走到图中的右侧情况.

image.png

我们都知道, 模板编译最终会将HTML结构转化为一个render函数, App组件内部也不例外, 他最终会被转化为这样:

image.png

  • 图中有一个_c函数, 实际上这个就是$createElement函数, 只是Vue会实现两个版本, 一个是用户使用的, 而_c则是Vue内部自己使用的.区别在于对子节点的处理方式上, 篇幅问题,就不展开啦.
    vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };

    vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };

可以看到, 我们会给$createElement传入一个字符串的"HelloWorld", 它就会走入到resolveAsset函数这个函数的作用就是在将组件名称转为驼峰式, 首字母大写的形式, 然后去父组件的options找到这个组件的定义, 如果有的话则返回这个构造函数.

我们通过全局注册的组件会通过mergeoptions函数, 将引用拷贝一份赋值给子组件, 所以在子组件内部是可以拿到这个全局注册组件的构造函数.这样在creatComponent函数内部, 就无需再走一次extend函数, 而是直接使用这个构造函数了.当然最终也会返回VNode.

好了, 到这里我们就看完了组件虚拟节点的生成. 我们来总结一下:

  1. createElement函数内部会执行creatComponent函数这个函数通过extend完成原型链继承, 将当前组件转为一个组件构造函数, 如同Vue构造函数一般

  2. 组件也像原生标签一样, 被生成为一个虚拟节点, 我们一般叫做占位符虚拟节点


组件的挂载

我们还是回到这个函数, 上述都是vm.render()做的事情, 它最终返回了一个Vnode, 会通过vm._update()挂载到DOM树上. 现在我们来看看这个函数对于组件会做哪些不同的事情

updateComponent = () => { 
    vm._update(vm._render(), hydrating) 
}

在组件挂载的流程中_update函数只有两个核心操作需要我们关注:

  1. 将组件和原生虚拟节点转为真实节点
  2. 插入到它的父级节点

真实节点的创建

在执行_update函数的时候, 会执行一个creatElm函数 它会做四件事情:

  1. 尝试将当前VNode作为组件创建
  2. 不行的话将Vnode通过原生createElement创建成真实节点
  3. 递归创建子节点
  4. 插入到父级节点

截屏2022-01-08 上午10.33.12.png

  function createElm (
    vnode,              // 当前虚拟节点
    insertedVnodeQueue,
    parentElm,          // 父真实节点
    refElm,             // 节点插入的时候要用到
    nested,             // 创建子节点的时候 这里是 true 用于判断是否是根节点
    ownerArray,
    index
  ) {
    // 尝试将当前VNode作为组件创建
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      return
    }
    // 创建真实节点
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)
    // 递归创建子节点
    createChildren(vnode, children, insertedVnodeQueue)
      // 插入
    insert(parentElm, vnode.elm, refElm)
  }
  • createChildren的实现
  function createChildren (vnode, children, insertedVnodeQueue) {
    for (let i = 0; i < children.length; ++i) {
      // 递归创建子节点
      createElm(children[i], insertedVnodeQueue, vnode.elm, null, true, children, i)
    }
  }

子组件的创建

接下来到我们重点是createComponent, 到这一步的时候, 就进入了组件内部子组件的创建了.

还记得我们组件Vnode在创建的时候, 有一步操作是将4个内联钩子安装到Vnode.data中吗? 对了就在这里用到了. 我们来看看第一个用到钩子init

  function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
      let i = vnode.data

      if (isDef(i = i.hook) && isDef(i = i.init)) { // 这里赋值 init
        i(vnode, false /* hydrating */) 
      }

      /**
       *  1. 组件内原生节点在 creatElm阶段的时候插入了
       *  2. 这时候就是插入到当前组件的上级根节点
       */
      insert(parentElm, vnode.elm, refElm)

      return true
  }

init函数会执行组件VnodeCtor构造函数

init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    const child = vnode.componentInstance = new vnode.componentOptions.Ctor(options)
    // 在这里实现挂载, 全局下的_init 不会走到$mount
    child.$mount(hydrating ? vnode.elm : undefined, hydrating)
},

还记得构造函数的定义吗?

const Sub = function VueComponent (options) {
  this._init(options)
}

这个组件又会执行一次Vue中的_init然后再走一遍像new Vue()的流程, 初始化method, data, 还有上面提到的vm.render() vm._update()去生成自己内部的原生节点, 组件等.

最终通过当组件实例化完毕就会通过insert函数插入到父级节点.

function insert (parent, elm, ref) {
    if (isDef(parent)) {
      if (isDef(ref)) {
        // ref元素 存在的话, 就将其插入到 ref元素 之前
        // 这个元素是真实节点的下一个真实兄弟节点
        // 我猜这么做, 插入位置会更准确?
        if (nodeOps.parentNode(ref) === parent) {
          nodeOps.insertBefore(parent, elm, ref)
        }
      } else {
        // 否则是直接添加到 父元素下
        nodeOps.appendChild(parent, elm)
      }
    }
  }

通过代码看可能比较绕, 我们看看图

截屏2022-01-08 下午1.13.37.png

总结

本质上对于Vue来说每一个组件都是它的子类, 组件实例就是new Vue()的过程, 是一个递归过程. 初次看到这里可能会比较难以理解, 我十分推荐你单步调试, 感受一下.

源码到这里我们就能看出Vue的第一个设计理念.

对于Vue来说, 每一个组件实例化执行_init方法, 都会new一个Watcher实例, 去用于订阅数据变化.

这样做有什么好处? 可以把diff过程限定在组件内部, 而无需从整体去做. 也就无需像React一样由于庞大的计算量而需要提出Fiber架构

感谢😘


如果觉得文章内容对你有帮助: