Дерево документа: узлы и элементы

окружение≈ 7 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Страница, которую видит браузер, — не текст, а дерево объектов: каждый тег стал узлом, вложенность тегов стала вложенностью узлов. Разберёмся, из чего это дерево собрано, почему его вершина — document и откуда в нём берутся узлы, которых вы не писали.

Разделы темы:·

HTML приходит по сети плоской строкой. Браузер читает её слева направо и по ходу строит структуру: открывающий тег — новый узел, вложенные теги — его дети, текст между тегами — тоже узел. Результат называют DOM, Document Object Model: не картинка страницы и не её исходник, а дерево объектов, к каждому из которых можно обратиться из JavaScript.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Важное следствие: исходник и дерево — разные вещи. Незакрытый тег парсер закроет сам, <td> вне таблицы выбросит, а <div>, попавший внутрь <table> мимо ячейки, переставит перед таблицей. JavaScript видит не то, что вы написали, а то, что получилось после разбора.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

У дерева одна вершина — объект document. Сам он не элемент: тега у него нет, атрибутов тоже, зато есть ровно один ребёнок-элемент — <html>, он же document.documentElement. Внутри него привычные document.head и document.body.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отсюда двойная роль document: это и корень дерева, и точка входа в поиск. document.getElementById, document.querySelector, document.querySelectorAll ищут по всему документу. Те же querySelector есть и у любого элемента — тогда поиск идёт только внутри его поддерева.

Не всякий узел — тег. Элемент — узел с именем, атрибутами и детьми. Текстовый узел всегда лист: внутри него только строка, детей у него быть не может. Комментарий тоже полноценный узел: невидимый на экране, но в дереве присутствует и занимает своё место среди соседей. Различает их числовое свойство nodeType.

nodeTypeКонстантаЧто этоnodeName
1Node.ELEMENT_NODEэлемент — тег со своими детьмиимя тега заглавными: DIV, P, LI
3Node.TEXT_NODEтекст между тегами#text
8Node.COMMENT_NODEкомментарий#comment
9Node.DOCUMENT_NODEсам document, корень дерева#document
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обратите внимание на последнюю строку: textContent собирает текст всех потомков подряд, игнорируя теги и комментарии. Содержимое одного конкретного узла, без детей, отдаёт nodeValue — у текстового узла это его строка, у элемента всегда null.

Если текст между тегами становится узлом, то и перенос строки с отступом — тоже текст, а значит тоже узел. Аккуратно отформатированный список из двух пунктов даёт браузеру не два узла, а пять: пробельный текст, пункт, пробельный текст, пункт, пробельный текст.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Отсюда классическая ловушка: menu.firstChild — вовсе не первый пункт, а строка с переносом, и tagName у неё undefined. Уберёшь переносы в разметке — код внезапно заработает, переформатируешь шаблон — снова сломается. Ровно поэтому у каждого свойства навигации есть «элементный» двойник.

Правило простое: всё, где в названии нет слова Element, считает и пробелы, и комментарии. Нужны только теги — берите вариант с Element.

Пробелы, оказавшиеся перед <html>, парсер выбрасывает, а те, что стоят после </body>, приклеивает к концу body. Внутри же обычной разметки — в списках, таблицах, любых контейнерах — они сохраняются целиком, со всеми отступами.

Поэтому число пробельных узлов зависит от форматирования исходника: минификатор их вырежет, а сборка через шаблонную строку и innerHTML — наоборот, добавит. Код, который лезет в childNodes по индексам, ломается от одного нажатия «отформатировать файл».

Из любого узла можно шагнуть к родителю, детям и соседям. Свойств две серии: одна ходит по всем узлам подряд, вторая — только по элементам, пропуская текст и комментарии. Названия отличаются вставкой Element, а поведение — тем, споткнётесь вы о перенос строки или нет.

Все узлыТолько элементыЧто вернёт
parentNodeparentElementродителя
childNodeschildrenколлекцию детей
firstChildfirstElementChildпервого ребёнка
lastChildlastElementChildпоследнего ребёнка
previousSiblingpreviousElementSiblingсоседа слева
nextSiblingnextElementSiblingсоседа справа
js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

children и childNodes — не массивы, а коллекции, и они живые: это не снимок, а окно в дерево. Добавили ребёнка — переменная, полученная до этого, уже знает о нём.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

А вот querySelectorAll отдаёт статический NodeList — честный снимок на момент вызова. Разница больно кусается в циклах: перебирая живую коллекцию и удаляя из неё элементы, вы двигаете индексы под собой. Спасает Array.from(list.children) — копия, которую уже никто не подменит.

  • Браузер разбирает строку HTML в дерево объектов: теги становятся узлами, вложенность — вложенностью. Дерево может отличаться от исходника: парсер сам чинит незакрытые теги.
  • Вершина дерева — document (nodeType 9). Внутри него один элемент html, а в нём head и body.
  • Узлы бывают элементами (1), текстом (3) и комментариями (8); различает их nodeType, а textContent собирает текст всех потомков подряд.
  • Переносы строк и отступы между тегами — полноценные текстовые узлы, поэтому childNodes почти всегда длиннее children.
  • Навигация идёт двумя сериями: parentNode, childNodes, firstChild, nextSibling ходят по всем узлам, а варианты с Element — только по тегам. Коллекции children и childNodes живые.

1/3В разметке список ul с двумя li, каждый на своей строке и с отступом. Чему равно menu.childNodes.length?

2/3Чему равно document.nodeType?

3/3Чем children отличается от childNodes?

Перепись узлов

Напишите функцию countNodes(el), которая пробегает прямых детей элемента и возвращает объект { elements, texts, comments } — сколько среди них элементов, текстовых узлов и комментариев. Вложенные уровни считать не нужно.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пройтись по childNodes и разложить узлы по nodeType:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Числа 1, 3 и 8 работают точно так же, но именованные константы читаются лучше. Проверьте себя: если добавить в разметку переносы строк между тегами, счётчик texts вырастет — и это правильный ответ, а не ошибка.

Второй пункт без children

Получите текст второго пункта списка, пользуясь только «всеузловой» навигацией: firstChild и nextSibling. Свойства с Element в названии под запретом — задача как раз в том, чтобы вручную пропустить пробельные узлы.

js
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Идём от первого узла вправо и считаем только элементы:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Ровно эту работу за вас делает элементная серия свойств: menu.firstElementChild.nextElementSibling.textContent даёт тот же результат одной строкой, а menu.children[1] — ещё короче.