50行代码实现Virtual DOM

作者: mervynyang | 来源:发表于2017-03-22 16:28 被阅读1326次

    50行代码实现Virtual DOM

    在你创造出自己的Virtual DOM之前,你只需要知道两件事情。你甚至不需要深入了解React的源代码,或者其他Virtual DOM的实现。它们都太庞大和复杂了,但实际上Virtual DOM的部分只需要不超过50行的代码!(当然,你千万不要把它放在生产环境)

    这里有2个概念:

    • Virtual DOM是真实DOM的映射。
    • 当我们在Virtual DOM树改变一些东西的时候,我们得到了一个新的Virtual DOM树,通过算法比较新树和旧树,找到不同的地方,然后只需要在真实的DOM上做出相应的改变。

    仅此而已,让我们来深入这两个概念。

    构建我们的Virtual DOM树

    首先,我们要在内存中存储我们的DOM树,我们能够用纯JS对象来表示它,假设我们有这样的一个结构:

    <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'] }
    ] }
    

    这里有两个点需要注意下:

    • 我们用JS对象表示DOM的元素:
    { type: '...', props: {...}, children: [...] }
    
    • 我们用JS字符串表示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'),
    )
    

    是不是看起来有点熟悉?如果我们能够用我们的h(...)函数代替React.createElement(…),那么我们也能使用JSX语法。其实,我们只需要在源文件头部加上这么一句注释:

    /** @jsx h */
    

    它实际上是告诉Babel:'哥们, 帮我编译JSX语法,用h(...)函数代替React.createElement(…),然后Babel就开始编译。
    因此,总结我之前说的,我们将用这样的方式去写我们的DOM树:

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

    Babel会帮我们编译成这样的代码:

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

    h(...)执行的之后,它将会返回纯的JS对象,即我们的虚拟DOM。

    运用Virtual DOM构建真实的DOM

    现在我们使用JS对象来表示DOM的结构,这非常酷,但是我们需要用它创建一个真实的DOM。

    首先,让我们做一些假设并设置一些术语。

    • 我会用带$的变量名来表示真实的DOM树, — 因此$parent将会是一个真实的DOM节点。
    • Virtual DOM在变量中使用node命名。
    • 就像在React中,你仅仅只有一个root节点,其他所有的节点都将会在它里面。

    如上所述,让我们来写一个createElement(…)函数把Virtual DOM转换成真实的DOM。

    因为我们有两种节点,text和element。因此我们的createElement函数需要处理这两种情况。

    让我们想一下,其实子节点要么是一个element,要么是一个text节点,是text节点的话,我们直接渲染:

    document.createTextNode(node)
    

    是element节点的话 需要递归地把它的子节点也构建起来:

    const $el = document.createElement(node.type)
    node
      .children
      .map(createElement)
      .forEach($el.appendChild.bind($el))
    

    createElement代码如下:

    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
    }
    

    现在的完整代码如下:

    <div id="root"></div>
    
    /** @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))
    

    WOW,是不是看起来很不错,让我们暂时先抛开props,我们稍后会谈到它。

    比较两棵虚拟DOM树的差异

    现在我们已经把virtual DOM转换成一棵真实的DOM树,是时候考虑下怎么比较两棵虚拟DOM树的差异了。最基本的,我们需要一个算法来比较新的树和旧的树,它能够让我们知道什么地方改变了,然后相应的去改变真实的DOM。

    怎么比较DOM树呢?我们需要处理下面的情况:

    • 添加新节点,我们需要用appendChild方法添加节点
    c1
    • 移除老节点,我们需要用removeChild方法移除老的节点
    c2
    • 节点的替换,我们需要用replaceChild方法
    c3
    • 节点相同,因此我们需要深度比较子节点
    c4

    让我们开始写updateElement方法,它需要传递3个参数:$parent, newNodeoldNode$parent是我们虚拟节点的真实的父级DOM元素。现在我们来看看怎么处理上面描述的所有的情况。

    添加新节点

    非常直接,我甚至都不需要写注释。

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

    移除老节点

    这里我们遇到一个问题 — 如果在新的Virtual DOM树里面没有某个节点,那我们应该在真实的DOM树移除它。但我们应该怎么做呢?

    如果我们已知父元素(通过参数传递),我们就能调用$parent.removeChild(…)方法把变化映射到真实的DOM上。但前提是我们得知道我们的节点在父元素上的索引,我们才能通过$parent.childNodes[index]得到该节点的引用。

    OK,让我们假设index将会通过参数传递(确实如此,稍后会看到),我们的代码如下:

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

    节点变化

    首先我们需要写一个函数比较旧树和新树的不同,告诉我们node真的改变了。我们需要考虑文本和元素这两种情况:

    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 — 它只是子节点数组的索引。
    
    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
          )
        }
      }
    }
    

    到此就基本完成了,当你点击Reload按钮的时候,你可以打开开发者工具观察元素的变化。

    你可以在这里找到所有的代码,github

    原文地址 How to write your own Virtual DOM

    相关文章

      网友评论

        本文标题:50行代码实现Virtual DOM

        本文链接:https://www.haomeiwen.com/subject/yiwonttx.html