前言
上文:响应式系统实现
在上一篇文章中我们实现了响应式系统,那么在这一个章节我们将要实现渲染器了,我们主要要实现三个部分:
- h函数:返回虚拟节点对象
- mount函数:用于将虚拟节点挂载到真实 DOM 上
- patch函数:用于更新虚拟节点。
现在就让我们开始吧
实现 h 函数
话不多说,先上代码:
/**
* tag: 标签名,
* props: 属性
* children:子节点
*/
function h(tag, props, children) {
return {
tag,
props,
children,
};
}
const vnode = h(
"div",
{
style: {
width: "200px",
height: "200px",
backgroundColor: "red",
},
},
[
h("h2", null, "hello world"),
h(
"button",
{
onClick() {
alert("click");
},
},
"clicke me"
),
]
);
上面的代码做的事无非就是接收三个参数,再把这三个参数作为一个vnode返回。让我们来打印一下看看:
也许看到这里会觉得有些懵,不知道要干啥,其实要做的事无非就是待会要通过 mount 函数这个vnode转换成真实的DOM。话不多 说,让我们继续接下来的操作
实现 mount 函数
这个函数要实现的功能就是把刚刚获取调用h函数获取到的vnode转换成真实 DOM,话不多说,直接上代码:
function mount(vnode, root) {
const el = document.createElement(vnode.tag); // 创建真实节点
vnode.el = el; // 将真实节点挂载到虚拟节点上
const props = vnode.props; // 获取属性
const children = vnode.children; // 获取子节点
if (props) { // 如果有属性
Object.keys(props).forEach((key) => { // 遍历属性
if (key.startsWith("on")) { // 如果是事件属性
el.addEventListener(key.slice(2).toLocaleLowerCase(), props[key]); // 添加事件监听器
} else if (key === "style") { // 如果是样式属性
Object.keys(props[key]).forEach((styleKey) => { // 遍历样式
el.style[styleKey] = props[key][styleKey]; // 设置样式
});
} else { // 其他属性
el.setAttribute(key, props[key]); // 设置属性
}
});
}
if (typeof children === "string" || typeof children === "number") { // 如果子节点是文本节点
el.textContent = children; // 设置文本内容
}
if (Array.isArray(children) && children.length) { // 如果子节点是元素节点
children.forEach((childrenVnode) => { // 遍历子节点
mount(childrenVnode, el); // 递归挂载子节点
});
}
root.appendChild(el); // 将真实节点添加到根节点
}
上面代码中的 mount 函数无非就是接受两个参数:vnode、root,一个为 vnode,一个则是真实 DOM 元素,也就是根节点。在这个函数内部经过一序列操作,将传进来的 vnode 转换为真实的 DOM 再挂载到 根节点(root)上。
让我们结合 h 函数 和 mount 函数来生成真实的 DOM 元素:
const vnode = h(
"div",
{
style: {
width: "200px",
height: "200px",
backgroundColor: "red",
},
},
[
h("h2", null, "hello world"),
h(
"button",
{
onClick() {
alert("click");
},
},
"clicke me"
),
]
);
mount(vnode, document.querySelector('#root'))
可以看到,我们已经实现了将虚拟DOM转换成真实DOM并挂载到指定的根节点上,点击按钮的时候也能触发我们添加的点击事件
实现 patch 函数
在前面我们已经实现了 h 函数 以及 mount 函数 ,已经完成了最基本的渲染器的实现,我们还差最后一步:更新虚拟节点。让我们来设想一个场景:假设我们创建的 vnode 中需要依赖于在前文我们实现了的调用 reactive(obj)返回的响应式对象中的数据,并且是在页面的显示中依赖这个数据。每当这个依赖的数据发生更新的时候,我们需要vnode也发生更新这样页面上依赖的数据会随之而更新。随即我们可以想到每当依赖的响应式数据发生改变的时候,重新执行我们的mount 函数操作。这样做是可以,但是太浪费资源了每次发生更新就要重新执行将vnode转换成真实DOM操作,那有没有更具通用性的操作呢?答案是肯定的,我们可以写一个 patch 函数 这个函数接收两个参数:vnode1(old)、vnode2(new)。顾名思义,都是两个 vnode,然后在这个函数中比较新旧两棵虚拟DOM树的差异,然后根据差异对真实的DOM进行更新。
话不多说,直接上代码:
function patch(vnode1, vnode2) {
if (vnode1.tag !== vnode2.tag) { // 如果标签名不同
const el = vnode1.el; // 获取元素节点
const parentEl = el.parentElement; // 获取父元素节点
parentEl.removeChild(el); // 移除元素节点
mount(vnode2, parentEl); // 挂载新的虚拟节点
} else {
const el = (vnode2.el = vnode1.el); // 获取元素节点
const newProps = vnode2.props ?? {}; // 获取新属性
const oldProps = vnode1.props ?? {}; // 获取旧属性
Object.keys(newProps).forEach((key) => { // 遍历新属性
if (oldProps[key] !== newProps[key]) { // 如果属性值不同
if (key.startsWith("on")) { // 如果是事件属性
el.removeEventListener(
key.slice(2).toLocaleLowerCase(),
oldProps[key]
); // 移除旧事件监听器
el.addEventListener(key.slice(2).toLocaleLowerCase(), newProps[key]); // 添加新事件监听器
} else if (key === "style") { // 如果是样式属性
Object.keys(oldProps[key]).forEach((styleKey) => { // 遍历旧样式属性
if (!(styleKey in newProps[key])) { // 如果新样式属性中没有旧样式属性
el.style.removeProperty(styleKey); // 移除旧样式属性
}
});
Object.keys(newProps[key]).forEach((styleKey) => { // 遍历新样式属性
el.style[styleKey] = newProps[key][styleKey]; // 设置样式
});
} else { // 其他属性
el.setAttribute(key, newProps[key]); // 设置属性
}
}
});
Object.keys(oldProps).forEach((key) => { // 遍历旧属性
if (!(key in newProps)) { // 如果新属性中没有旧属性
if (key.startsWith("on")) { // 如果是事件属性
el.removeEventListener(
key.slice(2).toLocaleLowerCase(),
oldProps[key]
); // 移除旧事件监听器
} else { // 其他属性
el.removeAttribute(key); // 移除属性
}
}
});
// 处理 children
if (vnode2.children) { // 如果有子节点
const oldChildren = vnode1.children ?? []; // 获取旧子节点
const newChildren = vnode2.children ?? []; // 获取新子节点
if (typeof newChildren === "string" || typeof newChildren === "number") { // 如果新子节点是文本节点
if (oldChildren !== newChildren) { // 如果文本内容不同
el.innerHTML = newChildren; // 设置文本内容
}
} else { // 如果新子节点是元素节点
if (
typeof oldChildren === "string" ||
typeof oldChildren === "number"
) { // 如果旧子节点是文本节点
el.innerHTML = ""; // 清空元素节点
newChildren.forEach((childrenVnode) => { // 遍历新子节点
mount(childrenVnode, el); // 递归挂载子节点
});
} else { // 如果旧子节点是元素节点
const commenIndex = Math.min(oldChildren.length, newChildren.length); // 获取公共子节点的数量
for (let i = 0; i < commenIndex; i++) { // 遍历公共子节点
patch(oldChildren[i], newChildren[i]); // 递归更新子节点
}
newChildren.slice(oldChildren.length).forEach((childrenVnode) => { // 遍历新增子节点
mount(childrenVnode, el); // 递归挂载子节点
});
oldChildren.slice(newChildren.length).forEach((childrenVnode) => { // 遍历删除子节点
el.removeChild(childrenVnode.el); // 移除元素节点
});
}
}
} else { // 如果没有子节点
el.innerHTML = ""; // 清空元素节点
}
}
}
至此我们已经完整的实现了渲染器中所有的功能函数,其中 patch 函数的操作要在下一章节才能完全使用到,这里我们先不做测试操作
下文:实现createApp