Skip to content

组件挂载原理Vue.componentcomponents

前言

组件是 Vue 中的一大核心, 组件有自己的构造函数, 且是通过 extends 继承自父组件, 我们平时开发中定义组件都是以.vue文件来进行定义, 在打包工具的帮助下, 会使用vue-loader(webpack 中)来将.vue文件编译成为 render 函数, 这里手写的是运行时中编译的版本, 但是原理时一样的

组件分为全局组件和私有组件, 全局组件顾名思义可以在不引入的情况下在任何组件中使用, 但是私有组件的使用需要在父组件中引入才行, 底层的原理就是上一篇的Vue.extends, 在一个父组件中使用了一个私有组件的时候, 会现在自己的 components 中找, 找不到会沿着原型链往全局组件中查找

接下来就来实现组件从定义到挂载的过程

accessType 抽离维护

Vue中有一些全局的东西是通过Vue.xxx来定义的, 除了Vue.component之外, 还有Vue.filter, Vue.directive等, 因此可以通过循环在 Vue 身上进行拓展, 为了方便维护, 会将这些静态方法的名字当都定义维护

js
// const.js
export const ACCESS_TYPE = ["component", "filter", "directive"];

然后再在引入使用

js
// globalApiMixin/index.js
import { mergeOptions, nextTick } from "../utils/index";
import { initExtendsMixin } from "./extend";
import { initAccessMixin } from "./access"; 
import { ACCESS_TYPE } from "./const"; 

// 初始化全局Api
export function initGlobalApiMixin(Vue) {
  // 存放全局API
  Vue.options = {};

  Vue.mixin = function (mixin) {
    this.options = mergeOptions(this.options, mixin);
  };

  //  [!code ++] // 后面需要将全局的component, filter, directive都统一放到Vue.options.components Vue.options.filters Vue.options.directives中
  ACCESS_TYPE.forEach((type) => {
    Vue.options[type + "s"] = Object.create(null); 
  }); 

  // 将Vue保存到_base属性中, 便于后续能拿到
  Vue.options._base = Vue; 
  // Vue继承 Vue.extends()
  initExtendsMixin(Vue);
  // 初始化全局component filter directive
  initAccessMixin(Vue); 

  Vue.nextTick = nextTick;
}

注意, 上面也同样已经将Vue.component,Vue.filter, Vue.directive的定义抽离出去, 保证主流程干净

initAccessMixin

Vue.component,Vue.filter, Vue.directive都是函数, 且都有两个参数, 第一个参数为名称, 第二个参数为具体的配置, 因此通过循环来定义

js
// globalApiMixin/access.js
import { ACCESS_TYPE } from "./const";

export function initAccessMixin(Vue) {
  // 循环定义`Vue.component`,`Vue.filter`, `Vue.directive` 方法
  ACCESS_TYPE.forEach((type) => {
    Vue[type] = function (id, definition) {
      if (type === "component") {
        // 将全局定义的组件变成一个构造函数
        definition = Vue.options._base.extends(definition);
      } else if (type === "filter") {
        // Todo:过滤器
      } else if (type === "directive") {
        // Todo:指令
      }

      // 最终将组件的构造函数放到Vue.options.components中, key就是组件定义时候的名称, 便于查找匹配
      Vue.options[type + "s"][id] = definition;
    };
  });
}

提示

Vue.component为定义全局组件, 传入的参数为一个选择对象, 相当于new Vue(options)时候传入的options对象, 因此需要通过Vue.extends将传入的对象变成Vue的子类, 也就是子组件, 但是这里没有用Vue.extends,而是用Vue.options._base.extends(definition), 原因是传入的Vue有可能是父组件, 继承应该继承的是根组件

mergeOptions中组件合并策略

组件继承用的核心原理是extends方法, 在extends中有一段逻辑是合并选项的

js
Sub.options = mergeOptions(Super.options, extendsOption);

目的是合并子组件与 Vue 的一些默认配置, 但我们这里正在处理的component, 目前在mergeOptions中并没有关于mergeComponents的策略, 因此会走到默认策略, 默认策略遇到同名的组件会进行覆盖, 但我们不希望覆盖, 而是两者都保留, 不仅仅是components, filters, directives也一样

js
// mergeOptions
import { ACCESS_TYPE } from "../globalApiMixin/const";

// .....

// merge components filters  directives
ACCESS_TYPE.forEach((type) => {
  strategy[type + "s"] = mergeAccess;
});
// 合并components filters directives策略
function mergeAccess(oldComp, newComp) {
  // 等价于newComp.__proto__ = Vue.options._base.prototype
  const res = Object.create(oldComp);
  if (newComp) {
    for (const k in newComp) {
      res[k] = newComp[k];
    }
  }
  return res;
}

