主题
组件挂载原理Vue.component
或components
前言
组件是 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组件挂载流程的过程中, 一些小技非常多, 同时这种封装的思路非常值得我们去学习借鉴