DOM, который построил Chrome. Или не построил? Или не Chrome? Или не DOM?

Обычный, теневой, виртуальный, инкрементальный… Как получилось, что простой программный интерфейс доступа к элементам веб-страниц обзавелся таким количеством «родственников»? Чем современные фреймворки не устраивает стандартная объектная модель документа или просто DOM? Что и как на самом деле отрисовывает браузер в процессе рендера веб-страницы?

Всем привет, это Макс Кравец из Holyweb. Помните сцену из Матрицы, в которой один из юных кандидатов в Избранные наставляет Нео: «Не пытайся согнуть ложку. Первое, что ты должен понять — ложки не существует!»? Давайте переформулирую: «Не пытайся изменить DOM…». А вот о том, что прячется под многоточием, мы сегодня и поговорим.

image-loader.svg

Фундамент. Как строится веб-страница. CRP

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

Построение дерева DOM

Получив с сервера документ, парсер браузера каждый тег превращает в узел и строит их иерархию

  
  
  
  
  
        

Давай построим DOM

В результате получается дерево узлов (node), или просто DOM-дерево, в котором вложенные элементы представлены в виде дочерних узлов с полным набор атрибутов:

html
  head
    link rel="stylesheet"
         href="style.css"
  body
    h1 Давай построим DOM

Исторически-лирическое отступление. Когда все только задумывалось, страницы рендерились из статики, каналы связи были неторопливыми, а пользователи — непритязательными. Документ мог грузиться довольно долго и практически не изменялся за время жизни в браузере. Для того, чтобы страница как можно скорее отвечала на действие пользователя, любое изменение DOM автоматически запускало процесс повторного рендеринга страницы. 

Но ведь добавление очередного узла в DOM в процессе парсинга — это тоже изменение? Так и есть. Стоит узлу попасть в объектную модель документа — страница перерисовывается. До сих пор! На практике это означает, что документ отрисовывается по частям, браузер даже не дожидается окончательния загрузки. Пользователи интернета со стажем помнят, как это выглядело. Ну, а сегодня можно в инструментах разработчика поставить качество связи Slow 3G на вкладке network и насладиться загрузкой какой-либо статической страницы. 

Нам как разработчикам важен сам факт — любое изменение физического DOM требует перерисовки страницы, а значит — времени и ресурсов.

Построение CSSOM-дерева

Хорошо, верстку мы получили, надо сделать ее красивой. Пробежимся по полученному ранее DOM и добавим каждому узлу соответствующие стили. Говоря языком документации — сформируем CSS object model или CSSOM.

Если в примере выше в файле style.css будет

body { font-size: 16px; }
h1 { font-size: 20px; }

то соответствующее CSSOM дерево будет выглядеть следующим образом:

html
  head
    link rel="stylesheet"
         href="style.css"
  body font-size: 16px
    h1  font-size: 20px
        Давай построим DOM

Еще одно отступление. CSS недаром называют Cascading Style Sheets — каскадными таблицами стилей. Чтобы применить стиль для конкретного узла, необходимо его посчитать, разобрав полностью всю таблицу стилей документа. Следовательно — на это время процесс рендера нужно приостановить. 

CSS является блокирующим ресурсом. Причем не только для верстки, но и для скриптов, которые также вынуждены дожидаться построения CSSOM для того, чтобы начать выполняться.

На практике это означает, что мы должны учитывать время, которое нужно для построения CSSOM при написании своего кода.

Хорошая новость — CSS блокирует рендер только при применении. Стили, указанные с помощью медиа-атрибута для мобильного разрешения, не являются блокирующими (и не разбираются) при рендере десктопной версии и наоборот, а стили для ландшафтной ориентации устройства не участвуют в рендере для портретной. 

Плохая новость — изменение размера окна браузера или поворот мобильного из одной ориентацию в другую может вызвать срабатывание медиа-атрибута и блокировку рендера страницы для пересчета CSS. Если забыть про этот нюанс, можно потерять немало времени на поиск ошибок не в том месте.

Запуск JavaScript

Ура, добрались! JavaScript — это наша альфа и омега, именно с его помощью мы делаем веб-страницы интерактивными — такими, к каким привык современный пользователь. Но за счет чего мы это делаем? За счет изменения DOM и стилей.

Стоп, скажете вы! И будете правы — именно стоп. Когда парсер доходит до тега Велосипед 12000 руб. Самокат 2000 руб.

В этом примере браузер берет элементы из обычного DOM и отображает их в соответствующих слотах теневого дерева.

Это открывает широкие возможности для создания собственных компонентов. Но как быть, когда хочется расширить возможности стандартных элементов?

Одна из проблем, с которыми приходится сталкиваться при динамическом добавлении CSS-свойств элементу через element.style — невозможно напрямую использовать медиазапросы или определить псевдоклассы и псевдоэлементы. Shadow DOM дает доступ к элементу контейнеру через селектор :: host

