书格前端

理解虚拟DOM


理解虚拟DOM

正文

我最近写了关于DOMshadow DOM具体是什么,以及他们的差异。回顾一下,文档对象模型是一个HTML文档基于对象的表示,和操作对象的一个接口。影子DOM(shadow DOM)可以想做是”精简版”的DOM。它也是HTML元素基于对象的表示,但不是完整独立的文档。相反的,影子DOM允许我们分离我们的DOM成为更小的、具备封装性的单元并在HTML文档中使用。

另一个相似的术语你可能遇见过的是“虚拟DOM”(virtual DOM)。尽管这个概念已经出现了许多年,让它变得流行起来的还是在React框架中的使用。在这篇文章中,我会涉及虚拟DOM具体是什么,和原始的DOM有什么差别,以及如何使用。

为什么我们需要虚拟DOM?

为了理解为什么会出现虚拟DOM,先回顾一下原始DOM。就如前面提到的,DOM中有两部分内容,基于对象的HTML文档标识和操作对象的API。

例如,让我们看一个无序列表和列表项的简单HTML文档

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

文档会像下面的DOM树进行表示

html
  |
  |-- head lang="en"
  |-- body
        |-- ul class="list"
            |--li class="list__item"
               |--"List item"

设想一下我们想要修改第一个列表项的内容为“List item one”,并且添加第二个列表项。为了做到这一点,我们需要使用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);

DOM无法解决的问题

当1998年DOM的第一版规范发布时,我们构建和管理网页与现在有很大的不同。那时并没有像现在这样依赖于DOM API来创建和更新页面内容。

document.getElementsByClassName()这样的简单方法小范围使用是好的,但是如果我们每几秒去更新页面中的多个元素时,这会让查询和更新DOM变得耗性能。

更进一步,由于API的组织方式,通常执行一些更耗性能的操作,例如更新文档的一大块内容比查找然后更新具体的元素要来得简单。回到我们的例子,某种程度上替换整个无序列表成新的比修改具体的元素要容易。

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

在这个例子中,两个方法之间的性能差异可能并不明显。但是,随着页面大小的增加,仅选择和修改必须的内容变得尤其重要。

虚拟DOM的解决方式

虚拟DOM的提出就是为了高效解决这些频繁更新DOM的难题。不像DOM或者影子DOM,虚拟DOM并不是官方的规范,而是和DOM交互的新方法。

一个虚拟DOM可以设想为原始DOM的一份拷贝。这份拷贝可以不使用DOM的API而进行频繁的操作和更新。一旦所有的更新都被应用到虚拟DOM后,我们可以找到要应用到原始DOM上的具体变化差异并通过高效和有针对性的进行更新。

虚拟DOM看起来像什么?

虚拟DOM的名字让这个概念给人有点神秘的感觉。实际上,虚拟DOM就是一个常规的JavaScript对象。

让我们回顾一下之前创建的DOM树:

html
  |
  |-- head lang="en"
  |-- body
        |-- ul class="list"
            |--li class="list__item"
               |--"List item"

这颗树可以通过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"
                        }
                    ]
                }
            ]
        }
    ]
}

我们可以设想这个对象作为我们的虚拟DOM。就像原始DOM,这也是基于对象的HTML文档表示。但是因为这是一个纯粹的JavaScript对象,我们可以自由和频繁的操作而不用接触实际的DOM,直到需要的时候。

通常使用多个小块的虚拟DOM,而不是使用一个虚拟DOM来表示整个对象。例如,我们在使用一个list组件,对应我们的无序列表元素。

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

虚拟DOM的工作原理

现在我们看到了虚拟DOM的结构,接下来看一下它是如何在DOM中解决性能和使用问题的。

就如我提到的,我们可以使用虚拟DOM找到需要应用到DOM中的具体变更内容,然后单独更新这些变更。让我们回到无序列表的例子并做一些之前使用DOM API操作的变更。

首先我们要创建一份虚拟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的变更内容(“diff”),在这里是列表和更新的列表。变更内容如下:

const diffs = [
    {
        newNode: { /* 新版的列表1 */ },
        oldNode: { /* 原始版本的列表1 */ },
        index: /* 在父元素的子节点列表中元素索引 */
    },
    {
        newNode: { /* 列表2 */ },
        index: { /* */ }
    }
]

变更内容提供了如何更新实际DOM的指南。一旦所有的变更内容都收集到了,我们就可以批量更新DOM,而且是仅更新必需的内容。

例如,我们可以遍历每一个的变更内容,然后添加一个新元素或者基于变更内容更新旧的内容。

const domElement = document.getElementsByClassName("list")[0];

diffs.forEach((diff) => {
    const newElement = document.createElement(diff.newNode.tagName);
    /* 添加属性 */
    
    if (diff.oldNode) {
        // 如果有旧的版本,用新的版本替换
        domElement.replaceChild(newElement, diff.index);
    } else {
        // 如果没有旧的版本,创建一个新的节点
        domElement.appendChild(newElement);
    }
})

注意这里只是简单讲述虚拟DOM是如何工作的,有很多案例并没有涉及到。

虚拟DOM和框架

通常虚拟DOM会和框架一同使用,而不是像上面这样直接操作。

像React和Vue这样的框架使用虚拟DOM来执行高效的DOM更新。例如,我们的list组件通过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()方法,传递一个新的列表。

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是一个可以让我们和DOM元素进行简单高效的交互工具。它是DOM的JavaScript对象表示,我们可以根据我们的需要频繁的修改。对于这个对象的修改会被整理后,有针对性的对实际DOM进行更新。