createComponentVNode 创建组件的虚拟DOM

到这里, 全局组件的初始化已经完成, 若此时在template中使用了一个全局组件,或者一个局部组件, 例如<my-component></my-component>,在模板解析为AST后, 接着会通过generate方法得到一个render函数, 接着会创建一个Watcher, 创建Watcher过程会执行render函数, 会得到虚拟DOM, 在这个过程中, 由于组件标签并不是一个原生HTML标签名, 是无法创建真正DOM元素的, 因此在创建虚拟DOM的时候, 就需要将组件当初处理, 创建组件的虚拟DOM

创建元素节点虚拟DOM的方法为_c

js
// render.js
  // 创建虚拟DOM
  Vue.prototype._c = function(...args) {
    const [tag, data = {}, ...children] = args || [];
    const key = data.key;
    if (key) {
      delete data.key;
    }
    // [!code ++] // tag不一定就是原生html标签, 也有可能是自定义组件
    // [!code ++] // 若是Html元素标签名, 则创建虚元素虚拟节点
    if (isHtmlTag(tag)) {
      // 创建元素节点
      return new VNode(tag, data, key, children, undefined);
    } else {
      // [!code ++] // 否则就是自定已组件, 创建组件虚拟DOM
      const vm = this;
      // [!code ++] // 首先拿到组件的定义
      const Ctor = vm.$options.components[tag];
      return createComponentVNode(this, tag, data, key, children, Ctor);
    }
  };

判断是tag否为合法的Html标签名称

js
export function makeMap(str) {
  const map = str.split(",").reduce((pre, cur) => {
    pre[cur.toLocaleLowerCase()] = true;
    return pre;
  }, {});
  return s => map[s.toLocaleLowerCase()];
}

export const isHtmlTag = makeMap(
  "html,body,base,head,link,meta,style,title," +
    "address,article,aside,footer,header,h1,h2,h3,h4,h5,h6,hgroup,nav,section," +
    "div,dd,dl,dt,figcaption,figure,picture,hr,img,li,main,ol,p,pre,ul," +
    "a,b,abbr,bdi,bdo,br,cite,code,data,dfn,em,i,kbd,mark,q,rp,rt,rtc,ruby," +
    "s,samp,small,span,strong,sub,sup,time,u,var,wbr,area,audio,map,track,video," +
    "embed,object,param,source,canvas,script,noscript,del,ins," +
    "caption,col,colgroup,table,thead,tbody,td,th,tr," +
    "button,datalist,fieldset,form,input,label,legend,meter,optgroup,option," +
    "output,progress,select,textarea," +
    "details,dialog,menu,menuitem,summary," +
    "content,element,shadow,template,blockquote,iframe,tfoot"
);
js
// 创建组件虚拟节点
function createComponentVNode(vm, tag, data, key, children, Ctor) {
  // 在执行_render函数时候,用户在实例内部传入的components还是一个对象,
  // 只有全局组件会自动调用Vue.extends()变成Vue的子类
  if (isObject(Ctor)) {
    // 由于在 _init中做了mergeOptions, 将Vue.constructor与new Vue时传入的options做了合并,
    // 并且一起赋值给了vm.$options, 所以vm.$options能取到_base
    Ctor = vm.$options._base.extends(Ctor);
  }

  /* 
    为组件添加一些特有的勾子函数, 便于后面创建元素的时候直接使用,而不用去写一堆逻辑
    这些钩子函数是组件内部特有的, 不会被用户使用
  */
  data.hooks = {
    // 组件初始的钩子, 调用的时候需要传入虚拟DOM
    init(vnode) {
      // 实例化组件, 并将组件实例(类似与vm)放到虚拟DOM的componentInstance上面, 后面能很方便的使用
      // 组件实例化时候, 同new Vue 一样, 会自动调用_init方法,
      const child = (vnode.componentInstance = new Ctor({
        _isComponent: true
      }));

    // 由于组件没有$el, 因此在_init方法中不会自动执行$mount方法, 这里需要手动调用
    // 且斯奥用$mount时候不能传入el,因为没有
    // $mount在执行的时候, 会为组件创建一个watcher, 因此每一个组件都有自己的watcher, 创建watcher时候, 会执行vm._update(vm._render());
    // 最终执行vm.$el = patch(vm, vm.$el, vnode); 注意这里传入的vm.$el为 undefined, 最终将组件的DOM返回挂到组件实例的$el上
      child.$mount()
    }
  };

  // 每次执行Vue.extends都会生成一个唯一的ID
  // 页面上多次使用同一个组件, 会多次创建这个组件的类(父组件的子类), 所以, 每一个组件都会有自己的watcher
  const cid = Ctor.cid;
  // 这里需要注意, 组件的变迁之间出现的内容不是children, 而是插槽,这里暂时不考虑TODO:
  // 组件名不会重复 tag就是我们在模板中使用组件时候定义的组件tag名(这里未作驼峰与中横线的自动转换)
  return new VNode(`vue-component-${cid}-${tag}`, data, key, undefined, {
    Ctor,
    children
  });
}

