手写Vue2源码(十)—— 组件原理

461 阅读5分钟

前言

通过手写Vue2源码,更深入了解Vue;

在项目开发过程,一步一步实现Vue核心功能,我会将不同功能放到不同分支,方便查阅;

另外我会编写一些开发文档,阐述编码细节及实现思路;

源码地址:手写Vue2源码

组件原理

组件分为全局组件和局部组件;全局组件通过Vue.component()进行注册,在任何地方都可以直接使用;局部组件定义在父组件内部,在父组件中可用。

// 全局组件
Vue.component("parent-component", {
    template: `<div>我是全局组件</div>`,
});
let vm = new Vue({
    el: "#root",
    data() {
        return {
        obj1: {
            a: {
            a: 4,
            },
        },
        arr1: [1, 2, [4, 5]],
        number1: 2,
        firstName: "shi",
        lastName: "deshan",
        };
    },
    template: `<div>hello 这是我自己写的Vue{{number1}}<parent-component></parent-component><child-component></child-component></div>`,
    // 局部组件
    components: {
        "child-component": {
            template: `<div>我是局部组件</div>`,
        },
    },
});

有几个问题需要思考一下:

  1. Vue.component()是什么?为何在其他地方可以使用全局注册的组件?
  2. 组件内的局部组件是如何渲染的?

Vue.component(id,definition)

// src/global-api/index.js
import initMixin from './mixin'
import { ASSETS_TYPE } from './const'
import initExtend from './extend'
import initAssetRegisters from './assets'

export function initGlobalApi(Vue) {
  // 每个组件初始化的时候都会和Vue.options选项进行合并
  Vue.options = {} // 用来存放全局属性,例如Vue.component、Vue.filter、Vue.directive
  // 注册 Vue.mixin()方法
  initMixin(Vue)

  // 初始化Vue.options.components、Vue.options.directives、Vue.options.filters 为空对象
  ASSETS_TYPE.forEach((type) => {
    Vue.options[type + 's'] = {}
  })

  // Vue.options会与组件的options合并,所以无论创建多少子类,都可以通过实例的options._base找到Vue
  Vue.options._base = Vue

  // 注册Vue.extend()方法
  initExtend(Vue)

  // 注册Vue.component()、Vue.filter()、Vue.directive()方法
  initAssetRegisters(Vue)
}
// src/global-api/assets.js
import { ASSETS_TYPE } from './const'
export default function initAssetRegisters(Vue) {
  ASSETS_TYPE.forEach((type) => {
    // name 为组件名/filter名/自定义指令名
    // definition为配置项
    Vue[type] = function (name, definition) {
      if (type === 'component') {
        // Vue.component(name,definition) 就是调用 Vue.extend(definition),然后调用下方的指令,赋值 Vue.options.components[name] = definition
        definition = this.options._base.extend(definition)
      } else if (type === 'filter') {
        // 略
      } else if (type === 'directive') {
        // 略
      }

      this.options[type + 's'][name] = definition
    }
  })
}

可以看到 Vue.component(id,definition)做了两件事:

  1. 执行Vue.extend(definition)
  2. 将执行结果赋值给Vue.options.components.name;根据 上一篇混入原理 我们知道,组件实例执行_init()时,会把父类的options与自身的options进行合并,所以Vue.options.components会与自身options进行合并

为了实现在任何地方都可以使用全局组件,并且如果当前组件存在该组件,则直接调用自身的components[key],否则使用原型上的components[key];我们考虑在对components进行合并时,采用原型继承的方式:

根据 上一篇混入原理 可知,options生命周期的合并我们采用的是策略模式,所以直接扩展三种策略来合并components、directives、filters:

// src/util/index.js
const ASSETS_TYPE = ["component", "directive", "filter"];
ASSETS_TYPE.forEach((type) => {
  strategies[type + 's'] = mergeAssets
})
// components、directives、filters的合并策略是一致的
function mergeAssets(parentVal, childVal) {
  // 采用原型继承
  const res = Object.create(parentVal)
  if (childVal) {
    // childVal对res中的同名属性进行覆盖
    for (const key in childVal) {
      res[k] = childVal[k]
    }
  }
  return res
}