let myElement = document.querySelector('div');
myElement.attachShadow({
  mode: 'open',
});
myElement.shadowRoot.innerHTML = `


`;

Мы «подцепили» условному div-у реакцию на наведение мыши, при этом не создавали новые классы и не изменяли внешние стили.

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

Ограничения Shadow DOM:

  • создается только с помощью JS, а потому не существует возможности предварительного рендера (SSR). Данное ограничение можно обойти, но это тема для другой статьи

  • Требует внешнего контроля жизненного цикла компонентов и их инициализации во внешней среде

  • Из-за использования сайтами политик безопасности, существуют серьезные ограничения на добавление стилей к элементам внутри теневого DOM. Дело в том, что CSP (Content Security Policy) запрещает парсить стили из строки. Обойти это можно отключением политики безопасности, но при разработке виджета для стороннего сайта это, пожалуй, самое плохое решение. Более универсальные решения — создание динамических стилей через element.style или добавление в Shadow DOM внешнего css-файла

  • создание Shadow DOM, как и любого другое действе, требует выделения дополнительных ресурсов, а потому злоупотреблять им не стоит

  • отсутствует поддержка в IE

Беремся за второй этаж. Зачем понадобился Virtual DOM?

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

Представьте, что вам нужно выводить на странице список товаров



 
 
    
  • Товар

DOM для такой верстки:

html
  head lang="en"
    body
      ul class="product"
        li class="product__item"
          "Товар"

А теперь добавим в список самокат, а дефолтный товар заменим на велосипед. Для этого надо найти в DOM список, создать новый элемент, добавить в него контент, обновить контент в старом элементе списка, после чего обновить сам DOM

const productItemOne = document.getElementsByClassName("product__item")[0];
productItemOne.textContent = "Велосипед";

const productItemTwo = document.createElement("li");
productItemTwo.classList.add("product__item");
productItemTwo.textContent = "Самокат";

const product = document.getElementsByClassName("product")[0];
product.appendChild(productItemTwo);

При этом каждый раз, когда мы «дергаем» DOM API — запускается алгоритм пересчета изменений и рендера страницы. 

Откровенно говоря, проще заменить старый именованный список на новый