组件虚拟DOM

创建组件虚拟DOM时候, 会对组件名称tag进行重写, 加上统一前缀vue-component-, 在以组件的唯一ID加组件原有名字组合

组件没有children属性, 只有插槽

createComponent

上一步创建完虚拟DOM, 现在需要将虚拟DOM渲染为真实DOM了, 也就是_patch方法中

提示

组件没有el属性, 因此首次首次挂载的时候没有oldvnode, 可以根据这个来区分是组件还是dom元素

js
// patch.js
import { createEle } from "./create-element";

// 通过虚拟DOM创建真实DOM,并且更新到页面上
export function patch(vm, oldvnode, vnode) {
  if (!oldvnode) {
    // [!code ++] // 组件没有el, 因此在挂载阶段oldvnode为undefined
    return createEle(vm, vnode);// [!code ++] // vnode为组件内部的虚拟DOM
  }

  // 这里需要区分是初始渲染还是二次更新,
  // 初次渲染的时候oldvnode是真是DOM元素,
  // 二次跟新oldvnode是虚拟Dom, 会涉及到diff
  if (oldvnode.nodeType) {
    // 初次渲染
    const el = createEle(vm, vnode); // 通过虚拟dom创建真实dom
    const parentNode = oldvnode.parentNode; // 拿到oldDom节点的父级节点
    parentNode.insertBefore(el, oldvnode); // 在旧节点前面插入新节点
    parentNode.removeChild(oldvnode); // 删除旧节点
    return el;
  } else {
    // TODO:二次更新
  }
}

创建组件

js
export function createEle(vm, vnode) {
  const { tag, data = {}, text, children } = vnode;
  // 是元素节点
  if (tag) {
    // TODO: 区分是组件
    if (createComponent(vnode)) {// [!code ++] // 调用创建组件的方法, 若是组件会创建组件DOM并返回true
    // [!code ++]  // 如果是组件的话, 需要返回创建好的组件DOM, 这是在组件挂载的时候创建好的(_c方法的组件hooks钩子中)
      return vnode.componentInstance.$el;
    }
    //
    // 创建元素
    vnode.el = document.createElement(tag); // 将真实元素与虚拟DOM进行关联, 方便后续diff更新
    // 添加属性
    for (const key in data) {
      let value = data[key];
      if (value && typeof value === "object") {
        value = Object.keys(value).map(k => `${k}:${value[k]}`).join(";");
      }
      vnode.el.setAttribute(key, value);
    }
    // 递归创建
    if (children && !singleTag.includes(tag) /* 单标签没有children */) {
      children.forEach(child => {
        vnode.el.appendChild(createEle(vm, child));
      });
    }
  } else {
    // 是文本节点, 则创建文本元素
    vnode.el = document.createTextNode(text);
  }

  // 递归创建的时候需要有返回值, 上面有 vnode.el.appendChild
  // 因此返回值只能是一个element
  return vnode.el;
}

真实的创建组件DOM节点方法, 其实就是调用组件的内部钩子函数 init(vnode)

js
// createComponent
function createComponent(vnode) {
  let i = vnode.data;
  // 这个写法很骚气, 值得借鉴, 等价于vnode.data.hooks.init(), 
  // 但是中间做了if条件分支的判断与赋值合二为一, 很精炼
  if ((i = i.hooks) && (i = i.init)) {
    i(vnode);
  }

  // 若是组件, 那么会通过上面的 init(vnode)方法将组件内的模板解析为
  // ast -> render -> vnode -> DOM -> vnode.componentInstance.$el=DOM
  if (vnode.componentInstance) return true;
}

到这里, 页面上已经能看到组件内容了

小结

Vue中的组件, 不管是全局组件还是私有组件, 核心原理都是Vue.extends, 通过继承Vue来将组件的选项变成Vue的子组件

在组件在模板中使用的时候, 会进行组件实例化, 接着解析组件模板, 创建组件的真实DOM节点替换组件标签, 每一个组件都是一个Copy Vue, 从宏观上看, Vue本身就是一个组件, 称为根组件

在手写Vue组件挂载流程的过程中, 一些小技非常多, 同时这种封装的思路非常值得我们去学习借鉴