Vue.extend()

Vue.component(id,definition) 做的另一件事是 Vue.extend(definition)

Vue.extend() 的用法:

// 创建构造器 Profile
var Profile = Vue.extend(options)
// 创建 Profile 实例,并挂载到一个元素上。 
new Profile().$mount('#app')

可知Vue.extend()的结果是一个构造函数,通过new创建组件实例。 实现方式如下:

// src/global-api/extend.js
export default function initExtend(Vue) {
  let cid = 0

  /**
   * Vue.extend流程分析:
   * 1. 创建一个继承自Vue的子类
   * 2. 将子类的extendOptions与Vue.options合并
   * 3. 在子类中调用this._init(options),该方法会在子类实例化时调用,进行实例的数据响应式和页面渲染
   */
  Vue.extend = function (extendOptions) {
    // 这里的options是在使用Sub创建组件实例时需要传入的options
    const Sub = function VueComponent(options) {
      this._init(options) // 这里的this指向Sub的实例
    }
    Sub.cid = cid++

    // 为什么要继承Vue?为了可以使用Vue原型上所有的方法
    Sub.prototype = Object.create(this.prototype) // 这里的this指向Vue
    Sub.prototype.constructor = Sub

    Sub.options = mergeOptions(this.options, extendOptions)
    return Sub
  }
}

Vue.extend(definition)做的事情:

  1. 创建一个继承自Vue的子类
  2. 在子类中调用 this._init(options),实例化时调用
  3. 将传入的 extendOptions 与Vue.options进行合并
  4. 返回该子类

Vue.component(name,definition)本质也是创建一个子类,用于创建组件实例;再梳理一下它的流程:

  1. 创建了一个继承于Vue的子类Sub
  2. 子类中调用 this._init(options)
  3. 将definition与Vue.options进行合并,结果放到子类Sub.options
  4. 返回该子类
  5. 赋值 Vue.options.components.name 为 definition
  6. 当其他组件调用_init()方法的时候,会将该组件的options.components与Vue.options.components进行合并(components的合并采用的是策略模式 + 继承,具体合并方式见上文),所以可以在任何地方使用全局组件

组件的渲染

流程分析:

  1. 基于 new Vue 给根组件创建一个Vue的实例,
  2. 开始解析根组件,生成VNode;在生成VNode的过程中,对于组件特殊处理:在data上添加一个hook属性,详情见下文。
  3. 基于VNode,创建真实DOM:
    1. 创建真实DOM的过程中,如果遇到组件标签,特殊处理:
      1. 调用createComponent(vnode) —— 执行data.hook.init(vnode) —— 实例化components[key],执行 child.$mount() 生成真实dom,赋值到虚拟节点的vm.$el
      2. 将组件标签的 $el 插入到父容器(父组件)中
    2. 渲染完成整个DOM

在执行vm._render()创建VNode时,特殊处理组件元素:

// src/vdom/index.js
export function createElement(vm, tag, data = {}, ...children) {
  let key = data.key;
  // 如果是普通标签
  if (isReservedTag(tag)) {
    return new Vnode(tag, data, key, children);
  } else {
    // 否则就是组件
    // components[tag]可能函数或对象
    let Ctor = vm.$options.components[tag]; // 获取组件的构造函数
    return createComponent(vm, tag, data, key, children, Ctor);
  }
}
function createComponent(vm, tag, data, key, children, Ctor) {
  // Ctor如果是局部组件,则为一个对象;如果是全局组件(Vue.component创建的),则为一个构造函数
  // 将局部组件,调用Vue.extend(Ctor)创建一个子类
  if (isObject(Ctor)) {
    Ctor = vm.$options._base.extend(Ctor);
  }

  // 【关键】等会创建组件真实DOM时,需要调用此初始化方法
  data.hook = {
    init(vnode) {
      // new Ctor()相当于执行new Vue.extend(),即相当于new Sub;则组件会将自己的配置与{ _isComponent: true }合并
      let child = (vnode.componentInstance = new Ctor({ _isComponent: true })); // 实例化组件
      // 因为没有传入el属性,需要手动挂载,为了在组件实例上面增加$el方法可用于生成组件的真实渲染节点
      child.$mount(); // 组件挂载后会在vm上添加vm.$el 真实dom节点
    },
  };
  // 组件vnode也叫占位符vnode  ==> $vnode
  return new Vnode(
    `vue-component-${Ctor.cid}-${tag}`,
    data,
    key,
    undefined,
    undefined,
    {
      Ctor,
      children,
    }
  );
}

