【手写 Vue2.x 源码】第三十八篇 - 组件部分 - 创建组件虚拟节点

393 阅读8分钟

一,前言

上篇,介绍了组件部分-组件合并的实现,主要涉及以下几个点:

  • 组件注册流程梳理;
  • 组件合并的分析;
    • 组件合并的必要性;
    • 组件合并的问题;
  • 组件合并的实现;
    • 生命周期的合并策略-策略模式回顾;
    • 组件合并策略的实现;
  • 组件合并的测试;

本篇,组件部分-组件的编译;


二,前文回顾

1,组件初始化流程回顾

  • 通过 Vue.component 全局 API,声明全局组件;
  • Vue.component 内部,通过 Vue.extend 生成组件的构造函数;
  • 将组件的构造函数维护到全局对象 Vue.options.components 中备用;
  • new Vue 初始化时,在 mergeOptions 方法中,使用策略模式找到预置的组件合并策略,完成全局组件和局部组件的合并操作;
// 全局定义的内容,会被混合在当前实例上
vm.$options = mergeOptions(vm.constructor.options, options);

组件的查找规则:优先查找局部组件,若找不到,则继续沿着链查找全局组件;

至此,在 vm.$options 中,就已经完成了组件关系的构建;

接下来,就要开始做组件模板解析->render 函数->虚拟节点->真实节点...

2,模板解析流程回顾

  • 第一步,解析 html 模板生成 AST 语法树;
  • 第二步,根据 AST 语法树生成 render 函数;
  • 第三步,在 render 函数中,调用 _c 处理标签 tag(即 createElement 方法),生成元素标签的虚拟节点 vnode

此时,在 _c 方法中处理的标签 tag,可能是元素,也可能是组件;

这里,就需要对tag为组件的情况进行扩展,最终要生成组件的虚拟节点;


三,组件的编译流程分析

以一个简单的组件为例,组件的编译过程与模板的编译过程相似:

<div id="app">
    <my-button></my-button>
</div>
  • 第一步,解析组件的 html 模板生成 AST 语法树;
  • 第二步,根据 AST 语法树生成 render 函数;
  • 第三步,在 render 函数中,通过 _c 处理组件 tag,生成组件的虚拟节点 componentVnode

组件编译与标签编译的区别在于:

  • 标签编译:需要生成标签的虚拟节点;
  • 组件编译:需要生成组件的虚拟节点;

因此,需要对原 createElement 方法进行扩展,使之能够支持组件的编译,生成组件的虚拟节点;


四,创建组件的虚拟节点

1,扩展 createElement 方法

原先的 createElement 方法:生成标签元素的虚拟节点 vnode

// 参数:_c('标签', {属性}, ...儿子)
export function createElement(vm, tag, data={}, ...children) {

  // 返回元素的虚拟节点(元素没有文本)
  return vnode(vm, tag, data, children, data.key, undefined);
}

当前,由于组件的加入,在createElement 方法中,tag 不一定是元素,还有可能是组件,所以需要对组件需要单独进行处理:

export function createElement(vm, tag, data={}, ...children) {

  console.log(tag);
  // 添加 tag 为组件时的处理,创建组件的虚拟节点 componentVnode
  // todo ...
  
  return vnode(vm, tag, data, children, data.key, undefined);
}

image.png 控制台日志共输出 2 次:

  • 第一次:my-button(组件)
  • 第二次:div(元素)

因此,需要对 createElement 方法进行扩展:增加 tag 为组件时的处理逻辑,创建对应组件的虚拟节点 componentVnode;

那么,如何区分当前 tag 是组件还是元素呢?

2,判断当前 tag 是组件 or 元素?

判断依据:tag 是否属于原始标签(普通标签);

  • tag 属于原始标签,则 tag 为元素,如:div
  • tag 不属于原始标签,则 tag 为组件, 如:my-button

实现方案:将所有的普通标签统一维护起来,组成以逗号分割的字符串,检查当前 tag 是否包含在其中,若不包含则判定为组件,反之为元素;

创建 isReservedTag(tag) 方法,判断是否为原始标签:

// 添加 tag 为组件时的处理逻辑,创建出组件的虚拟节点
export function createElement(vm, tag, data={}, ...children) {

  // 判断是组件 or 元素节点;判断依据:是否属于普通标签;
  if (!isReservedTag(tag)) {// 组件:非普通标签即为组件
    // 创建组件的虚拟节点
    return createComponent(vm, tag, data, children, data.key, Ctor);
  }
  
  // 创建元素的虚拟节点
  return vnode(vm, tag, data, children, data.key, undefined);
}

/**
 * 创建组件的虚拟节点 componentVnode
 */
