我最近一直在研究 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 作为目标进行修改,这样的更新是最优的。