组件元素生成真实DOM:

// src/vdom/patch.js
export function patch(oldVnode, vnode, vm) {
  // 如果没有vm.$el,也没有oldVnode,及第一次渲染组件元素
  if (!oldVnode) {
    // 组件的创建过程是没有el属性的
    return createElm(vnode);
  } else {
    // 生成真实DOM
    const el = createElm(vnode);
    // 插入dom
    parentElm.insertBefore(el, oldElm.nextSibling);
    // 删除老的dom
    parentElm.removeChild(oldVnode);
    return el;
  }
}
// 虚拟dom转成真实dom
function createElm(vnode) {
  const { tag, data, key, children, text } = vnode;
  // 判断虚拟dom 是元素节点、自定义组件 还是文本节点(文本节点tag为undefined)
  if (typeof tag === "string") {
    // 如果是组件,返回组件渲染的真实dom
    if (createComponent(vnode)) {
      return vnode.componentInstance.$el;
    }

    // 否则是元素
    // 虚拟dom的el属性指向真实dom,方便后续更新diff算法操作
    vnode.el = document.createElement(tag);
    // 解析vnode属性
    updateProperties(vnode);
    // 如果有子节点就递归插入到父节点里面
    children.forEach((child) => {
      return vnode.el.appendChild(createElm(child));    // 递归创建子节点的真实dom(子节点可能包含组件元素)
    });
  } else {
    // 否则是文本节点
    vnode.el = document.createTextNode(text);
  }
  return vnode.el;
}
// 创建组件的实例,并执行实例的$mount()
function createComponent(vnode) {
  // 初始化组件,创建组件实例
  let i = vnode.data;
  // 相当于执行 vnode.data.hook.init(vnode)
  if ((i = i.hook) && (i = i.init)) {
    i(vnode);
  }
  // 如果组件实例化完毕,有componentInstance属性,那证明是组件
  if (vnode.componentInstance) {
    return true;
  }
}

整体流程:

  1. 根组件的$mount(el)
  2. patch(el, rootVnode)
  3. createElm(rootVnode)
  4. 对于rootVnode的children遍历调用createElm(childVnode),将结果append到rootVnode.el(最后会将rootVnode.el渲染到页面)
  5. 在遍历children过程中,当对自定义组件使用createElm(childVnode)时,调用vnode.data.hook.init(vnode)data.hook是在渲染成VNode时针对组件元素特殊处理的);创建该子类的一个实例(创建实例的时候会执行子类的 _init()方法,合并options,以及在vm上添加$options属性),然后手动调用该实例的child.$mount()vm._update()中,会将当前的vnode添加到vm._vnode属性上,还会将生成的真实dom添加到vm.$el
  6. 将child的template编译成render函数,创建vnode,渲染成真实DOM(此过程,因为调用了$mountmountComponent(),所以子组件中的数据会收集子组件的渲染watcher)
  7. 渲染完所有children后,将根节点的真实DOM渲染到页面

小结

  1. 每个组件都是一个vue实例(由上文可知,局部组件在创建成Vnode时也是使用的Vue.extend());
    1. 根组件是Vue的实例
    2. 其他组件:child = new Ctor({ _isComponent: true }); 其中Ctor = Vue.extend(vm.$options.components[tag])
  2. 在解析Vnode时如果遇到组件元素,则生成子组件的真实DOM(创建Vue的子类与子类的实例,调用实例_init()$mount方法,具有与根组件完全一致的数据劫持、响应式、模板编译、计算属性、侦听属性等功能)。

系列文章