如何实现 Virtual DOM

by: kelen / 2017-01-03

实现一个简单的 Virtual DOM, 你不需要去深入 React 的源码,或者去研究其他实现 Virtual DOM 的源码,他们的代码很多且复杂,但实际上实现 Virtual DOM 的核心代码仅仅只要50行,甚至更少。

首先理解两个概念:

  • Virtual DOM 描述的是真实的 DOM。
  • 当 Virtual DOM 树有所变化之后,将会得到一个新的 Virtual DOM 树,算法将比较新旧的两棵 Virtual DOM 树,找到不同之处,仅将变化的部分反映在真实的 DOM 上。

理解上述概念之后,现在来深入的了解每一个概念。

抽象 DOM 树

首先,我们要以某种方式在内存中存储我们的 DOM 树。这很简单,通过JS 的对象就可以实现。假设我们的 DOM 树结构如下:

<ul class="list">
	<li>item 1</li>
	<li>item 2</li>
</ul>

 

{ type: ‘ul’, props: { ‘class’: ‘list’ }, children: [
  { type: ‘li’, props: {}, children: [‘item 1’] },
  { type: ‘li’, props: {}, children: [‘item 2’] }
] }

 

看起来很简单的 DOM 结构,如何用 JS 的对象表示呢?

不过你注意到了吗?

  • 表示 DOM 元素的对象结构是这样的
{ type: ‘…’, props: { … }, children: [ … ] }
  • 我们用字符串来表示 DOM 的文本节点

但是用这样的方式编写一个复杂的 DOM 树是相当麻烦的,所以我们先来编写一个辅助函数,这也将让我们更容易理解上述的对象结构。

function h(type, props, ...children) {
  return { type, props, children }
}

现在,我们就可以这样描述 DOM 树了:

h1('ul', { 'class': 'list' },
 h('li', {}, 'item 1'),
 h('li', {}, 'item 2'), 
)

这样看上去更加简洁了吧,但是不是还可以优化, 你应该听说过 JSX 吧, 他的原理是怎样的呢。现在,我们就可以这样描述 DOM 树了:

如果你读过 Babel JSX 的官方文档, 你知道 Babel 会把如下的代码

<ul className=”list”>
  <li>item 1</li>
  <li>item 2</li>
</ul>

转译成

React.createElement(‘ul’, { className: ‘list’ },
  React.createElement(‘li’, {}, ‘item 1’),
  React.createElement(‘li’, {}, ‘item 2’),
);

有没有发现相似之处? 如果将上面的 React.createElement(…) 的调用替换成 h(…)就可以了。事实上,我们通过 jsx pragma 来实现。只需要在源码的顶部添加一行注释

/** @jsx h */
<ul className=”list”>
  <li>item 1</li>
  <li>item 2</li>
</ul>

这样就好比告诉Babel, “喂, 转译这个jsx文件,但是用h代替React.createElement”。

通过上述的内容,我可以通过如下的方式来编写 DOM

/** @jsx h */
const a = (
  <ul className=”list”>
    <li>item 1</li>
    <li>item 2</li>
  </ul>
);

Babel 会将其转化成如下的代码:

const a = (
  h(‘ul’, { className: ‘list’ },
    h(‘li’, {}, ‘item 1’),
    h(‘li’, {}, ‘item 2’),
  );
);

当函数h执行的时候,他将返回一个纯JS的对象,也就是 Virtual DOM 表达式。

const a = (
  { type: ‘ul’, props: { className: ‘list’ }, children: [
    { type: ‘li’, props: {}, children: [‘item 1’] },
    { type: ‘li’, props: {}, children: [‘item 2’] }
  ] }
);

试试在 JSFiddle 的例子吧。

应用 DOM 表达式

好了,现在我们已经有了JS对象作为 DOM 树的表达式。但我们该以怎样的方式来创建真实的 DOM 呢,因为我们不能直接把 DOM 表达式直接添加到 DOM 结构里去。

首先,我们来做一下相关的约定。

  • 书写真实的 DOM 节点(元素,文本节点)时,面前都加上 $符号。比如: $parent就是一个真实的 DOM 元素。
  • Virtual DOM 的表达式存储在以节点名称命名的变量里。
  • 类似React, 只能有一个根节点,其他的所有节点都在其内部。

有了上述的约定,可以开始写 createElement(…) 函数了, 它接受一个 Virtual DOM 节点,然后返回一个真实的 DOM 节点,暂时先不管 props 和 children, 如下:

function createElement(node) {
  if (typeof node === 'string') {
    return document.createTextNode(node);
  }
  reutrn document.createElement(node.type);
}

因为节点有两种类型:文本节点——字符串 和 元素——对象的 type 字段,就像下面的这样:

{ type: ‘…’, props: { … }, children: [ … ] }

从而,我们现在可以传虚拟的文本节点或者虚拟的元素节点来创建真实的节点。

