Что такое DOM?

Объектная модель документа, или "DOM", является программным интерфейсом доступа к элементам веб-страниц. По сути, это API страницы, позволяющий читать и манипулировать содержимым, структурой и стилями страницы. Давайте разберемся как это устроено и как это работает.

- Как строится веб-страница?

Процесс преобразования исходного HTML-документа в отображаемую стилизованную и интерактивную страницу, называется "критическим путем рендеринга" (Critical Rendering Path). Хотя этот процесс можно разбить на несколько этапов, эти этапы можно условно сгруппировать в два этапа. В первом браузер анализирует документ, чтобы определить, что в конечном итоге будет отображаться на странице, а во второй браузер выполняет рендеринг.

Результатом первого этапа является то, что называется "render tree" (дерево рендеринга). Дерево рендеринга - это представление элементов HTML, которые будут отображаться на странице, и связанных с ними стилей. Чтобы построить это дерево, браузеру нужны две вещи:

  • CSSOM, представление стилей, связанных с элементами
  • DOM, представление элементов

Из чего состоит DOM?

DOM - это объектное представление исходного HTML-документа. Он имеет некоторые различия, как мы увидим ниже, но по сути это попытка преобразовать структуру и содержание документа HTML в объектную модель, которая может использоваться различными программами.

Структура объектов DOM представлена тем, что называется "деревом узлов". Оно так называется, потому что его можно рассматривать как дерево с одним родительским элементом, который разветвляется на несколько дочерних ветвей, каждая из которых может иметь листья. В этом случае родительский "элемент" - это корневой элемент, дочерние "ветви" - это вложенные элементы, а "листья" - это содержимое внутри элементов.

Давайте рассмотрим этот HTML-документ в качестве примера:

<!doctype html>
    <html lang="en">
        <head>
            <title>My first web page</title>
        </head>
    <body>
        <h1>Hello, world!</h1>
        <p>How are you?</p>
    </body>
</html>

Этот документ может быть представлен в виде следующего дерева узлов:

Чем DOM не является

В приведенном выше примере кажется, что DOM является отображением 1:1 исходного HTML-документа. Однако, как я уже говорил, есть различия. Чтобы полностью понять, что такое DOM, нам нужно взглянуть на то, чем он не является.

- DOM не является копией исходного HTML

Хотя DOM создан из HTML-документа, он не всегда точно такой же. Есть два случая, в которых DOM может отличаться от исходного HTML.

1. Когда HTML содержит ошибки разметки

DOM - это интерфейс доступа к действительным (то есть уже отображаемым) элементам документа HTML. В процессе создания DOM, браузер сам может исправить некоторые ошибки в коде HTML.

Рассмотрим в качестве примера этот HTML-документ:

<!doctype html>
<html>
    Hello, world!
</html>

В документе отсутствуют элементы <head> и <body> , что является обязательным требованием для HTML. Но если мы посмотрим на получившееся дерево DOM, то увидим, что это было исправлено:

2. Когда DOM модифицируется кодом JavaScript

Помимо того, что DOM является интерфейсом для просмотра содержимого документа HTML, он сам также может быть изменен.

Мы можем, например, создать дополнительные узлы для DOM, используя Javascript.

var newParagraph = document.createElement("p");
var paragraphContent = document.createTextNode("I'm new!");

newParagraph.appendChild(paragraphContent);
document.body.appendChild(newParagraph);

Этот код изменит DOM, но изменения не отобразятся в документе HTML.

DOM - это не то, что вы видите в браузере (то есть, дерево рендеринга)

В окне просмотра браузера вы видите дерево рендеринга, которое, как я уже говорил, является комбинацией DOM и CSSOM. Чем отличается DOM от дерева рендеринга, так это то, что последнее состоит только из того, что в конечном итоге будет отображено на экране.

Поскольку дерево рендеринга имеет отношение только к тому, что отображается, оно исключает элементы, которые визуально скрыты. Например, элементы, у которых есть стили с display: none.

<!doctype html>
<html lang="en">
    <head></head>
    <body>
        <h1>Hello, world!</h1>
        <p style="display: none;">How are you?</p>
    </body>
</html>

DOM будет включать элемент <p>:

Однако дерево рендеринга и, следовательно, то, что видно в окне просмотра, не будет включено в этот элемент.

DOM - это не то, что отображается в DevTools

Это различие немного меньше, потому что инспектор элементов DevTools обеспечивает самое близкое приближение к DOM, которое мы имеем в браузере. Однако инспектор DevTools содержит дополнительную информацию, которой нет в DOM.

Лучший пример этого - псевдоэлементы CSS. Псевдоэлементы, созданные с использованием селекторов ::before и ::after, являются частью CSSOM и дерева рендеринга, но технически не являются частью DOM. Это связано с тем, что DOM создается только из исходного HTML-документа, не включая стили, примененные к элементу.

Несмотря на то, что псевдоэлементы не являются частью DOM, они есть в нашем инспекторе элементов devtools.

Резюме

DOM - это интерфейс к HTML-документу. Он используется браузерами как первый шаг к определению того, что визуализировать в окне просмотра, и кодом JavaScript для изменения содержимого, структуры или стиля страницы.

