(翻译)构建你自己的虚拟DOM

294 阅读6分钟

原文:medium.com/@deathmood/…

构建你自己的虚拟DOM,你需要知道2个事情。你不需要理解 React,或者深入其他虚拟DOM的实现源码。他们太过庞大和复杂,事实上,虚拟DOM的核心代码的实现甚至可以少于50行代码。

下面是2个原则:

  • 虚拟DOM是任意一种真实DOM的表现。
  • 当你修改了虚拟DOM树,我们会得到一棵新的树。算法会比较这2棵树,找到差异,对真实DOM做最小的更新。

下面让我们深入上面的2个原则。

表示我们的dom树

首先,我们需要一种方式来存储dom树。我们可以使用 javascript 对象。假设我们有下面这样一颗树:

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

看起来是不是很简单,我们如何用 javascript 对象表示这棵树呢?

{
    "type":"ul",
    "props":{
        "class":"list"
    },
    "children":[
        {
            "type":"li",
            "props":{

            },
            "children":[
                "item 1"
            ]
        },
        {
            "type":"li",
            "props":{

            },
            "children":[
                "item 2"
            ]
        }
    ]
}

我们可以注意到2个事情:

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

但是用这种方式编写一棵很大的树会很困难,所我们需要一个帮助方法,这样可以使我们很容易理解DOM树的结构。

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

现在我们可以这样编写我们的DOM树:

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>

它会告诉 BabelReact.createElement(…) 替换为 h(…)。当然,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 对象 - 虚拟DOM:

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

应用一下 DOM 表达

现在我们有了根据我们的要求用 js 对象表达的 DOM 树。但是我们需要根据 DOM 树创建真实的 DOM。因为我们不能直接追加表达式到真实的DOM中。

首先我们做一些假设,制定一下术语:

  • 真实DOM节点的变量我会以 $ 开头,因此 $parent 就是一个真实的DOM元素

  • 虚拟DOM表达式将在名为node的变量中

  • 比如在 React 中,你只会有一个根节点(one root node),其他所有的节点都在根节点下

入上文所说,让我们创建 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: [  ] }

这样,我们可以同时传递文本节点和元素节点。

现在我们考虑一下 childrenchildren 的每个元素要么是文本节点,要么是元素节点。所以我们也可以通过 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 放到一边,后面再讨论。理解虚拟DOM的基础原则不需要用到它们, 而且它们会增加复杂度。

处理更新

现在我们可以把一个虚拟DOM转化为真实的DOM,是时候取考虑比对我们的虚拟DOM树。就是说,我们需要实现一个算法,算法会比对2棵虚拟DOM树 - 新的和旧的,真实的DOM只做必要的更新。

如何比较2棵树?我们需要处理下一个例子:

  • 有些地方没有旧的节点,我们调用 appendChild(…) 添加一个新的节点。
  • 有些地方没有新的节点,我们调用 removeChild(…) 删除这个节点。
  • 如果一个地方有不同的2个节点,我们调用 replaceChild(…) 更新这个节点。
  • 如果节点相同,我们需要更深入,去比较子节点。

让我们创建一个函数 updateElement(…),函数有3个参数-parent,newNodeandoldNodeparent, newNode and oldNode。parent 是真实的DOM节点,虚拟DOM的父亲节点。现在让我们看看怎么处理上面提到的所有例子。

没有旧的节点

这个很简单,我都不用注释:

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

没有新的节点

如果新的虚拟DOM树的当前位置没有节点,我们需要从真实DOM中删除对应的节点。我们知道父亲节点,这样就可以调用 parent.removeChild()方法,传递真实的DOM节点引用。但是我们没有这个引用。但是如果我们知道节点在夫妻节点中的位置,我们可以通过parent.removeChild(…)*方法,传递真实的DOM节点引用。但是我们没有这个引用。但是如果我们知道节点在夫妻节点中的位置,我们可以通过 *parent.childNodes[index] 拿到这个引用,inex 是节点在父亲节点中的位置。

假设 index 会传递到我们的方法中。代码如下:

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

节点更新

首先我们创建一个方法用于比较2个节点(新的和旧的),告诉我们哪个节点有变化。我们需要同时考虑元素节点和文本节点。

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]
    );
  }
}

比对children

最好,我们需要遍历节点的子节点并且进行比对。我们把这个过程叫 *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
      );
    }
  }
}

合在一起

/** @jsx h */

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

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;
}

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

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
      );
    }
  }
}

// ---------------------------------------------------------------------

const a = (
  <ul>
    <li>item 1</li>
    <li>item 2</li>
  </ul>
);

const b = (
  <ul>
    <li>item 1</li>
    <li>hello!</li>
  </ul>
);

const $root = document.getElementById('root');
const $reload = document.getElementById('reload');

updateElement($root, a);
$reload.addEventListener('click', () => {
  updateElement($root, b, a);
});

结论

恭喜,我们做到了。我们实现了虚拟DOM。通过这个文章,我们读者能理解虚拟DOM的基本原则,以及 React 底层是怎么工作的。

然而有一些这里没有提到的:

  • 设置元素属性(props),区别和更新它们
  • 事件处理,添加事件到元素中。
  • 让虚拟DOM支持组件,就像 React
  • 从真实DOM中获取引用
  • 引入可以处理真实DOM变化的三方库,比如 jQuery 和它的插件