50行代码实现虚拟DOM

11,638 阅读7分钟

你不需要深入react源码,或者深入其他虚拟DOM实现的代码。你只要知道两点就能建立自己的虚拟DOM。虚拟DOM虽然体系庞大复杂,但是事实上虚拟DOM的主要部分只用50行代码则可。

记住下面两个概念:

  • 虚拟DOM是任何形式DOM的表示。
  • 当改变虚拟DOM树时,会得到一个新的虚拟树。算法会比较这两个树的差别,且对DOM做一下小的必要的修改,以至于他能对虚拟DOM进行映射。

渲染DOM树

首先,需要将DOM树存储在内存中。例子中用普通的JS对象表示,假设有这个DOM树:

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

part 1

如果只用纯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文本节点。

part 2

但是用这种方式来写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,那么他是怎么工作的?

part 3

根据JSX官方Babel文档,Babel将部分1的代码转译为部分2

  // 部分1
  <ul class="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'),
  );

咦!注意到相似之处了吗?如果我们利用 h(…)调用函数 替代 React.createElement(…)调用函数,是不是就一样啦。事实证明,我们能实现利用叫做 jsx的编译方式实现。我们只需要在源文件的头部 包含 固定的注释的行:

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

这段代码是这样的,他告诉Babel:嗨,将React.createElement调用函数 代替 h 函数去转译这个jsx文件。你可以放任何函数代替h,然后就会按照相应的方式转译代码。

part 4

因此,总结上文,我们以下列方式写DOM :

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

这样,Babel就会将这段代码解析成这样:

  const a = (
    h('ul', { 'class''list' },
      h('li', {}, 'item 1'),
      h('li', {}, 'item 2'),
    );
  );

当函数h执行的时候,它将返回纯JS对象---我们的虚拟DOM的表现形式:

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

尝试一下这个代码(不用忘记设置Babel):

  /** @jsx h */

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

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

  console.log(a);

执行结果如下所示:

image.png

应用DOM表示

现在,我们已经将DOM树表示为具有自己结构的纯JS对象。是不是很cool! 但是我们需要根据它建立一个DOM对象。因为我们不能将我们的表示添加到这个纯JS对象中。

首先,我们做些假设且设置一些术语:

  • 我将以 $ 开头编写所有具有实际DOM节点(元素、文本节点)的变量---——因此 $parent 将是DOM元素。
  • 虚拟DOM的表示 将在名为node的变量中。
  • 像React一样,只能有一个根节点——--所有其他节点都在根结点里面。

写一个函数 createElement(…), 这个函数接受一个虚拟DOM返回一DOM节点。现在先忽略 props and children,后续再设置。

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

因为可以同时有 文本节点(即纯JS字符串) 和 元素(即JS对象,如:{ type: ‘…’, props: { … }, children: [ … ] })

因此,我们可以在这里同时传递虚拟文本节点和虚拟元素节点——这样就可以了。

现在我们来考虑一下children属性,他们不是文本节点就是元素节点。所以他们也可用函数 createElement(…) 创造出来。类似递归,所以可以为每个元素的子元素调用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;
  }

咦!看起来很cool~。现在先不讨论 props节点,放在后边介绍。

代码实际操作一波:

  /** @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;
  }

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

  const $root = document.getElementById('root');
  $root.appendChild(createElement(a));

Vue,React,nodejs,JS基础,前端性能优化

处理变更

现在我们可以将你虚拟DOM转换为DOM,是时候区分虚拟DOM树了。因此,我们需要编写一个算法,它将比较两个虚拟树——旧的和新的——并只对实际DOM进行必要的更改。

怎么区分不同不得树结果?看下列的例子:

  • 有些地方没有旧的节点-----所以需要添加这样的节点,需要利用appendChild(…), 如下图图片
  • 有些没有新的节点------因此这些节点被删除了,需要利用removeChild(…),如下图图片
  • 有些地方存在不同的节点,因此这些节点被改变了,需要利用replaceChild(…),如下图图片
  • 一些节点相同----所以需要看下一层级比较子节点的不同图片

这样,我们可以写一个叫做updateElement(…)的函数,他有三个参数---$parentnewNodeoldNode。其中$parent是虚拟节点的DOM的父元素。下面是我们如何处理上述的所有情况。

Vue,React,nodejs,JS基础,前端性能优化

没有旧节点

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

没有新节点

有一个问题——如果在新虚拟树的当前位置没有节点——我们应该从实际DOM中删除它——但我们应该怎么做呢? 是的,我们知道父元素,因此我们应该调用$parent.removeChild(…)并传递DOM元素引用那里。但是我们并没有这样调用。如果我们知道节点在parent中的位置,我们可以用$parent.childNodes[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
  }

现在,在父节点中有了当前节点的索引,这样就可以很容易的用新创建的节点替换它。

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

把这些都放在一起

把所有代码放在一起,咦!50行代码就能实现!

  /** @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如何在底层工作。

如果觉得这篇文章还不错,来个【分享、点赞、在看】三连吧,让更多的人也看到~