function createComponent(vm, tag, data, children, key, Ctor) {
    // todo...
}

// 判定包含关系
function makeMap(str) {
  let tagList = str.split(',');
  return function (tagName) {
    return tagList.includes(tagName);
  }
}

// 原始标签
export const isReservedTag = makeMap(
  'template,script,style,element,content,slot,link,meta,svg,view,button,' +
  'a,div,img,image,text,span,input,switch,textarea,spinner,select,' +
  'slider,slider-neighbor,indicator,canvas,' +
  'list,cell,header,loading,loading-indicator,refresh,scrollable,scroller,' +
  'video,web,embed,tabbar,tabheader,datepicker,timepicker,marquee,countdown'
)

接下来,实现 componentVnode 方法:创建组件的虚拟节点;

3,createComponent 方法定义

“获取对应组件的构造函数,并创建组件的虚拟节点”:

  • 通过 vm.$options.components 获取对应组件的构造函数;
  • 通过 createComponent 方法创建组件的虚拟节点;
// 添加 tag 为组件时的处理逻辑,创建出组件的虚拟节点
export function createElement(vm, tag, data={}, ...children) {

  if (!isReservedTag(tag)) {// 当前 tag 为组件
  
    // 获取组件的构造函数: 之前已保存在全局 vm.$options.components 上
    let Ctor = vm.$options.components[tag];
    
    // 创建组件的虚拟节点
    return createComponent(vm, tag, data, children, data.key, Ctor);
  }
  
  // 创建元素的虚拟节点
  return vnode(vm, tag, data, children, data.key, undefined);
}

/**
 * 创建组件的虚拟节点 componentVnode
 */
function createComponent(vm, tag, data, children, key, Ctor) {
    let componentVnode;
    // todo...
    return componentVnode;
}

组件构造函数 Ctor 的取值过程:

  • 首先,先到 vm.$options.components 对象上查找局部组件,如果找到了 Ctor 会是一个对象;(因为局部组件在定义时,不会被 Vue.extend 处理成为组件构造函数)
  • 如果没找到,则会继续沿着链查找全局组件,如果找到了 Ctor 会是一个函数;(因为全局组件在定义时,内部会通过 Vue.extend 处理成为组件构造函数)

所以,在 createComponent 中,当 Ctor 为对象时,还需要先通过 Vue.extend 处理为组件的构造函数;

/**
 * 创建组件的虚拟节点 componentVnode
 */
function createComponent(vm, tag, data, children, key, Ctor) {

  if(isObject(Ctor)){
    //  todo:获取 Vue.extend,并将对象处理成为组件的构造函数
  }
  
  let componentVnode;
  
  return componentVnode;
}

问题:在 createComponent 中,如何才能拿到 Vue.extend 方法呢?

4,如何获取到 Vue.extend

为了便于在后续操作中能够快速获取到 Vue.extend 方法:

  • Vue 初始化流程中,初始化全局 API initGlobalAPI 时,将 Vue 整个保存到 Vue.options._base 中;
// src/global-api/index.js

export function initGlobalAPI(Vue) {
  Vue.options = {};
  // 当组件初始化时,会使用 Vue.options 和组件 options 进行合并;
  // 在这个过程中,_base 也会被合并到组件的 options 上;
  // 之后,所有的 vm.$options 就都可以取到 _base 即 Vue;
  // 这样,在任何地方通过 vm.$options._base 都可以拿到 Vue;
  Vue.options._base = Vue;
  
  Vue.options.components = {};
  Vue.extend = function (opt) { }
  Vue.component = function (id, definition) { }
}
  • 当组件初始化时,会将 Vue.options 和组件的 options 进行合并,在这个过程中 _base 也将被合并到组件的 options 上;
  Vue.prototype._init = function (options) {
    const vm = this;
    // 使用 Vue 的 options 和组件自己的options进行合并
    vm.$options = mergeOptions(vm.constructor.options, options);
    initState(vm);
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }

这样一来,所有组件就都能够通过 vm.$options 中的 _base 属性拿到 Vue;

/**
 * 创建组件的虚拟节点 componentVnode
 */
function createComponent(vm, tag, data, children, key, Ctor) {

  if(isObject(Ctor)){
    // 获取 Vue,并通过 Vue.extend 将对象处理成为组件的构造函数
    Ctor = vm.$options._base.extend(Ctor)
  }
  
  let componentVnode;
  
  return componentVnode;
}

至此,所有的组件最终都通过 Vue.extend 方法的处理,生成了组件的构造函数:

  • 全局组件:在 Vue.component 内部就被 Vue.extend 处理;
  • 局部组件:在 createComponent 创建组件虚拟节点时,才被 Vue.extend 处理;

