vlambda博客
学习文章列表

听说你想写个 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

  1. 可想而知,vdom 与 dom 会有一种映射关系。vdom 需要跟真实 dom 进行关联,这样就可以方便的找到真实 dom,进行操作。那么在 vdom 的信息中就会包含 dom。

  2. 此外,vdom 还需包含节点描述信息,不然怎么做对比呢?

  3. 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 节点。如下图所示:

听说你想写个 React - 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 的实现,敬请期待~

最后

微微笑的蜗牛
会点前端的 iOS 程序媛,写点技术,谈点人生,还有点理想。
24篇原创内容
Official Account

参考资料

  • https://engineering.hexacta.com/didact-instances-reconciliation-and-virtual-dom-9316d650f1d0