[译] 认识虚拟 DOM

2,167 阅读5分钟

我最近一直在研究 DOM影子 DOM 究竟是什么,以及它们之间有何区别。

概括地说,文档对象模型(DOM)包含两部分;一是 HTML 文档基于对象的表示,二是操作该对象的一系列接口。影子 DOM 可以被认为是 DOM 的缩减版。它也是 HTML 元素基于对象的表示(推荐这篇神奇的Shadow DOM,能更好的理解影子 DOM),影子 DOM 能把 DOM 分离成更小封装位,并且能够跨 HTML 文档使用。

另外一个术语是“虚拟 DOM ”。虽然这个概念已存在很多年,但在 React 框架中的使用更受欢迎。在这篇文章中,我将详细阐述什么是虚拟 DOM 、它跟原始 DOM 的区别以及如何使用。

为什么我们需要虚拟 DOM ?

为了弄明白为什么虚拟 DOM 这个概念会出现,让我们重新审视原始 DOM 。正如上面提到的,DOM 有两部分 —— HTML 文档的对象表示和一系列操作接口。

举个 :chestnut::

<!doctype html>
<html lang="en">
 <head></head>
 <body>
    <ul class="list">
        <li class="list__item">List item</li>
    </ul>
  </body>
</html>

上面是一个只包含一条数据的无序列表,能够转成下面的 DOM 对象:

假设我们想要将第一个列表项的内容修改为“列出项目一”,并添加第二个列表项。为此,我们需要使用 DOM API 来查找我们想要更新的元素,创建新元素,添加属性和内容,然后最终更新 DOM 元素本身。

const listItemOne = document.getElementsByClassName("list__item")[0];
listItemOne.textContent = "List item one";
const list = document.getElementsByClassName("list")[0];
const listItemTwo = document.createElement("li");
listItemTwo.classList.add("list__item");
listItemTwo.textContent = "List item two";
list.appendChild(listItemTwo);

我们现在创建网页的方式跟1998年发行的第一版 DOM 不同,他们不像我们今天这么频繁的依赖 DOM API。

举例一些简单的方法,比如 document.getElementsByClassName() 可以小规模使用,但如果每秒更新很多元素,这非常消耗性能。

更进一步,由于 API 的设置方式,一次性更新大篇文档会比查找和更新特定的文档更节省性能。回到前面列表的 :chestnut:

const list = document.getElementsByClassName("list")[0];
list.innerHTML = `<li class="list__item">List item one</li>
  <li class="list__item">List item two</li>`;

替换整个无序列表会比修改特定元素更好。在这个特定的 :chestnut: ,上述两种方法性能差异可能是微不足道的。但是,随着网页规模不断增大,这种差异会越来越明显。

什么是虚拟 DOM ?

创建虚拟 DOM 是为了更高效、频繁地更新 DOM 。与 DOM 或 shadow DOM 不同,虚拟 DOM 不是官方规范,而是一种与 DOM 交互的新方法。

虚拟 DOM 被认为是原始 DOM 的副本。此副本可被频繁地操作和更新,而无需使用 DOM API。一旦对虚拟 DOM 进行了所有更新,我们就可以查看需要对原始 DOM 进行哪些特定更改,最后以目标化和最优化的方式进行更改。

“虚拟 DOM ”这个名称往往会增加这个概念实际上的神秘面纱。实际上,虚拟 DOM 只是一个常规的 Javascript 对象。

回顾之前的 DOM 树:

上述这颗树可以用下面的 Javascript 对象表示:

const vdom = {
    tagName: "html",
    children: [
        { tagName: "head" },
        {
            tagName: "body",
            children: [
                {
                    tagName: "ul",
                    attributes: { "class": "list" },
                    children: [
                        {
                            tagName: "li",
                            attributes: { "class": "list__item" },
                            textContent: "List item"
                        } // end li
                    ]
                } // end ul
            ]
        } // end body
    ]
} // end html

与原始DOM一样,它是我们的 HTML 文档基于对象的表示。因为它是一个简单的 Javascript 对象,我们可以随意并频繁地操作它,而无须触及真实的 DOM 。

不一定要使用整个对象,更常见是使用小部分的虚拟 DOM 。例如,我们可以处理列表组件,它将对无序列表元素进行相应的处理。

const list = {
    tagName: "ul",
    attributes: { "class": "list" },
    children: [
        {
            tagName: "li",
            attributes: { "class": "list__item" },
            textContent: "List item"
        }
    ]
};

虚拟 DOM 的原理

现在我们已经知道了虚拟 DOM 是什么,但它是如何解决操作 DOM 的性能问题呢?

正如我所提到的,我们可以使用虚拟 DOM 来挑选出需要对 DOM 进行的特定更改,并单独进行这些特定更新。回到无序列表示的例子,并使用虚拟 DOM 进行相同的更改。

我们要做的第一件事是制作虚拟 DOM 的副本,其中包含我们想要的修改。我们无须使用 DOM API,因此我们只需创建一个新对象。

const copy = {
    tagName: "ul",
    attributes: { "class": "list" },
    children: [
        {
            tagName: "li",
            attributes: { "class": "list__item" },
            textContent: "List item one"
        },
        {
            tagName: "li",
            attributes: { "class": "list__item" },
            textContent: "List item two"
        }
    ]
};

此副本用于在原始虚拟 DOM(在本例中为列表)和更新的虚拟 DOM 之间创建所谓的“差异”。差异可能看起来像这样:

const diffs = [
    {
        newNode: { /* new version of list item one */ },
        oldNode: { /* original version of list item one */ },
        index: /* index of element in parent's list of child nodes */
    },
    {
        newNode: { /* list item two */ },
        index: { /* */ }
    }
]

上述对象提供了节点数据更新前后的差异。一旦收集了所有差异,我们就可以批量更改 DOM,并只做所需的更新。

例如,我们可以循环遍历每个差异,并根据 diff 指定的内容添加新的子代或更新旧的子代。

const domElement = document.getElementsByClassName("list")[0];
diffs.forEach((diff) => {

    const newElement = document.createElement(diff.newNode.tagName);
    /* Add attributes ... */

    if (diff.oldNode) {
        // If there is an old version, replace it with the new version
        domElement.replaceChild(diff.newNode, diff.index);
    } else {
        // If no old version exists, create a new node
        domElement.appendChild(diff.newNode);
    }
})

框架

通过框架使用虚拟 DOM 更常见。诸如 React 和 Vue 之类的框架使用虚拟 DOM 概念来对 DOM 进行更高效的更新。例如,我们的列表组件可以用以下方式用 React 编写。

import React from 'react';
import ReactDOM from 'react-dom';
const list = React.createElement("ul", { className: "list" },
    React.createElement("li", { className: "list__item" }, "List item")
);
ReactDOM.render(list, document.body);

如果我们要更新列表,重写整个列表模板,并调用 ReactDOM.render()

const newList = React.createElement("ul", { className: "list" },
    React.createElement("li", { className: "list__item" }, "List item one"),
    React.createElement("li", { className: "list__item" }, "List item two");
);
setTimeout(() => ReactDOM.render(newList, document.body), 5000);

因为 React 使用虚拟 DOM ,即使我们重新渲染整个模板,也只更新实际存在差异的部分。

总结

回顾一下,虚拟 DOM 是一种工具,使我们能够以更简单,更高效的方式与 DOM 元素进行交互。它是 DOM 的 Javascript 对象表示,我们可以根据需求随时修改。然后整理对该对象所做的所有修改,并以实际 DOM 作为目标进行修改,这样的更新是最优的。

原文链接