Хотя DOM похож на другие формы исходного документа HTML, он отличается по ряду причин:

  • Это всегда верный (валидный) HTML код
  • Это модель, которая может быть изменена с помощью JavaScript
  • В него не входят псевдоэлементы (такие как ::after)
  • В него входят скрытые элементы (такие как display: none)

Что такое Shadow DOM?

Все элементы и стили в HTML-документе и, следовательно, в DOM, находятся в одной большой глобальной области. Любой элемент на странице может быть доступен с помощью метода document.querySelector(), независимо от того, насколько глубоко он вложен в документ или где он находится. Точно так же CSS, примененный к документу, может выбрать любой элемент, независимо от того, где он находится.

Такое поведение может быть очень удобным, если мы хотим применить стили ко всему документу. Невероятно полезно иметь возможность выбирать каждый отдельный элемент на странице и устанавливать, например, их размеры одной строкой.

* { box-sizing: border-box }

С другой стороны, бывают случаи, когда элемент требует полной изоляции, и мы не хотим, чтобы на него влияли даже глобальные стили. Хорошим примером этого являются сторонние виджеты, такие как кнопка "Follow" для Twitter на некоторых страницах. Такая кнопка может отображаться как обычная ссылка.

Но если вы изучите этот элемент в DevTools, вы заметите, что кнопка является элементом <iframe>, который загружает небольшой документ со стилизованной кнопкой, которую вы на самом деле видите.

Это единственный способ, которым Twitter может гарантировать, что предполагаемый стиль их виджета останется незатронутым любым CSS в документе. Хотя существуют способы использования каскада стилей для достижения того же результата, но ни один другой метод не даст такой же гарантии, как <iframe>, хотя и он не идеален.

Shadow DOM был создан для обеспечения возможности изоляции и компонентизации непосредственно на веб-платформе без необходимости полагаться на такие инструменты, как <iframe>.

DOM внутри DOM

Вы можете думать о теневом DOM как о "DOM внутри DOM". Это собственное изолированное дерево DOM со своими элементами и стилями, полностью изолированное от исходного DOM.

Хотя только недавно его стали использовать программисты, теневой DOM годами использовался пользовательскими агентами для создания и оформления сложных компонентов, таких как элементы формы. Для примера возьмем элемент ввода диапазона. Чтобы создать его на странице, все, что нам нужно сделать, это добавить следующий элемент:

<input type="range">

Если мы посмотрим глубже, мы увидим, что этот один элемент <input> фактически состоит из нескольких меньших элементов <div> , управляющих дорожкой и ползунком.

Это делается с помощью теневого DOM. Элемент, который предоставляется HTML-документу как простой <input>, но в реальности он состоит из нескольких элементов и стилей, связанных с компонентом, которые не являются частью глобальной области видимости DOM.

Как работает shadow DOM

Чтобы проиллюстрировать, как работает теневой DOM, давайте воссоздаем кнопку Twitter "Follow", используя теневой DOM вместо <iframe>.

Сначала мы начнем с shadow host (теневого хоста). Это обычный элемент HTML в исходном DOM, к которому мы хотим присоединить новый теневой DOM. Для такого компонента, как кнопка "Follow", он также может содержать запасной элемент, который мы хотели бы отобразить, если JavaScript не был включен на странице или теневой DOM не поддерживается.

<span class="shadow-host">
    <a href="https://twitter.com/ireaderinokun">
        Follow @ireaderinokun
    </a>
</span>

Обратите внимание, что мы не просто использовали элемент <a> в качестве теневого хоста, поскольку некоторые элементы, в первую очередь интерактивные элементы, не могут быть теневыми хостами.

Чтобы прикрепить теневой DOM к нашему хосту, мы используем метод attachShadow().

const shadowEl = document.querySelector(".shadow-host");
const shadow = shadowEl.attachShadow({mode: 'open'});

Это создаст пустой shadow root (теневой корень) как дочерний элемент нашего теневого хоста. Теневой корень - это начало нового теневого DOM, так же, как <html> элемент является началом исходного DOM. Мы можем увидеть наш теневой корень в инспекторе devtools с помощью #shadow-root.

Хотя теперь дочерние элементы HTML видны в инспекторе, они больше не видны на странице, так как заработал теневой корень.

Далее мы хотим создать контент для формирования нашего нового теневого дерева. Чтобы создать нашу кнопку "Follow", нам нужен только новый элемент <a>, который будет почти таким же, как и у уже имеющейся резервной ссылки, но со значком.

const link = document.createElement("a");
link.href = shadowEl.querySelector("a").href;

link.innerHTML = `
    <span aria-label="Twitter icon"></span> 
    ${shadowEl.querySelector("a").textContent}
`;

Далее мы добавим этот новый элемент в нашу теневую DOM так же, как добавляем любой элемент в качестве дочернего элемента к другому с помощью метода appendChild().

shadow.appendChild(link);

Наконец, мы можем добавить несколько стилей, создав <style> элемент и добавив его к теневому корню.

