
HTML-дерево и DOM-дерево: почему браузер строит не то, что вы написали
ДБ
Дмитрий Борисенко
Есть распространённое мнение, что «HTML-дерева» не существует, а правильно говорить только «DOM-дерево». Это удобное упрощение, и ровно до тех пор, пока всё работает, оно никому не мешает.
Ломается оно в тот момент, когда вы пишете селектор table > tr, он не
срабатывает, и вы полчаса смотрите в код, где tr совершенно точно лежит внутри
table.
Деревьев на самом деле два. Первое — то, что вы написали в файле: теги и их вложенность. Второе — то, что браузер построил у себя в памяти, разобрав ваш файл. Обычно они совпадают. А когда не совпадают — начинается необъяснимое.
Что такое DOM-дерево
DOM (Document Object Model) — это представление документа в памяти браузера в
виде дерева объектов. Корень — document, у него единственный потомок html, у
того — head и body, дальше вложенность повторяет разметку.
Узлы бывают разных типов, и это первое расхождение с интуицией: узел — не только
тег. Текст внутри элемента — отдельный текстовый узел. Комментарий — тоже узел.
Поэтому у абзаца <p>Привет</p> есть дочерний узел, хотя тегов внутри нет.
Именно с DOM работает JavaScript: querySelector ищет по этому дереву,
appendChild меняет его, и стоит его изменить — браузер тут же перерисует
страницу. Исходный HTML-файл при этом остаётся нетронутым: вы правите модель, а
не документ на сервере.
Пока всё звучит как «DOM — это ваш HTML, только в памяти». Дальше начинаются отличия.
Браузер обязан построить дерево из чего угодно
Ключевое свойство HTML-парсера: он не имеет права упасть. XML-парсер на незакрытом теге останавливается и показывает ошибку — HTML-парсер обязан выдать дерево при любой разметке, даже при откровенно сломанной.
А раз падать нельзя, значит, нужно чинить. И вот как он это делает.
Достраивает то, чего вы не написали
Скормите браузеру одну строку:
<p>Привет</p>
В DOM окажется полноценный документ: html, внутри него head (пустой) и
body, и только внутри body — ваш абзац. Вы не писали ни одного из этих трёх
тегов, но они есть, потому что спецификация требует, чтобы они были.
То же самое с таблицами, и вот тут прячется тот самый селектор:
<table>
<tr>
<td>Ячейка</td>
</tr>
</table>
В DOM между table и tr появится tbody, которого вы не писали. Реальная
структура — table > tbody > tr. Поэтому селектор table > tr не находит
ничего: прямой потомок таблицы — tbody, а не строка. Работать будут table tr
(любой уровень вложенности) или честный table > tbody > tr.
Закрывает теги за вас
У части элементов закрывающий тег необязателен, и браузер сам решает, где они кончаются. Классика — абзац, внутри которого блочный элемент:
<p>
Текст
<div>Блок</div>
</p>
Внутри p не может лежать div. Встретив <div>, парсер молча закрывает абзац
и кладёт блок рядом, а не внутрь. А закрывающий </p>, которому теперь
нечего закрывать, превращается в пустой абзац.
В DOM это выглядит так:
p ← «Текст»
div ← «Блок», сосед абзаца, а не потомок
p ← пустой, взялся из </p>
Селектор p > div не сработает никогда. Отступы, заданные абзацу, до блока не
дойдут. А в вёрстке появится пустой <p>, который может неожиданно
поучаствовать в :nth-child и сдвинуть всё на одну позицию.
Выкидывает лишнее наружу
Внутри таблицы разрешены только строго определённые элементы. Всё постороннее парсер не удаляет и не оставляет на месте — он переносит его перед таблицей. У этого поведения есть официальное название: foster parenting, «передача на воспитание».
<table>
<div>Я тут случайно</div>
<tr>
<td>Ячейка</td>
</tr>
</table>
В DOM div окажется соседом таблицы сверху, а не внутри неё. Элемент
буквально уезжает в другое место документа — а в исходном коде он по-прежнему
выглядит вложенным.
Чинит перепутанную вложенность
<b>Жирный <i>жирный курсив</b> просто курсив</i>
Теги перекрываются крест-накрест, чего дерево не допускает в принципе: у узла
может быть только один родитель. Браузер разбирает это по алгоритму со служебным
названием adoption agency и достраивает недостающие узлы — в DOM появится второй
элемент i, которого вы не писали, чтобы курсив продолжился после закрытия b.
Ошибки нет, предупреждения нет. Просто в дереве стало на один элемент больше.
Третье дерево: то, что показывает DevTools
Отдельный источник путаницы. Вкладка Elements в DevTools показывает не ваш HTML. Она показывает текущий DOM — уже починенный парсером и уже изменённый JavaScript.
Отсюда вечное «в коде одно, в инспекторе другое»:
- React, Vue и любой другой фреймворк собирают разметку в рантайме — в исходном файле её нет вообще;
- скрипты аналитики дописывают свои узлы;
- ваш собственный код успел добавить класс или переставить элемент.
Чтобы увидеть именно то, что пришло с сервера, откройте исходник страницы
(Ctrl/Cmd + U, view-source:). Разница между этими двумя картинками — и есть
всё, что произошло с документом после загрузки.
Правило простое:
- HTML-дерево — что вы написали. Смотреть в редакторе или в
view-source. - DOM-дерево — что получилось у браузера. Смотреть в DevTools → Elements.
Почему на это стоит тратить внимание
Расхождение двух деревьев ломает вещи молча — без ошибок в консоли.
CSS-селекторы с прямым потомком. table > tr, p > div, ul > span —
любой > опирается на реальную структуру DOM. Если парсер вставил tbody или
вынес элемент наружу, селектор просто не совпадёт. Стиль не применится, и
выглядеть это будет как «CSS не работает».
Счётные псевдоклассы. :nth-child, :first-child, :last-child считают по
DOM. Один пустой абзац, родившийся из лишнего </p>, сдвигает нумерацию — и
полосатая таблица внезапно красится не в те строки. Подробнее про то, как эти
селекторы считают соседей, — в разборе
псевдоклассов и псевдоэлементов.
Доступность. Скринридер читает DOM, а не ваш файл. Заголовок, уехавший в
другое место из-за foster parenting, потеряет свою секцию. Разрыв в иерархии
заголовков (h1 → h3) сломает оглавление, по которому незрячий пользователь
ориентируется в странице.
JavaScript. querySelector, children, parentElement ходят по DOM. Код,
написанный по мотивам исходного HTML, будет промахиваться мимо узлов, которых в
дереве нет — или наоборот, спотыкаться о те, которых вы не создавали.
Как посмотреть дерево своей разметки
Проще всего — построить его и увидеть глазами. Вставьте разметку, и инструмент покажет вложенность тегов, иерархию заголовков и ошибки в именовании БЭМ-классов.
Проверка HTML: ошибки, валидность и структура
Вставьте код, адрес страницы или файл — проверим HTML на ошибки: валидность по W3C, линтинг разметки, семантику, доступность и порядок заголовков. Плюс дерево документа и отчёт в PDF.
Попробовать →
Дальше сравните с DevTools. Если структура совпала — ваш HTML корректен, и браузер ничего за вас не переписывал. Если разошлась — вы только что нашли причину бага, который иначе искали бы весь вечер.
Коротко
- HTML-дерево — это разметка, DOM-дерево — то, что из неё построил браузер. Обычно совпадают, но не обязаны.
- Парсер не имеет права упасть, поэтому он достраивает
html/head/bodyиtbody, закрывает забытые теги, выносит лишнее из таблиц наружу и распутывает перекрёстную вложенность. table > trне работает именно поэтому: между ними стоит невидимыйtbody.- DevTools показывает DOM (уже починенный и уже изменённый скриптами), а
view-source— исходный HTML. Разница между ними и есть всё интересное.