现在,我们来实现children部分,同样的,他们也有可能是文本节点或者元素。所以他们也可以在 createElement 函数中创建。察觉到了吗? 这里我们要使用到递归。 这样我们就可以在任何的元素子节点上调用 createElement(...),然后调用 appendChild(),将结果添加到元素节点上。就像这样:

真不错。我们这里先把 props 的放一放。因为添加了 props,无助于我们来理解 Virtual DOM 相关的概念,反而理解起来会变的复杂。稍后我也会提到它。

现在,先去 JSFiddle 试试吧。

处理节点变化

现在我们已经能把虚拟节点转化成真实的节点了。可以来思考一下怎么来进行虚拟树的版本比较了。最基本的就是我们需要写一个算法,来比较新旧两棵虚拟树,然后仅仅将变化的部分在真实的 DOM 上改变。

如何去比较两个虚拟树呢? 我们来列举一下可能的情况:

  • 旧节点中不存在——所以需要用到 appendChild(...) 来添加节点。

  • 新节点中不存在——因此需要用到 removeChild(...) 来删除节点。

  • 同一处的节点不同——因此要用到 repalceChild(...) 来改变节点

  • 子节点相同但跟深的节点不同——我们需要去比较更深层的节点。

现在编写一个叫 updateElement 的函数,它接受三个参数——$parentnewNode 和oldNode, 其中 $parent 是我们的虚拟节点对映的真实节点的父节点。现在来具体实现上述的所有的情况。

旧节点中不存在

这种情况下十分简单,直接将新节点添加到父节点上。

function updateElement($parent, newNode, oldNode) {
  if (!oldNode) {
    $parnet.appendChild(
      createElement(newNode)
    );
  }
}

新节点中不存在

如果在新的虚拟树里不存在了某个节点,我们应该将这个节点从真实的DOM结构中删除,那么问题来了?我们要怎么删除这个节点呢?在函数调用的时候,我们传入了父节点作为参数,因此只要我们调用$parent.removeChild(...) 方法,传入要删除的节点,就可以了该节点了。 但是这样还不够,如果我们知道需要删除的节点在父节点的位置,即index,我们可以通过$parent.childNodes[index]获取到该节点。然后将其删除。

现在,我们假设将会有index作为参数传入我们的函数, 那么代码就会变成如下:

function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parnet.childNodes[index]
    )
  }
}

节点改变

现在,我们要写一个函数来比较新旧 Virtual DOM 树的节点,然后判断节点是否变化了。这里我们要考虑到节点可能是元素和文本节点。

function changed(node1, node2) {
  return typeof node1 !== typeof node2 ||
    typeodf node1 === 'string' && node1 !== node2 ||
    node1.type !== node2.type
}

现在我们有当前节点在父节点中的索引(index),我们很容易将它替换成新的节点:

function updateElement($arent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
      createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parnet.childNodes[index]
    );
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(
      createElement(newNode),
      $parnet.childNodes[index]
    );
  }
}

子节点的比较

最后,也是最重要的,我们应该遍历节点的所有子节点来比较他们的不同,然后再在每个子节点上调用updateElement(...),这里再一次需要用到递归。

在写代码之前,先来整理一下思路:

  • 只有元素节点的子元素需要遍历比较(文本节点的子节点不需要遍历)
  • 当前节点作为父节点传入
  • 所有的子节点需要遍历比较,即使有些时候可能是undefined,我们的函数可以处理这种情况。
  • 索引(index)也就是children数组的索引
function updateElement($parent, newNode, oldNode, index = 0) {
  if (!oldNode) {
    $parent.appendChild(
    	createElement(newNode)
    );
  } else if (!newNode) {
    $parent.removeChild(
      $parent.childNodes[index]
    );
  } else if (changed(newNode, oldNode)) {
    $parent.replaceChild(
      createElement(newNode),
      $parent.childNods[index]
    );
  } else if (newNode.type) {
    const newLength = newNode.children.length;
    const oldLength = oldNode.children.length;
    for (let i = 0; i < newLength || i < oldLength; i++) {
      updateElement(
        $parent.childNodes[index],
        newNode.children[i],
        oldNode.children[i],
      );
    }
  } 
}

整合

你可以在 JSFiddle 上去试试, 真的只有 50 行代码。

打开开发者工具,当你按下Reload之后,可以看到应用的相关变化。

总结

好了,我们已经编写了一个自己的 Virtual DOM 的实现。我希望你在阅读这篇文章之后了解了 Virtual DOM 的基本概念,React 是如何基于此工作的。

然而,还有一些高级的内容没有提及到(我将在未来的文章里进行阐述)

  • 设置元素的属性(props),然后实现比较和更新。
  • 处理事件,给元素添加事件监听
  • 让我们的 Virtual DOM 和组件开发结合起来,比如 React
  • 渲染真实的 DOM 节点
  • 将 Virtual DOM 和直接操作的 DOM 的类库结合使用,比如 jQuery 和他的一些插件

最新发布
热门文章