yanni4night

如何编写你自己的 Virtual DOM

yanni4night · 2016-08-29翻译 · 2610阅读 原文链接

为了构建你自己的 Virtual DOM,你只需要知道两件事,甚至你都不必深入 React 或者其它 Virtual DOM 实现的源码。因为它们都太庞大和复杂了 —— 但是实际上 Virtual DOM 的主要部分可以用少于 50 行代码实现。50 行!!!

两个概念:

  • Virtual DOM 是真实 DOM 的任意一种表达形式;
  • 在 Virtual DOM 树上的改动,会创建一个新的 Virtual DOM 树。比较新老 Virtual DOM 树的算法,会计算差异并对真实 DOM 进行最小的更改,所谓“虚拟”

就是这些,让我们深挖每个概念的含义。

更新:关于 Virtual DOM 中设置属性和事件的第二篇文章在这里

描述 DOM 树

首先,我们需要以某种方式在内存中存储 DOM 树。可以利用纯 JavaScript 对象实现。假如我们有这样一棵树:

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

看起来非常简单,是吧?我们如何用 JS 对象来表示它?

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

这里我们强调两件事:

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

但是以这种方式写大型的树是非常困难的。所以我们来写一个帮助函数,使得理解这个结构更容易一些:

function h(type, props, …children) {
  return { type, props, children };
}

现在向树中写入数据是这样的:

h(‘ul’, { ‘class’: ‘list’ },
  h(‘li’, {}, ‘item 1’),
  h(‘li’, {}, ‘item 2’),
);

看起来清晰多了,是不是?我们更进一步。你听说过 JSX,对么?嗯,我也要实现它。那么它是如何工作的呢?

如果你阅读过 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 编译指令 做到这一点。只要在源码的开头放一行像注释的东西:

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

这一行实际上告诉 Babel:嘿,用 h 而不是 React.createElement 来编译 jsx。你可以将 h 替换成任何东西,都会被编译。

因此,总结上面我所说的来看,我们会以下面的形式写 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 表达形式

Ok,现在我们有了纯 JS 对象以及自己结构的 DOM 树表达形式。非常酷,但是我们得利用它创建一个真实的 DOM。毕竟我们不能直接把表达式写入 DOM。

首先我们先进行一系列假设并设定一些术语:

  • 我会用 $ 开头的变量代表真实 DOM 节点(元素以及文本),那么 $parent 就是一个真实 DOM 元素;
  • Virtual DOM 表达形式存储于变量 node 中;
  • 像 React 一样,你可以只有一个根节点 —— 其它都是其后代节点

Ok,如前所述,我们写一个函数 createElement(…) 把虚拟 DOM 节点转换成真实 DOM 节点。暂时忘记 propschildren —— 过后再说:

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

因为我们已经有了纯 JS 字符串表示的文本节点和像下面的以 JS 对象表示的元素

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

因此,我们在这里既可以处理虚拟文本节点也可以处理虚拟元素节点。

现在我们来考虑 children —— 每一个要么是一个文本节点要么是一个元素。所以他们都可以用我们的 createElement(…) 函数来创建。啊...你感到了么?我感受到了递归 :)) 于是我们在 children 的每一个元素上调用 createElement(…),并用 appendChild() 加入我们的元素中,像这样:

function createElement(node) {
  if (typeof node === ‘string’) {
    return document.createTextNode(node);
  }
  const $el = document.createElement(node.type);
  node.children
    .map(createElement)
    .forEach($el.appendChild.bind($el));
  return $el;
}

哇,看起来非常赞。我们先把 props 放一放,过后再讨论它,因为理解基本的 Virtual DOM 概念不需要它们,只会徒增复杂性。

JSFiddle

处理更新

Ok,现在我们能够把虚拟 DOM 转换为真实 DOM,到了该比较虚拟树差异的时候了。基本上我们要写个算法,比较两棵新旧树的差异,并对真实 DOM 做最少必要的更新。

如何比较树的差异?我们需要处理下面几个问题:

  • 某个位置有新节点 —— 因此节点是被增加的,我们需要 appendChild(…) 它;

  • 某个位置有旧节点 —— 因此节点是被删除的,我们需要 removeChild(…) 它;

  • 某个位置有不同的节点 —— 节点被更新,我们需要 replaceChild(…) 它;

  • 节点是相同的,我需要到下一层比较子节点

Ok,我们写一个函数 updateElement(…),输入 3 个参数,$parent, newNode and oldNode,其中 $parent 是我们的虚拟节点的对应的真实节点的父节点。现在看看我们如何处理上面提到的问题。

有一个新节点

相当简单了,都不必注释:

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

有旧节点

这里有问题了 —— 如果在 Virtual DOM 树的当前位置没有节点 —— 我们应该从真实 DOM 树中移除它 —— 但是我们如果做到?是的,我们知道父元素(传给函数了),于是,我们该调用 $parent.removeChild(…) 并传入真实的 DOM 元素引用。但是我们并没有这个引用。如果知道在父元素中的位置的话,我们则可以用 $parent.childNodes[index] 获取引用,这里 index 是索引:

假设这个 index 被传入了我们的函数(后面会看到,确实被传入了)。所以我们的代码是:

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

节点更新

首先我们需要写一个函数来比较两个节点(新和旧),并且告诉我们节点是否被真的更新了。我们应该考虑到元素和文本节点:

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

现在,有了 index,我们可以轻易地用新的节点替换它:

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.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.childNodes[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],
        i
      );
    }
  }
}

综合

好了,我们已经完成了任务,我把所有的代码放到了 JSFiddle,实现部分确实使用了 50 行代码,亦如我承诺你的那样。去玩玩它吧。

打开开发者工具,在你按下 Reload 按钮后观察应用的更新。

总结

恭喜你!我们达到了目的,实现了自己的 Virtual DOM,并且能正常工作。我希望在阅读完这篇文章后,你已经对 Virtual DOM 是如何工作的、React 的内部机制有了基本的了解。

然而,这里我们有些事情没有强调(我会在未来的文章中涉及到):

  • 设置元素属性并且比较或更新它们;
  • 处理事件 —— 为元素增加事件;
  • 让 Virtual DOM 和组件一起工作,像 React 那样;
  • 获取到真实 DOM 节点的引用;
  • 让 Virtual DOM 与直接操作 DOM 的库一同工作,如 jQuery 极其插件;
  • 其它…

P.S.

如果在代码或文字中有任何错误,或者代码可以有的任何优化,请在评论中指出 :)) 另外,对于我的英语我很抱歉 :)

更新:关于 Virtual DOM 中设置属性和事件的第二篇文章在这里

相关文章