const product = document.getElementsByClassName("product")[0];
product.innerHTML = `
  • Велосипед
  • Самокат
  • `;

    В этом варианте мы выполнили всего одно обращение к DOM и единожды отрисовали страницу заново. А значит — выиграли в производительности.

    Хочу такой же, но с перламутровыми пуговицами!

    Как этого добиться? Сделать «копию» DOM, выполнить все нужные преобразования, и только когда все посчитано — обновить реальный DOM. Поздравляю, мы только что сформулировали основную идею Virtual DOM. 

    Давайте сформулируем и реализацию. В качестве копии — воспользуемся самым обычным JS-объектом. Для нашего примера со списком продуктов его можно представить как

    const vdom = {
        tagName: "html",
        children: [
            { tagName: "head" },
            {
                tagName: "body",
                children: [
                    {
                        tagName: "ul",
                        attributes: { "class": "product" },
                        children: [
                            {
                                tagName: "li",
                                attributes: { "class": "product__item" },
                                textContent: "Товар"
                            }
                        ]
                    }
                ]
            }
        ]
    }

    Первый плюс мы уже получили — нет нужды лишний раз перерисовывать страницу. А значит, уже выиграли в производительности.

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

    Но можно ли еще как-то усовершенствовать идею? Конечно! в DOM у нас складывается весь документ целиком, и его копия — довольно большой объект. Из которого нам в нашем случае нужен всего лишь один компонент!  

    Давайте сделаем следующий шаг: создадим для каждого компонента свой объект и будем работать с ними, как с некими разделами Virtual DOM. 

    const product = {
        tagName: "ul",
        attributes: { "class": "product" },
        children: [
            {
                tagName: "li",
                attributes: { "class": "product__item" },
                textContent: "Товар"
            }
        ]
    };

    Мы еще на порядок упростили работу с конкретным компонентом и вновь выиграли в производительности, поскольку взаимодействуем с небольшим объектом, а не с копией всего DOM в целом.

    Как работает виртуальный DOM

    Вернемся к нашему примеру и проделаем те же операции. Поскольку API браузера нас больше не ограничивает, мы просто создадим новый объект 

    const copy = {
        tagName: "ul",
        attributes: { "class": "product" },
        children: [
            {
                tagName: "li",
                attributes: { "class": "product__item" },
                textContent: "Велосипед"
            },
            {
                tagName: "li",
                attributes: { "class": "product__item" },
                textContent: "Самокат"
            }
        ]
    };

    и сравним исходный product и новый copy, выделив изменения.

    const diffs = [
        {
            newNode: { /* textContent: "Велосипед" */ },
            oldNode: { /* textContent: "Товар" */ },
            index: /* index of element in parent's nodes */
        },
        {
            newNode: { /* textContent: "Самокат" */ },
            index: { /* */ }
        }
    ]

    Остается пройтись циклом по диффам, обновить старые или добавить новые элементы

    const domElement = document.getElementsByClassName("product")[0];
    diffs.forEach((diff) => {
        const newElement = document.createElement(diff.newNode.tagName);
        
        if (diff.oldNode) {
            domElement.replaceChild(diff.newNode, diff.index);
        } else {
            domElement.appendChild(diff.newNode);
        }
    })

    Создать новый объект, посчитать его разницу с предыдущим и точечно обновить элементы — намного быстрее, чем добавить на страницу новую строку с версткой, вызвав тем самым ее полный пересчет и перерисовку.

    Давайте подведем промежуточный итог. Мы сформулировали принципиальную идею отказаться от работы непосредственно с DOM, пришли к тезису что удобнее работать с отдельным объектом для каждого компонента и предусмотрели механизм, который позволяет запустить рендер страницы. Разумеется, это не production  версия Virtual DOM, а только объяснение принципа его работы. В зависимости от того, какой фреймворк вы используете (и даже от его версии) — детали реализации могут отличаться.

    Virtual DOM — это объектное представления JavaScript, которое позволяет взаимодействовать с элементами DOM более простым и производительным способом. Объект-представление можно изменять так часто, как это необходимо, после завершения вычислений вызывается механизм сравнения изменений. В реальный DOM вносятся только финальные изменения, что происходит намного реже, не требует большого количества обращений к API браузера и, следовательно, повышает производительность.

    Для тех, кто готов достроить третий этаж

    Дошедшим до этого раздела уже должно быть понятно, почему современному фронтенд-разработчику не стоит пытаться «согнуть ложку», а точнее — пытаться общаться с DOM напрямую. Для этого есть более производительный и удобный Virtual DOM, предоставляемый фреймворками. 

    Но постойте, браузер-то умеет работать только со своим API, а наши компоненты — только с Virtual DOM. Нужен движок, «переводчик» пожеланий компонентов в инструкции, понятные браузеру. И этот движок мы должны загрузить в браузер вместе с самими компонентами. 

    Как уменьшить размер бандла?

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

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

    Давайте предложим компонентам обращаться не к движку рендеринга, а непосредственно к инструкциям. Пусть каждый компонент заранее определится, какие ему нужны, тогда на этапе компиляции приложения можно будет отсеять невостребованные. 

    Таким образом решается задача tree-shakable — уменьшения размера сборки, которая загружается в браузер за счет удаления неиспользуемых фрагментов кода.

    Поговорим о потреблении памяти

    Давайте еще раз посмотрим, как Virtual DOM осуществляет рендеринг. На первом шаге мы на базе реального DOM строим виртуальное дерево, следовательно — для каждого элемента нам нужно выделить какой-то объем памяти. Когда приходит время отрисовать новое состояние, мы строим новое виртуальное дерево, а значит — для каждого элемента повторно выделяется нужный объем памяти. Далее старое и новое виртуальные деревья сравниваются, и если есть разница — изменения применяются к физическому DOM. 

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

    image-loader.svg

    И снова — повод задуматься. Если у нас на какой-то момент времени есть существующее актуальное состояние DOM уже отрисованное браузером, и изменяется только один элемент — зачем нам строить новое виртуальное дерево целиком? Что, если можно было бы пробежаться по старому и взять из него для построения нового те элементы, которые не изменились? Тогда нам потребуется дополнительно выделять память только в случае создания нового узла или изменения старого.

    image-loader.svg

    Этот алгоритм реализует небольшая (всего 2,6 КБ) библиотека под названием Incremental DOM.

    Здравый вопрос — почему же тогда этот вариант до сих пор используется не всеми? Из «большой тройки» фреймворков, Incremental DOM применяется только в Angular, а React и Vue предпочитают старый добрый Virtual DOM. 

    Все дело в том, что Angular — единственный фреймворк большой тройки, изначально построенный на архитектуре с использованием template, когда все компоненты пишутся с использованием шаблонов. Посмотрим на пример функции renderPart () из документации библиотеки Incremental DOM

    function renderPart() {
      elementOpen('div');
        text('Hello world');
      elementClose('div');
    }

    и мысленно подставим на место elementOpen () и elementClose () —  selector компонента Angular, а на место text () — template.

    function renderPart() {
      elementOpen('some-component');
        text(`
        … some template html
    `);
      elementClose('some-component');
    }

    Для тех, кто добрался до финала

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

    Не пытайся изменить DOM… без причины. Первое, что нужно понять: DOM существует. Но его задача — обеспечить отрисовку страницы, заранее подготовленной с помощью других инструментов. Таких, как Shadow, Virtual и Incremental DOM. И современный разработчик должен знать, в чем они схожи, чем отличаются и как выбрать наиболее подходящее решение для конкретной задачи.

    Если есть чем дополнить — комментарии можно оставлять под текстом или мне в телеграм. 

    Другие наши статьи:  

    © Habrahabr.ru