问题分析:

为什么使用 vm.$options._base,而不是用 vm.constructor_base?

1,当 vm 为组件的实例时,比如 new Profile().$mount()

  • 由于,子类 Sub 继承自 Vue,所以,vm 一定是 Sub 的实例;
  • 在子类 Sub 上并没有 extend 方法,即 vm 上也不存在 extend 方法;

此时,vm.constructor 指向的子类 Sub 上没有 extend 方法;

2,vm.$options 一定指向 Vue 而不会是子类

因此,使用 vm.$options._base 确保拿到 Vue,即必定可以得到 extend 方法;

接下来,生成组件的虚拟节点 componentVnode;

5,createComponent 方法实现

扩展 vnode 结构

想要生成组件的虚拟节点,需要对原来的 vnode 结构进行扩展,添加组件选项属性componentOptions

/**
 * 创造组件的虚拟节点 componentVnode
 */
function createComponent(vm, tag, data, children, key, Ctor) {

  if(isObject(Ctor)){
    // 获取 Vue,并通过 Vue.extend 将对象处理成为组件的构造函数
    Ctor = vm.$options._base.extend(Ctor)
  }
  
  // 创建 vnode 时,组件是没有文本的,需要传入 undefined
  let componentVnode = vnode(vm, tag, data, children, key, undefined, Ctor);
  
  return componentVnode;
}

// options:可能是组件的构造函数,也可能是对象
function vnode(vm, tag, data, children, key, text, options) {
  return {
    vm,       // 谁的实例
    tag,      // 标签
    data,     // 数据
    children, // 儿子
    key,      // 标识
    text,     // 文本
    componentOptions: options  // 组件的选项,包含 Ctor 及其他扩展项
  }
}

扩展组件选项 componentOptions

  • 组件没有孩子,“组件的孩子”就是 slot 插槽,所以 children 应该被放入组件选项 componentOptions 中;
  • 当前元素为组件时,data 数据也属于组件,需要被放入组件选项 componentOptions 中;同理,组件的其它属性也应该被放入组件选项中(完整的 componentOptions 包含:Ctor、propsData、listeners、tag、children
function createComponent(vm, tag, data, children, key, Ctor) {

  if(isObject(Ctor)){
    Ctor = vm.$options._base.extend(Ctor)
  }
  
  // 注意:组件没有孩子,组件的孩子就是插槽,所以需要将原本的 children 放到组件的选项中去
  let componentVnode = vnode(vm, tag, data, undefined, key, undefined, {Ctor, children, tag});
  
  return componentVnode;
}

6,测试组件虚拟节点生成

image.png

componentVnode 即为组件的虚拟节点;

其中,在componentOptions 选项中,包含组件的构造函数 Ctor

7,组件的唯一标识

下图来自 Vue2 源码:

image.png

与手写版本对比,在创建 VNode 虚拟节点时确定了组件唯一标识:

组件虚拟节点的唯一标识为:vue-component-cid-name

  • cid:组件实例的唯一标识;
  • name:组件定义中的 name 属性;

同时注意到,在创建 VNode 之前,还有一步installComponentHooks操作,即在 data 上扩展了组件的生命周期钩子函数;

下一篇,介绍组件的生命周期;


五,结尾

本篇,介绍了组件部分-组件的编译,主要涉及以下几部分:

  • 对组件初始化流程、模板解析流程进行回顾;
  • 组件的编译流程分析;
    • 组件html模板 -> AST语法树 -> render函数 -> 组件vnode
  • 扩展 createElement 方法,创建组件虚拟节点;
    • 判断 tag 是组件 or 元素;
    • 获取 Vue.extend 方法,创建组件构造函数;
    • 扩展 vnode 结构,支持组件选项 componentOptions;
    • 扩展组件选项 componentOptions;
    • 生成组件虚拟节点 componentVnode;
    • 测试组件虚拟节点生成;

下一篇,组件部分-组件的生命周期;


维护记录

  • 20230226:
    • 添加了串联上下文的问题;
    • 补充了“组件中的钩子函数”部分;
    • 优化了多处描述,修正语义表达不准确;
    • 更新了文章摘要;
  • 20230308:
    • 重新梳理本篇知识点,重新设计文章结构;
    • 更新文章目录;
      • 重新拆分为三大块:回顾、分析、实现
      • 移除“组件钩子函数”部分(合并到下一篇,组件的生命周期)
    • 更新文章摘要;
  • 20230316:
    • 添加“组件的唯一标识”部分,引出“组件的生命周期”;
    • 添加“使用 vm.$options._base”的问题分析;