渲染器的作用是把虚拟DOM渲染为特定平台上的真实元素。在浏览器平台上,渲染器会把虚拟DOM渲染为真实DOM元素。而这一过程,被称为挂载,通常用英文字母mount表示。
function render(vnode, container) {
// 将vnode渲染为真实DOM,然后挂载到container上
}
而渲染器除了要进行渲染操作外,通常还需要执行更新操作。而这个更新操作的具体过程,就是我们耳熟能详的diff函数,这个过程通常也被称为打补丁,也就是英文单词patch。
function render(vnode, container) {
if(vnode) {
// 如果vnode存在,进行更新操作
patch(container._vnode, vnode, container);
} else {
if(container._vnode) {
// 否则,若干container存在node结点,清空contain的内容
}
container._vnode = null;
}
}
在前文中我们提到过,因为虚拟DOM将UI抽象成了对象,因此借助虚拟DOM我们可以很容易的实现跨平台的特性。而渲染系统作为最核心的几个子系统之一,将它设计成可配置的,解耦合是非常有必要的。
function createRenderer(options) {
const {
createElement,
insert,
setElementText,
} = options;
function mountElement(vnode, container) {
// 调用 createElement 函数创建元素
const el = createElement(vnode.type)
if (typeof vnode.children === 'string') {
// 调用 setElementText 设置元素的文本节点
setElementText(el, vnode.children)
}
// 调用 insert 函数将元素插入到容器内
insert(el, container)
}
function patch(n1, n2, container) {
if(!n1) {
mountElement(n2,container);
} else {
// 进行比较修改
}
}
function render(vnode, container) {
if(vnode) {
// 如果vnode存在,进行更新操作
patch(container._vnode, vnode, container);
} else {
if(container._vnode) {
// 否则,若干container存在node结点,清空contain的内容
}
container._vnode = null;
}
}
return {
render,
}
}
const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag);
},
setELementText(el, text) {
el.textContent = text;
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor);
}
})
通常需要渲染的子元素呈一个多叉树结构,而每一个标签都是具有不同属性的,比如form就有特殊的属性“action”,因此,在vnode对象解构中,我们用数组children来表示一个节点的子元素,props来表示一个元素的属性。
const vnode = {
type = 'div',
props: {
id: 'app',
},
children: [
{
type: 'span',
children: 'hellp'
}
]
}
对于以上两者的挂载,我们在mountELement函数中,分别遍历这两个属性。
function mountElement(vnode, container) {
const el = createElement(vnode.type);
if (typeof vnode.children === "string") {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
// 如果当前children属性是一个数组,遍历该数组,然后分别进行挂载
vnode.children.forEach((child) => {
patch(null, child, el);
});
}
if(vnode.props) {
// 如果当前节点具有props属性,遍历props属性,然后挂载props属性
for(const key in vnode.props) {
el[key] = vnode.props[key];
}
}
insert(el, container);
}
顾名思义,HTML Attributes就是定义在HTML标签上的属性,而DOM Properties就是HTML元素对应的DOM对象上持有的属性,事实上,大部分HTML Attributes和DOM properties的同名属性都指代同一个值,但是在这之中还是存在着少部分的差异。比如说,对于class属性,在HTML Attributes中用class描述,而在DOM Properties中,对应的却是calssName;再比如,在Dom Properties中可以使用textContent属性设置元素的内容,而在HTML Attributes中却没有对应的属性。
另外一点,通过HTML Attributes设置的是对应DOM Properties的初始值。
<input value="1">
<script>
const el = document.querySelector("input");
el.value = "2";
console.log(el.getAttribute("value")) // 1
console.log(el.value) // 2
console.log(el.defaultValue) // 1
script>
因为HTML Attributes和DOM Properties都存在一定的问题,所以在进行属性设置的时候,我们需要进行一些特殊的处理。比如说,在设置button的disable属性时,如果我们希望按钮禁用,通常会以这样的形式书写,当使用setAttribute()函数设置属性的时候,它会以setAttribute(‘disable’,‘’)的形式执行,而当书写形式改为
时,setAttribute(‘disable’,‘false’)执行后,button依然会被禁用,因为disable的规则是,只要存在disable属性就禁用,所以这明显违背了用户的本意。但若是直接设置元素的属性,改为
el.disable = false
就可以规避这个问题,但直接设置元素属性这一方法依然存在问题。对于它反而不会禁用button,因为直接设置属性会自动将’'转换位false。而这里就是需要我们手动进行调整的地方了。
function mountElement(vnode, container) {
// 调用 createElement 函数创建元素
const el = createElement(vnode.type);
// 此处省略节点创建
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key];
if (key in el) {
const type = typeof el[key];
if (type === "boolean" && value === "") {
// 如果HTML Attributes的值为boolean 且 vnode.props[key]的值为'',将该属性的值矫正为true
el[key] = true;
} else {
// 否则直接设置
el[key] = value;
}
} else {
// 如果HTML Attributes属性不是元素上默认持有的,使用setAttributes设置值
el.setAttribute(key, vnode.props[key]);
}
}
}
// 调用 insert 函数将元素插入到容器内
insert(el, container);
}
除此之外,还有一个特殊情况,input标签的form属性是只读的,因此我们只能使用setAttribute函数来设置属性。因此,这里我们通过一个特殊判断来进行修复。
function shouldSetAsProps(el, key, value) {
if(key === 'form' && el.tagName === 'INPUT') return false;
return key in el;
}
最后,为了维护跨平台的特性,我们将DOM属性的设置封装成配置项。
const renderer = createRenderer({
patchProps(el, key, prevValue, nextValue) {
if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === "boolean" && nextValue === "") {
// 如果HTML Attributes的值为boolean 且 nextValue的值为'',将该属性的值矫正为true
el[key] = true;
} else {
// 否则直接设置
el[key] = nextValue;
}
} else {
// 如果HTML Attributes属性不是元素上默认持有的,使用setAttributes设置值
el.setAttribute(key, nextValue);
}
},
})
function mountElement(vnode, container) {
// ...
if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key]); // 这里只需要调用patchProps函数就行了
}
}
// 调用 insert 函数将元素插入到容器内
insert(el, container);
}
实际上js对class属性的设置用三种方法,className、setAttribute和classList,而其中直接设置className的性能是最优的,因此我们对patchProps函数稍作修改。
patchProps(el, key, prevValue, nextValue) {
if (key === "class") {
el.className = nextValue || "";
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === "boolean" && nextValue === "") {
// 如果HTML Attributes的值为boolean 且 nextValue的值为'',将该属性的值矫正为true
el[key] = true;
} else {
// 否则直接设置
el[key] = nextValue;
}
} else {
// 如果HTML Attributes属性不是元素上默认持有的,使用setAttributes设置值
el.setAttribute(key, nextValue);
}
},
当进行第一次挂载操作后,后续的操作就是对原来挂载元素的修改,如果传入render函数的元素是一个null,那么我们就需要对元素进行卸载,这里我们通过一个封装的unmount函数实现。
function mountElement(vnode, container) {
// 首先让vnode指向真实node
const el = vnode.el = createElement(vnode);
...
}
function unmount(vnode) {
const el = vnode.el;
const parent = el.parentNode;
if(parent) parent.removeChild(el);
}
function render(vnode, container) {
if (vnode) {
// 如果vnode存在,进行更新操作
patch(container._vnode, vnode, container);
} else {
if (container._vnode) {
// 否则,卸载vnode
unmount(container._vnode);
}
container._vnode = null;
}
}
前文提到,如果传递的内容为null,需要对vnode进行卸载操作,那如果vnode的类型与之前不一致,我们很显然需要先对旧的vnode进行挂载,然后对新的vnode进行挂载。如果相同的话,我们再对其进行patch,也就是打补丁的操作。
function patch(n1, n2, container) {
if (n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (type === "string") {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (type === "object") {
// type=object,说明它描述的是一个组件
}
}
对于HTML标签上的事件,事件通常都是以on开头,所以只需要匹配以on开头的key,然后使用addEventListener添加事件就可以了。
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const name = key.slice(2).toLowerCase();
prevValue && el.removeEventListener(name, prevValue);
el.addEventListener(name, nextValue);
}
}
但是上述代码中的第四行,也就是涉及事件更新操作的代码,其实还可以以一种更优化的方式实现。我们绑定一个伪造的时间处理函数invoker,然后将真正的事件绑定到invoker函数的value属性上,当更新事件的时候,直接更新invoker的value值,而省去执行一次removeEventListener的时间。
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
let invoker = el._vei;
const name = key.slice(2).toLowerCase();
if (nextValue) {
if (!invoker) {
invoker = el._vei = (e) => {
invoker.value(e);
};
invoker.value = nextValue;
el.addEventListener(name, invoker);
} else {
invoker.value = nextValue;
}
} else if(invoker) {
el.removeEventListener(name, invoker);
}
}
}
除此之外,还存在一个问题,就是当vnode绑定了多个事件时,会产生事件覆盖的问题,对此,我们需要修改invoker的数据结构。
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 这里我们新增一个对象结构,key为事件的全称,value为事件对应的invoker
const invokers = el._vei || (el._vei = {});
let invoker = invokers[key]; // 取出对应的invoker
const name = key.slice(2).toLowerCase();
if (nextValue) {
if (!invoker) {
// 将事件添加到对应invoker的键上
invoker = el._vei[key] = (e) => {
invoker.value(e);
};
invoker.value = nextValue;
el.addEventListener(name, invoker);
} else {
invoker.value = nextValue;
}
} else if(invoker) {
el.removeEventListener(name, invoker);
}
}
}
并且当同类事件多次添加事件方法时,多个方法之间可以并存。因此需要对事件的执行机制进行调整。
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 这里我们新增一个对象结构,key为事件的全称,value为事件对应的invoker
const invokers = el._vei || (el._vei = {});
let invoker = invokers[key]; // 取出对应的invoker
const name = key.slice(2).toLowerCase();
if (nextValue) {
if (!invoker) {
// 将事件添加到对应invoker的键上
invoker = el._vei[key] = (e) => {
if(Array.isArray(invoker.value)) {
invoker.value.forEach(fn=>fn());
} else {
invoker.value(e);
}
};
invoker.value = nextValue;
el.addEventListener(name, invoker);
} else {
invoker.value = nextValue;
}
} else if(invoker) {
el.removeEventListener(name, invoker);
}
}
}
有时父组件的的事件绑定会依赖于子组件的事件,而当子组件触发时,会冒泡到父组件上,从而执行父组件的事件。但是我们希望的是将父组件的事件绑定延后到子组件事件执行完成之后,也就是说我们不希望父组件事件会在事件冒泡这一过程中触发。这事我们可以比较事件的触发事件和事件的绑定时间,所有事件绑定时间晚于事件触发时间的时间,都不执行。
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 这里我们新增一个对象结构,key为事件的全称,value为事件对应的invoker
const invokers = el._vei || (el._vei = {});
let invoker = invokers[key]; // 取出对应的invoker
const name = key.slice(2).toLowerCase();
if (nextValue) {
if (!invoker) {
// 将事件添加到对应invoker的键上
invoker = el._vei[key] = (e) => {
// 如果事件的触发时间晚于事件的绑定时间,不执行当前事件
if(invoker.attached > e.timeStamp) return;
if(Array.isArray(invoker.value)) {
invoker.value.forEach(fn=>fn());
} else {
invoker.value(e);
}
};
invoker.value = nextValue;
el.addEventListener(name, invoker);
} else {
invoker.value = nextValue;
// 为事件添加绑定时间属性
invoker.attached = performace.now();
}
} else if(invoker) {
el.removeEventListener(name, invoker);
}
}
}
对于子节点的更新,我们通常分为三种情况,文本、数组和’',对于这三种情况我们都需要分别进行处理。
function patchChildren(n1, n2, container) {
if (typeof n2.child === "string") {
// 当新节点的children为string时
if (Array.isArray(n1.children)) {
// 当旧节点为数组,卸载旧节点上的所有元素
n1.children.forEach((child) => unmount(child));
}
setElementText(container, n2.children);
} else if (Array.isArray(n2.children)) {
// 当新节点的children值为数组时
if (Array.isArray(n1.children)) {
// 若旧节点的children也是数组,执行Diff算法
} else {
// 否则,清空容器内容,将新节点的子节点全部挂载
setElementText(container, "");
n2.children.forEach((child) => patch(null, child, container));
}
} else {
// 否则,新节点的值为空,清空容器内容
if (Array.isArray(n1.children)) {
n1.children.forEach((child) => unmount(child));
} else if (typeof n1.child === "string") {
setElementText(container, "");
}
}
}
文本标签和注释标签的创建不同于其他基本标签,他们各自拥有自己的创建方法,对此我们需要单独对文本节点和注释节点做单独处理。
function patch(n1, n2, container) {
if (n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (type === "string") {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (type === Text) {
if (!n1) {
// 如果旧节点不存在,直接创建新节点
const el = (n2.el = document.createTextNode(n2.children));
insert(el, container);
} else {
// 否则,修改旧节点的值
const el = (n2.el = n1.el);
if (n2.children !== n1.children) {
el.nodeValue = n2.children;
}
}
} else if (type === "object") {
// type=object,说明它描述的是一个组件
}
}
同时,为了维护跨平台的特性,将createTextNode和nodeValue单独封装。
const renderer = createRenderer({
createTextNode(text) {
return document.createTextNode(text);
},
setText(el, text) {
el.nodeValue = text;
},
});
function patch(n1, n2, container) {
if (n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (type === "string") {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (type === Text) {
if (!n1) {
// 如果旧节点不存在,直接创建新节点
const el = (n2.el = createTextNode(n2.children));
insert(el, container);
} else {
// 否则,修改旧节点的值
const el = (n2.el = n1.el);
if (n2.children !== n1.children) {
setText(el, n2.children);
}
}
} else if (type === "object") {
// type=object,说明它描述的是一个组件
}
}
Fragment,字面意思片段,它是一种节点的类型,同时也是Vue3支持多根节点template的缘由。Fragment本身不具有属性,所以挂载一个Fragment节点只需要对其子节点进行操作就行了,同时unmount函数也要相应的支持对Fragment节点的卸载。
function patch(n1, n2, container) {
if (n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (type === "string") {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (type === Text) {
if (!n1) {
// 如果旧节点不存在,直接创建新节点
const el = (n2.el = createTextNode(n2.children));
insert(el, container);
} else {
// 否则,修改旧节点的值
const el = (n2.el = n1.el);
if (n2.children !== n1.children) {
setText(el, n2.children);
}
}
} else if (type === Fragment) {
if (!n1) {
n2.children.forEach((c) => patch(null, c, container));
} else {
patchChildren(n1, n2, container);
}
} else if (type === "object") {
// type=object,说明它描述的是一个组件
}
}
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach((c) => unmount(c));
return;
}
const el = vnode.el;
const parent = el.parentNode;
if (parent) parent.removeChild(el);
}