前端大杂烩

构建一个虚拟 DOM

2022-05-27  本文已影响0人  lio_zero

本文已整理到 Github,地址 👉 blog

如果我的内容帮助到了您,欢迎点个 Star 🎉🎉🎉 鼓励鼓励 :) ~~

我希望我的内容可以帮助你。现在我专注于前端领域,但我也将分享我在有限的时间内看到和感受到的东西。


本文译自 How to write your own Virtual DOM,为了更好的理解 Virtual DOM 的基本概念,不增加其复杂性,它并没有设置 props、处理事件等,如果想要了解这些内容,可以查看第二部分 Write your Virtual DOM 2: Props & Events

构建自己的虚拟 DOM 需要了解两件事。

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

在这里你可以注意到两件事:

{ type: '...', props: { ... }, children: [ ... ] }

但以这种方式书写大树是相当困难的。所以我们需要编写一个辅助函数,这样我们就可以更容易理解它的结构:

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 pragma。我们只需要在源文件的顶部包含类似注释行:

/** @jsx h */

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

它实际上告诉 Babel,转译 jsx 而不是 React.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 表示

我们已经将 DOM 树表示为普通的 JS 对象,并具有了一个清晰的结构。

接下来,我们需要以某种方式从中创建一个真实的 DOM。因为我们不能只是将我们的虚拟 DOM 表示附加到 DOM 中。

在开始之前,我们需要先说明一些事情:

这里,我们编写一个函数 createElement(...),它将获取一个虚拟 DOM 节点并返回一个真实的 DOM 节点:

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

因为我们可以有两个文本节点,即纯 JS 字符串和元素,它们都是 JS 对象类型,比如:

{ type: '...', props: { ... }, 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
}

效果如下:

转为真实 DOM

处理变化

现在我们可以把虚拟 DOM 变成真正的 DOM,是时候考虑区分我们的虚拟树了。我们需要编写一个算法,它将比较两个虚拟树(新旧树),并只对真实 DOM 进行必要的更改。

如何区分树?往下看一个示例:

image.png image.png image.png image.png

让我们编写一个名为 updateElement(...) 的函数,它接受三个参数:$parentnewNodeoldNode,其中 $parent 是一个真实的 DOM 元素,是虚拟节点的父元素。现在我们将了解如何处理上述所有情况。

没有旧节点

没有旧节点时,添加新节点:

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

不同的 children

最后,但并非最不重要的是,我们应该遍历这两个节点的每一个子节点并对它们进行比较,实际上为它们分别调用 updateElement(...)。是的,再次递归。

但是在编写代码之前,有一些事情需要考虑:

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

以上就是虚拟 DOM 的实现。

点击此处查看示例

上一篇 下一篇

猜你喜欢

热点阅读