const styles = document.createElement("style");

styles.textContent = `
a, span {
  vertical-align: top;
  display: inline-block;
  box-sizing: border-box;
}
a {
    height: 20px;
    padding: 1px 8px 1px 6px;
    background-color: #1b95e0;
    color: #fff;
    border-radius: 3px;
    font-weight: 500;
    font-size: 11px;
    font-family:'Helvetica Neue', Arial, sans-serif;
    line-height: 18px;
    text-decoration: none;   
}
a:hover {  background-color: #0c7abf; }
span {
    position: relative;
    top: 2px;
    width: 14px;
    height: 14px;
    margin-right: 3px;
    background: transparent 0 0 no-repeat;
    background-image: url(data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2072%2072%22%3E%3Cpath%20fill%3D%22none%22%20d%3D%22M0%200h72v72H0z%22%2F%3E%3Cpath%20class%3D%22icon%22%20fill%3D%22%23fff%22%20d%3D%22M68.812%2015.14c-2.348%201.04-4.87%201.744-7.52%202.06%202.704-1.62%204.78-4.186%205.757-7.243-2.53%201.5-5.33%202.592-8.314%203.176C56.35%2010.59%2052.948%209%2049.182%209c-7.23%200-13.092%205.86-13.092%2013.093%200%201.026.118%202.02.338%202.98C25.543%2024.527%2015.9%2019.318%209.44%2011.396c-1.125%201.936-1.77%204.184-1.77%206.58%200%204.543%202.312%208.552%205.824%2010.9-2.146-.07-4.165-.658-5.93-1.64-.002.056-.002.11-.002.163%200%206.345%204.513%2011.638%2010.504%2012.84-1.1.298-2.256.457-3.45.457-.845%200-1.666-.078-2.464-.23%201.667%205.2%206.5%208.985%2012.23%209.09-4.482%203.51-10.13%205.605-16.26%205.605-1.055%200-2.096-.06-3.122-.184%205.794%203.717%2012.676%205.882%2020.067%205.882%2024.083%200%2037.25-19.95%2037.25-37.25%200-.565-.013-1.133-.038-1.693%202.558-1.847%204.778-4.15%206.532-6.774z%22%2F%3E%3C%2Fsvg%3E);
}
`;

shadow.appendChild(styles);

- DOM против shadow DOM

В некотором смысле, теневой DOM является "облегченной" версией DOM. Как и DOM, он является представлением элементов HTML, которое используется для определения того, что следует отображать на странице, и позволяет изменять элементы. Но в отличие от DOM, теневой DOM не использует глобальные стили документа. Теневой DOM, как следует из названия, всегда присоединен к элементу в обычном DOM. Без базовых элементов DOM, теневой DOM не существует.


Что такое Virtual DOM?

Напомним, что Document Object Model (Объектная Модель Документа) - это объектное представление HTML документа и интерфейс для управления этим объектом. Shadow DOM можно рассматривать как "облегченную" версию DOM. Это также объектно-ориентированное представление элементов HTML, но Shadow DOM позволяет нам разделить основной DOM на меньшие изолированные части, которые можно использовать в документах HTML.

Другой похожий термин, это "Virtual DOM". Хотя эта концепция существует уже несколько лет, она стала более популярной благодаря использованию ее в различных фреймворках, таких как React, Vuejs и т.д..

Зачем нам нужен Virtual 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", а также добавить второй элемент списка. Для этого нам нужно будет использовать API DOM, чтобы найти элементы, которые мы хотим обновить, создать новые элементы, добавить атрибуты и контент, а затем, наконец, обновить сами элементы 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, мы создавали и управляли веб-страницами по-другому. API DOM использовался для создания и обновления содержимого страниц гораздо реже, чем это делается сегодня.

Простые методы, такие как 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 или Shadow DOM, Virtual 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"
                        } // end li
                    ]
                } // end ul
            ]
        } // end body
    ]
} // end html

Мы можем думать об этом объекте как о нашем виртуальном DOM. Как и исходный 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 API.

Первое, что мы сделаем, это сделаем копию виртуального DOM, содержащего изменения, которые мы хотим сделать. Поскольку нам не нужно использовать API DOM, мы фактически можем просто создать новый объект полностью.

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"
        }
    ]
};

Эта копия используется для создания того, что называется "diff" между исходным и виртуальным DOM, в данном случае исходным списком, и обновленным списком. Diff может выглядеть примерно так:

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. Как только все различия собраны, мы можем пакетно вносить изменения в DOM, делая только необходимые обновления.

Например, мы могли бы перебрать каждый diff и добавить нового потомка или обновить старого в зависимости от того, что указано в 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, и есть много случаев, которые я здесь не описал.

Виртуальный DOM и фреймворки

В реальности с виртуальным 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 против Virtual DOM

Напомним, что виртуальный DOM - это инструмент, который позволяет нам взаимодействовать с элементами DOM более простым и производительным способом. Это JavaScript-объектное представление DOM, которое мы можем изменять так часто, как нам нужно. Изменения, внесенные в этот объект, затем сопоставляются, а изменения в реальном DOM производятся намного реже.