初探虚拟 DOM

1,646

前言

如果有这么一张表格要你维护。

后续涉及到表的增删改,你会怎么做?

  • 增:先找到正确的位置,再插元素进去?
  • 删:找到正确的元素,删掉它?
  • 改:找到正确的元素,修改它?

表格简单的时候还好,用 JavaScript 操作起来还算方便。但随着应用越来越复杂,需要处理的数据也越来越大,越来越复杂的时候,需要利用 JavaScript 操作的地方也会越来越多,这个时候准确地修改数据就变得不是那么容易了。

虚拟 DOM 的产生

针对前面的情况,那么能不能用一个东西来存储页面的视图状态,当视图状态发送变化时,读取这个东西,然后更新页面?

比如这一段 HTML 代码对应的 DOM,

<div>
  <div>
    <span>hello</span>
  </div>
  <span>world</span>
</div>

我们用另外的一个对象来表示它

let nodesData = {
  tag: 'div',
  children: [
    {
      tag: 'div',
      children: [
        {
          tag: 'span',
          children: [
            {
              tag: '#text',
              text: 'hello'
            }
          ]
        }
      ]
    },
    {
      tag: 'span',
        children: [
          {
            tag: '#text',
            text: 'world'
          }
        ]
    }
  ]
}

用这个对象来表示 DOM 结构,我们可以根据这个对象来构建真正的 DOM。

现在我们需要写一个函数,将这个虚假的 DOM 转化为真实的 DOM。

化假为真

function vNode({tag, children, text}){
  this.tag = tag
  this.children = children
  this.text = text
}

vNode.prototype.render = function(){
  if(this.tag === '#text'){
    return document.createTextNode(this.text)
  }
  
  let el = document.createElement(this.tag)
  this.children.map((vChild) => {
    el.appendChild(new vNode(vChild).render())
  })
  
  return el
}

调用上面的这个函数可以将我们用来表示 DOM 的对象(虚假 DOM)变成真正的 DOM。

let node = new vNode(nodesData)
node.render()

这样,就化假 DOM 为真 DOM 了。

当我们的需要改变 DOM 时,只需要改变其对应的虚假 DOM,再调用一下 render 函数,就可以改变真实 DOM,不需要我们亲自用 JavaScript 去操作页面中的 DOM。

局部更新

上面虽然实现了从虚假 DOM 到真实 DOM 的转化,但是也有一个问题,那就是每次转化都会遍历所有的 DOM 结构,通通的全部转化一遍。如果只有一个小地方发生了改变,也需要将全部的 DOM 更新一遍,那这样就太耗费性能了,我们应该比较虚假 DOM 的变化,只更新变化的地方。

function patchElement(parent, newVNode, oldVNode, index = 0) {
  if (!oldVNode) {
    parent.appendChild(newVNode.render());
  } else if (!newVNode) {
    parent.removeChild(parent.childNodes[index]);
  } else if (newVNode.tag !== oldVNode.tag || newVNode.text !== oldVNode.text) {
    parent.replaceChild(new vNode(newVNode).render(), parent.childNodes[index]);
  } else {
    for (
      let i = 0;
      i < newVNode.children.length || i < oldVNode.children.length;
      i++
    ) {
      patchElement(
        parent.childNodes[index],
        newVNode.children[i],
        oldVNode.children[i],
        i
      );
    }
  }
}

通过这个算法,逐层比较新旧虚假 DOM 的结构变化,如果没变,就继续往下遍历;如果发现结构发生了变化,就重新生成真实 DOM 替换掉旧的。

来看一看效果。

从图中可以看到,当虚假 DOM 发生变化时,在更新真实 DOM 的过程中,只更新了发生了变化的那一部分,没有发生变化的地方是没动的,这样就优化了性能。

结语

这是一个非常粗糙的实现,diff 算法非常简单地比较了差异,这里仅仅表达了一下虚拟 DOM 的实现思想,在实际运用过程还有很多地方需要考虑。

这里贴个完整代码。

<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width">
  <title>JS Bin</title>
</head>

<body>
  <div id="test"></div>
  <script>
    let nodesData = {
      tag: 'div',
      children: [
        {
          tag: 'div',
          children: [
            {
              tag: 'span',
              children: [
                {
                  tag: '#text',
                  text: 'hello'
                }
              ]
            }
          ]
        },
        {
          tag: 'span',
          children: [
            {
              tag: '#text',
              text: 'world'
            }
          ]
        }
      ]
    };
    let nodesData2 = {
      tag: 'div',
      children: [
        {
          tag: 'div',
          children: [
            {
              tag: 'span',
              children: [
                {
                  tag: '#text',
                  text: 'HELLO'
                }
              ]
            }
          ]
        },
        {
          tag: 'span',
          children: [
            {
              tag: '#text',
              text: 'WORLD'
            }
          ]
        }
      ]
    };

    function vNode({ tag, children, text }) {
      this.tag = tag;
      this.children = children;
      this.text = text;
    }

    vNode.prototype.render = function () {
      if (this.tag === '#text') {
        return document.createTextNode(this.text);
      }

      let el = document.createElement(this.tag);

      this.children.map(vChild => {
        el.appendChild(new vNode(vChild).render());
      });

      return el;
    };

    function patchElement(parent, newVNode, oldVNode, index = 0) {
      if (!oldVNode) {
        parent.appendChild(newVNode.render());
      } else if (!newVNode) {
        parent.removeChild(parent.childNodes[index]);
      } else if (newVNode.tag !== oldVNode.tag || newVNode.text !== oldVNode.text) {
        parent.replaceChild(new vNode(newVNode).render(), parent.childNodes[index]);
      } else {
        for (
          let i = 0;
          i < newVNode.children.length || i < oldVNode.children.length;
          i++
        ) {
          patchElement(
            parent.childNodes[index],
            newVNode.children[i],
            oldVNode.children[i],
            i
          );
        }
      }
    }

    let node1 = new vNode(nodesData);
    let node2 = new vNode(nodesData2);

    let test = document.querySelector('#test');
    test.appendChild(node1.render());

    setTimeout(() => {
      patchElement(test, node2, node1, 0);
    }, 5000);
  </script>
</body>

</html>