虚拟DOM详解

转自:https://www.jianshu.com/p/cbb7d7094fb9


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



作者:mervynYang
链接:https://www.jianshu.com/p/cbb7d7094fb9
來源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
阅读更多
想对作者说点什么?

博主推荐

换一批

没有更多推荐了,返回首页