听说你想写个 React - virtual dom
大家好,我是微微笑的蜗牛,🐌。
上一篇文章介绍了 jsx 背后的实现,今天来介绍一下 virtual dom。
如何更新 dom
若采用现有的实现方式,想要更新 dom,只能先构造不同的节点描述信息,再重新调用 render 方法。
先看一个例子,计时器每隔 1s 刷新页面。
const rootDom = document.getElementById("root");
function tick() {
const time = new Date().toLocaleString();
const clockElement = <h1>{time}</h1>;
SLReact.render(clockElement, rootDom);
}
tick();
setInterval(tick, 1000);
这个例子中,每秒都会调用一次 render 方法。
而以现有的实现方式,render 方法中总是在往 dom 树上添加节点。这样会造成节点不断的增加,就像下图这个样子。
但我们可稍微简单修改一下 render 的实现,将添加改成替换。
if (!parentDom.lastChild) {
parentDom.appendChild(dom);
} else {
parentDom.replaceChild(dom, parentDom.lastChild);
}
这样改过之后,比上面的实现方案要好一点。但是对于复杂的结构来说,仍是耗费巨大,因为在操作真实的 dom 树。
但如果试想有一种方案,只刷新差异部分,那么操作 dom 树的效率将会大大提升。
那么如何才能得到前后 dom 树的差异呢?
这就需要用到中间层,使用额外的结构来存储真实 dom 树的结构。在重新 render 的时候,进行新老对比,找出差异部分,再进行更新。
这种中间结构称之为 virtual dom,可简称 vdom。为什么叫做虚拟 dom,因为它只是内存中真实 dom 树的对照。
virtual dom
-
可想而知,vdom 与 dom 会有一种映射关系。vdom 需要跟真实 dom 进行关联,这样就可以方便的找到真实 dom,进行操作。那么在 vdom 的信息中就会包含 dom。
-
此外,vdom 还需包含节点描述信息,不然怎么做对比呢?
-
vdom 也是树状结构,那么同样包含子节点。
根据上述分析,我们便可得到 vdom 的结构。它包括节点描述信息、关联的真实 dom、子 vdom 节点。每个 dom 节点都对应着一个 vdom 节点。
当在做 diff 时,根据新旧节点描述信息,找出差异部分,尽可能的重用 dom,减少开销。
那如何来构建 vdom 进行 diff 呢?下面我们来一步步的讲解。
vdom 结构
虚拟 dom 节点信息包括三部分:
-
节点描述信息 -
关联的真实 dom -
子虚拟 dom 节点
它的结构如下:
let vdom = { dom, element, childInstances };
根据之前的 render 方法,其实比较容易改造出这种结构。因为它返回的是真实 dom,我们只需将返回信息修改为 virtual dom 的结构就好。
改造过程如下:
-
根据节点类型生成真实 dom -
更新 dom 属性 -
递归处理子节点 -
获取子节点真实 dom,逐个添加到父节点 -
返回 virtual dom
代码如下所示:
// virtual dom,保存真实的 dom,element,childInstance
function instantitate(element) {
const { type, props } = element;
const isTextElement = type === TEXT_ELEMENT;
// 生成真实 dom
const dom = isTextElement
? document.createTextNode("")
: document.createElement(type);
// 更新属性
updateDomProperties(dom, [], props);
// 处理子节点
const childElements = props.children || [];
// 生成子虚拟 dom
const childInstances = childElements.map(instantitate);
// 获取子 dom
const childDoms = childInstances.map((childInstance) => childInstance.dom);
// 添加到 dom 树
childDoms.forEach((childDom) => dom.appendChild(childDom));
// 组成虚拟 dom 结构
const instance = { dom, element, childInstances };
return instance;
}
在得到 virtual dom 结构后,下一步需要做的就是更新真实 dom 节点。
此时,render 方法需要进行改造,变为比较前后 vdom 差异。
let rootInstance = null;
function render(element, parentDom) {
const prevInstance = rootInstance;
const nextInstance = reconcile(parentDom, prevInstance, element);
rootInstance = nextInstance;
}
它主要工作是和上一个 virtual dom 实例做对比,然后进行 dom 树的更新。
这里我们将 diff 更新的过程叫做 reconcile,它会返回 virtual dom 节点。如下图所示:
diff 简单处理
reconcile 的入参有三个,分别是父 dom 节点、vdom、节点描述信息。
先来看一种简单的处理方式:
-
如果传入的 vdom 为空,说明还没有 dom 节点,需要将真实 dom 添加到 dom 根节点上。 -
如果不为空,则用新的 dom 节点替换原有 dom 节点。
如下所示:
function reconcile(parentDom, instance, element) {
if (instance == null) {
// 添加 dom
const newInstance = instantiate(element);
parentDom.appendChild(newInstance.dom);
return newInstance;
} else {
// 替换 dom
const newInstance = instantiate(element);
parentDom.replaceChild(newInstance.dom, instance.dom);
return newInstance;
}
}
不知大家发现了没,上面的处理中,无论哪种条件下都调用了 instantiate 方法来重新创建 vdom 结构。
而 instantiate 中会创建真实 dom,这样在节点类型没变化时会产生不必要的开销。
重用 dom 节点
这里我们可以稍微优化一下,当节点类型一样时,可以不用重新创建 dom 节点,复用已有就行,然后再更新属性。
如下所示:
function reconcile(parentDom, instance, element) {
if (instance.element.type === element.type) {
// 复用 dom,更新属性
updateDomProperties(instance.dom, instance.element.props, element.props);
instance.element = element;
return instance;
}
}
子节点 diff
但是,还存在一个问题,子节点的 diff 还没有进行处理。
在 React 中,子节点会有一个额外的属性 key,以它为标识来对比之前的节点。
这里,我们将简单处理,只将每个位置上的子节点与新的节点信息进行对比,进行新增/更新/替换/删除操作。
如下所示:
// 对子节点做处理
function reconcileChildren(instance, element) {
const dom = instance.dom;
// 原有 virutal dom
const childInstances = instance.childInstances;
// 新的节点描述信息
const nextChildElements = element.props.children || [];
const newChildInstances = [];
// 取最大的,若新子节点数 < 原节点数,需移除
const count = Math.max(childInstances.length, nextChildElements.length);
for (let i = 0; i < count; i++) {
const childInstance = childInstances[i];
const childElement = nextChildElements[i];
const newChildInstance = reconcile(dom, childInstance, childElement);
newChildInstances.push(newChildInstance);
}
return newChildInstances;
}
每个位置上的子节点 diff 仍然会调用到 reconcile 方法。
请注意:新旧子节点的数目可能是不一样的。
若新的子节点数目小于旧子节点数,需要删除旧子节点。
因为遍历次数是由新旧节点数目最大的那个决定。当遍历次数超出新子节点数时,这时,childElement 为 null。
如下图所示:
对应到 reconcile 中的处理,当 element 为空时,删除真实 dom 节点,然后 vdom 返回 null。
// dom 更新操作
function reconcile(parentDom, instance, element) {
// 省略...
if (element == null) {
console.log("remove dom");
// remove,若新子节点数 < 原节点数,需移除
parentDom.removeChild(instance.dom);
return null;
} else if (instance.element.type == element.type) {
console.log("reuse dom");
// 重用节点,更新属性
updateDomProperties(instance.dom, instance.element.props, element.props);
// 子节点处理
instance.childInstances = reconcileChildren(instance, element);
instance.element = element;
return instance;
}
}
这样就完成了 dom diff 的简单处理。
完整代码可查看:https://github.com/silan-liu/slreact/tree/master/part3。
总结
这篇文章主要介绍了如何构建 virutal dom 结构,以及进行简单的 diff 处理,以重用 dom 节点,减少不必要的开销。
下一篇将介绍 component 和 state 的实现,敬请期待~
最后
参考资料
-
https://engineering.hexacta.com/didact-instances-reconciliation-and-virtual-dom-9316d650f1d0