Содержание

DOM-дерево и навигация по нему

DOM-дерево

Основой HTML-документа являются теги. В соответствии с объектной моделью документа («Document Object Model», коротко DOM), каждый HTML-тег является объектом. Вложенные теги являются «детьми» родительского элемента. Текст, который находится внутри тега, также является объектом.

Все эти объекты доступны при помощи JavaScript, мы можем использовать их для изменения страницы.

Например, document.body – объект для тега <body>.

Если запустить этот код, то <body> станет красным на 3 секунды:

document.body.style.background = 'red'; // сделать фон красным

setTimeout(() => document.body.style.background = '', 3000); // вернуть назад

Это был лишь небольшой пример того, что может DOM. Скоро мы изучим много способов работать с DOM, но сначала нужно познакомиться с его структурой.

Пример DOM

Начнём с такого, простого, документа:

<!DOCTYPE HTML>
<html>
<head>
  <title>О лосях</title>
</head>
<body>
  Правда о лосях.
</body>
</html>

DOM – это представление HTML-документа в виде дерева тегов. Вот как оно выглядит:

На рисунке выше узлы-элементы можно кликать, и их дети будут скрываться и раскрываться.

Каждый узел этого дерева – это объект.

Теги являются узлами-элементами (или просто элементами). Они образуют структуру дерева: <html> – это корневой узел, <head> и <body> его дочерние узлы и т.д.

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

Например, в теге <title> есть текстовый узел "О лосях".

Обратите внимание на специальные символы в текстовых узлах:

  • перевод строки: (в JavaScript он обозначается как \n)
  • пробел:

Пробелы и переводы строки – это полноправные символы, как буквы и цифры. Они образуют текстовые узлы и становятся частью дерева DOM. Так, в примере выше в теге <head> есть несколько пробелов перед <title>, которые образуют текстовый узел #text (он содержит в себе только перенос строки и несколько пробелов).

Существует всего два исключения из этого правила:

  1. По историческим причинам пробелы и перевод строки перед тегом <head> игнорируются
  2. Если мы записываем что-либо после закрывающего тега </body>, браузер автоматически перемещает эту запись в конец body, поскольку спецификация HTML требует, чтобы всё содержимое было внутри <body>. Поэтому после закрывающего тега </body> не может быть никаких пробелов.

В остальных случаях всё просто – если в документе есть пробелы (или любые другие символы), они становятся текстовыми узлами дерева DOM, и если мы их удалим, то в DOM их тоже не будет.

Здесь пробельных текстовых узлов нет:

<!DOCTYPE HTML>
<html><head><title>О лосях</title></head><body>Правда о лосях.</body></html>

Пробелы по краям строк и пробельные текстовые узлы скрыты в инструментах разработки

Когда мы работаем с деревом DOM, используя инструменты разработчика в браузере (которые мы рассмотрим позже), пробелы в начале/конце текста и пустые текстовые узлы (переносы строк) между тегами обычно не отображаются.

Таким образом инструменты разработки экономят место на экране.

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

Автоисправление

Если браузер сталкивается с некорректно написанным HTML-кодом, он автоматически корректирует его при построении DOM.

Например, в начале документа всегда должен быть тег <html>. Даже если его нет в документе – он будет в дереве DOM, браузер его создаст. То же самое касается и тега <body>.

Например, если HTML-файл состоит из единственного слова "Привет", браузер обернёт его в теги <html> и <body>, добавит необходимый тег <head>, и DOM будет выглядеть так:

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

Есть такой документ с незакрытыми тегами:

<p>Привет
<li>Мама
<li>и
<li>Папа

…Но DOM будет нормальным, потому что браузер сам закроет теги и восстановит отсутствующие детали:

Таблицы всегда содержат <tbody>

Важный «особый случай» – работа с таблицами. По стандарту DOM у них должен быть <tbody>, но в HTML их можно написать (официально) без него. В этом случае браузер добавляет <tbody> в DOM самостоятельно.

Для такого HTML:

<table id="table"><tr><td>1</td></tr></table>

DOM-структура будет такой:

Видите? Из пустоты появился <tbody>, как будто документ и был таким. Важно знать об этом, иначе при работе с таблицами возможны сюрпризы.

Другие типы узлов

Есть и некоторые другие типы узлов, кроме элементов и текстовых узлов.

Например, узел-комментарий:

<!DOCTYPE HTML>
<html>
<body>
  Правда о лосях.
  <ol>
    <li>Лось -- животное хитрое</li>
    <!-- комментарий -->
    <li>...и коварное!</li>
  </ol>
</body>
</html>

Здесь мы видим узел нового типа – комментарий, обозначенный как #comment, между двумя текстовыми узлами.

Казалось бы – зачем комментарий в DOM? Он никак не влияет на визуальное отображение. Но есть важное правило: если что-то есть в HTML, то оно должно быть в DOM-дереве.

Все, что есть в HTML, даже комментарии, является частью DOM.

Даже директива <!DOCTYPE...>, которую мы ставим в начале HTML, тоже является DOM-узлом. Она находится в дереве DOM прямо перед <html>. Мы не будем рассматривать этот узел, мы даже не рисуем его на наших диаграммах, но он существует.

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

Существует 12 типов узлов. Но на практике мы в основном работаем с 4 из них:

  1. document – «входная точка» в DOM.
  2. узлы-элементы – HTML-теги, основные строительные блоки.
  3. текстовые узлы – содержат текст.
  4. комментарии – иногда в них можно включить информацию, которая не будет показана, но доступна в DOM для чтения JS.

Поэкспериментируйте сами

Чтобы посмотреть структуру DOM в реальном времени, попробуйте Live DOM Viewer. Просто введите что-нибудь в поле, и ниже вы увидите, как меняется DOM.

Другой способ исследовать DOM – это использовать инструменты разработчика браузера. Это то, что мы каждый день делаем при разработке.

Для этого откройте страницу elks.html, включите инструменты разработчика и перейдите на вкладку Elements.

Выглядит примерно так:

https://learn.javascript.ru/article/dom-nodes/elks@2x.png

Вы можете увидеть DOM, понажимать на элементы, детально рассмотреть их и так далее.

Обратите внимание, что структура DOM в инструментах разработчика отображается в упрощённом виде. Текстовые узлы показаны как простой текст. И кроме пробелов нет никаких «пустых» текстовых узлов. Ну и отлично, потому что большую часть времени нас будут интересовать узлы-элементы.

Клик по этой кнопке в левом верхнем углу инспектора позволяет при помощи мыши (или другого устройства ввода) выбрать элемент на веб-странице и «проинспектировать» его (браузер сам найдёт и отметит его во вкладке Elements). Этот способ отлично подходит, когда у нас огромная HTML-страница (и соответствующий ей огромный DOM), и мы хотим увидеть, где находится интересующий нас элемент.

Есть и другой способ сделать это: можно кликнуть на странице по элементу правой кнопкой мыши и в контекстном меню выбрать «Inspect».

https://learn.javascript.ru/article/dom-nodes/inspect@2x.png

В правой части инструментов разработчика находятся следующие подразделы:

  • Styles – здесь мы видим CSS, применённый к текущему элементу: правило за правилом, включая встроенные стили (выделены серым). Почти всё можно отредактировать на месте, включая размеры, внешние и внутренние отступы.
  • Computed – здесь мы видим итоговые CSS-свойства элемента, которые он приобрёл в результате применения всего каскада стилей (в том числе унаследованные свойства и т.д.).
  • Event Listeners – в этом разделе мы видим обработчики событий, привязанные к DOM-элементам (мы поговорим о них в следующей части учебника).
  • … и т.д.

Лучший способ изучить инструменты разработчика – это прокликать их. Большинство значений можно менять и тут же смотреть результат.

Взаимодействие с консолью

При работе с DOM нам часто требуется применить к нему JavaScript. Например: получить узел и запустить какой-нибудь код для его изменения, чтобы посмотреть результат. Вот несколько подсказок, как перемещаться между вкладками Elements и Console.

Для начала:

  1. На вкладке Elements выберите первый элемент <li>.
  2. Нажмите Esc – прямо под вкладкой Elements откроется Console.

Последний элемент, выбранный во вкладке Elements, доступен в консоли как $0; предыдущий, выбранный до него, как $1 и т.д.

Теперь мы можем запускать на них команды. Например $0.style.background = 'red' сделает выбранный элемент красным, как здесь:

https://learn.javascript.ru/article/dom-nodes/domconsole0@2x.png

Это мы посмотрели как получить узел из Elements в Console.

Есть и обратный путь: если есть переменная node, ссылающаяся на DOM-узел, можно использовать в консоли команду inspect(node), чтобы увидеть этот элемент во вкладке Elements.

Или мы можем просто вывести DOM-узел в консоль и исследовать «на месте», как document.body ниже:

https://learn.javascript.ru/article/dom-nodes/domconsole1@2x.png

Это может быть полезно для отладки. В следующей главе мы рассмотрим доступ и изменение DOM при помощи JavaScript.

Инструменты разработчика браузера отлично помогают в разработке: мы можем исследовать DOM, пробовать с ним что-то делать и смотреть, что идёт не так.

Пример

Вот пример небольшого проекта на HTML, CSS и JavaScript, который демонстрирует работу с DOM (Document Object Model):

HTML

Создадим базовую HTML-структуру с кнопкой и пустым элементом для отображения текста.

<!DOCTYPE html>
<html>
<head>
    <title>Пример работы с DOM</title>
</head>
<body>
    <button id="myButton">Нажми меня</button>
    <p id="displayText">Здесь появится текст</p>

    <script src="script.js"></script>
</body>
</html>

CSS

Добавим немного стилей для кнопки и текстового элемента.

button {
    background-color: lightblue;
    border: none;
    padding: 10px 20px;
    margin: 10px 0;
    cursor: pointer;
}

#displayText {
    color: green;
}

JavaScript

Теперь добавим JavaScript для обработки клика по кнопке и изменения текста в элементе <p>.

document.getElementById("myButton").addEventListener("click", function() {
    document.getElementById("displayText").innerHTML = "Привет, мир!";
});

Этот простой пример демонстрирует, как можно манипулировать элементами DOM с помощью JavaScript. При нажатии на кнопку, текст в параграфе изменится на “Привет, мир!”.

Итого

HTML/XML документы представлены в браузере в виде DOM-дерева.

  • Теги становятся узлами-элементами и формируют структуру документа.
  • Текст становится текстовыми узлами.
  • … и т.д. Всё, что записано в HTML, есть и в DOM-дереве, даже комментарии.

Для изменения элементов или проверки DOM-дерева мы можем использовать инструменты разработчика в браузере.

Здесь мы рассмотрели основы, наиболее часто используемые и важные действия для начала разработки. Подробную документацию по инструментам разработки Chrome Developer Tools можно найти на странице https://developers.google.com/web/tools/chrome-devtools. Лучший способ изучить инструменты – походить по разным вкладкам, почитать меню: большинство действий очевидны для пользователя. Позже, когда вы немного их изучите, прочитайте документацию и узнайте то, что осталось.

У DOM-узлов есть свойства и методы, которые позволяют выбирать любой из элементов, изменять, перемещать их на странице и многое другое. Мы вернёмся к ним в последующих разделах.

Навигация по DOM-элементам

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

Все операции с DOM начинаются с объекта document. Это главная «точка входа» в DOM. Из него мы можем получить доступ к любому узлу.

Так выглядят основные ссылки, по которым можно переходить между узлами DOM:

Поговорим об этом подробнее.

Сверху: documentElement и body

Самые верхние элементы дерева доступны как свойства объекта document:

<html> = document.documentElement

Самый верхний узел документа: document.documentElement. В DOM он соответствует тегу <html>.

<body> = document.body

Другой часто используемый DOM-узел – узел тега <body>: document.body.

<head> = document.head

Тег <head> доступен как document.head.

Есть одна тонкость: document.body может быть равен null

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

В частности, если скрипт находится в <head>, document.body в нём недоступен, потому что браузер его ещё не прочитал.

Поэтому, в примере ниже первый alert выведет null:

<html>

<head>
  <script>
    alert( "Из HEAD: " + document.body ); // null, <body> ещё нет
  </script>
</head>

<body>

  <script>
    alert( "Из BODY: " + document.body ); // HTMLBodyElement, теперь он есть
  </script>

</body>
</html>

В мире DOM null означает «не существует»

В DOM значение null значит «не существует» или «нет такого узла».

Дети: childNodes, firstChild, lastChild

Здесь и далее мы будем использовать два принципиально разных термина:

  • Дочерние узлы (или дети) – элементы, которые являются непосредственными детьми узла. Другими словами, элементы, которые лежат непосредственно внутри данного. Например, <head> и <body> являются детьми элемента <html>.
  • Потомки – все элементы, которые лежат внутри данного, включая детей, их детей и т.д.

В примере ниже детьми тега <body> являются теги <div> и <ul> (и несколько пустых текстовых узлов):

<html>
<body>
  <div>Начало</div>

  <ul>
    <li>
      <b>Информация</b>
    </li>
  </ul>
</body>
</html>

…А потомки <body>– это и прямые дети <div>, <ul> и вложенные в них: <li> (ребёнок <ul>) и <b> (ребёнок <li>) – в общем, все элементы поддерева.

Коллекция childNodes содержит список всех детей, включая текстовые узлы.

Пример ниже последовательно выведет детей document.body:

<html>
<body>
  <div>Начало</div>

  <ul>
    <li>Информация</li>
  </ul>

  <div>Конец</div>

  <script>
    for (let i = 0; i < document.body.childNodes.length; i++) {
      alert( document.body.childNodes[i] ); // Text, DIV, Text, UL, ..., SCRIPT
    }
  </script>
  ...какой-то HTML-код...
</body>
</html>

Обратим внимание на маленькую деталь. Если запустить пример выше, то последним будет выведен элемент <script>. На самом деле, в документе есть ещё «какой-то HTML-код», но на момент выполнения скрипта браузер ещё до него не дошёл, поэтому скрипт не видит его.

Свойства firstChild и lastChild обеспечивают быстрый доступ к первому и последнему дочернему элементу.

Они, по сути, являются всего лишь сокращениями. Если у тега есть дочерние узлы, условие ниже всегда верно:

elem.childNodes[0] === elem.firstChild
elem.childNodes[elem.childNodes.length - 1] === elem.lastChild

Для проверки наличия дочерних узлов существует также специальная функция elem.hasChildNodes().

DOM-коллекции

Как мы уже видели, childNodes похож на массив. На самом деле это не массив, а коллекция – особый перебираемый объект-псевдомассив.

И есть два важных следствия из этого:

  1. Для перебора коллекции мы можем использовать for..of:
for (let node of document.body.childNodes) {
  alert(node); // покажет все узлы из коллекции
}

Это работает, потому что коллекция является перебираемым объектом (есть требуемый для этого метод Symbol.iterator).

  1. Методы массивов не будут работать, потому что коллекция – это не массив:
alert(document.body.childNodes.filter); // undefined (у коллекции нет метода filter!)

Первый пункт – это хорошо для нас. Второй – бывает неудобен, но можно пережить. Если нам хочется использовать именно методы массива, то мы можем создать настоящий массив из коллекции, используя Array.from:

alert( Array.from(document.body.childNodes).filter ); // сделали массив

DOM-коллекции – только для чтения

DOM-коллекции, и даже более – все навигационные свойства, перечисленные в этой главе, доступны только для чтения.

Мы не можем заменить один дочерний узел на другой, просто написав childNodes[i] = ....

Для изменения DOM требуются другие методы. Мы увидим их в следующей главе.

DOM-коллекции живые

Почти все DOM-коллекции, за небольшим исключением, живые. Другими словами, они отражают текущее состояние DOM.

Если мы сохраним ссылку на elem.childNodes и добавим/удалим узлы в DOM, то они появятся в сохранённой коллекции автоматически.

Не используйте цикл for..in для перебора коллекций

Коллекции перебираются циклом for..of. Некоторые начинающие разработчики пытаются использовать для этого цикл for..in.

Не делайте так. Цикл for..in перебирает все перечисляемые свойства. А у коллекций есть некоторые «лишние», редко используемые свойства, которые обычно нам не нужны:

<body>
<script>
  // выводит 0, 1, length, item, values и другие свойства.
  for (let prop in document.body.childNodes) alert(prop);
</script>
</body>

Соседи и родитель

Соседи – это узлы, у которых один и тот же родитель.

Например, здесь <head> и <body> соседи:

<html>
  <head>...</head><body>...</body>
</html>
  • говорят, что <body> – «следующий» или «правый» сосед <head>
  • также можно сказать, что <head> «предыдущий» или «левый» сосед <body>.

Следующий узел того же родителя (следующий сосед) – в свойстве nextSibling, а предыдущий – в previousSibling.

Родитель доступен через parentNode.

Например:

// родителем <body> является <html>
alert( document.body.parentNode === document.documentElement ); // выведет true

// после <head> идёт <body>
alert( document.head.nextSibling ); // HTMLBodyElement

// перед <body> находится <head>
alert( document.body.previousSibling ); // HTMLHeadElement

Навигация только по элементам

Навигационные свойства, описанные выше, относятся ко всем узлам в документе. В частности, в childNodes находятся и текстовые узлы и узлы-элементы и узлы-комментарии, если они есть.

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

Поэтому давайте рассмотрим дополнительный набор ссылок, которые учитывают только узлы-элементы:

Эти ссылки похожи на те, что раньше, только в ряде мест стоит слово Element:

  • children – коллекция детей, которые являются элементами.
  • firstElementChild, lastElementChild – первый и последний дочерний элемент.
  • previousElementSibling, nextElementSibling – соседи-элементы.
  • parentElement – родитель-элемент.

Зачем нужен parentElement? Разве может родитель быть не элементом?

Свойство parentElement возвращает родитель-элемент, а parentNode возвращает «любого родителя». Обычно эти свойства одинаковы: они оба получают родителя.

За исключением document.documentElement:

alert( document.documentElement.parentNode ); // выведет document
alert( document.documentElement.parentElement ); // выведет null

Причина в том, что родителем корневого узла document.documentElement (<html>) является document. Но document – это не узел-элемент, так что parentNode вернёт его, а parentElement нет.

Эта деталь может быть полезна, если мы хотим пройти вверх по цепочке родителей от произвольного элемента elem к <html>, но не до document:

while(elem = elem.parentElement) { // идти наверх до <html>
  alert( elem );
}

Изменим один из примеров выше: заменим childNodes на children. Теперь цикл выводит только элементы:

<html>
<body>
  <div>Начало</div>

  <ul>
    <li>Информация</li>
  </ul>

  <div>Конец</div>

  <script>
    for (let elem of document.body.children) {
      alert(elem); // DIV, UL, DIV, SCRIPT
    }
  </script>
  ...
</body>
</html>

Ещё немного ссылок: таблицы

До сих пор мы описывали основные навигационные ссылки.

Некоторые типы DOM-элементов предоставляют для удобства дополнительные свойства, специфичные для их типа.

Таблицы – отличный пример таких элементов.

Элемент <table>, в дополнение к свойствам, о которых речь шла выше, поддерживает следующие:

  • table.rows – коллекция строк <tr> таблицы.
  • table.caption/tHead/tFoot – ссылки на элементы таблицы <caption>, <thead>, <tfoot>.
  • table.tBodies – коллекция элементов таблицы <tbody> (по спецификации их может быть больше одного).

<thead>, <tfoot>, <tbody> предоставляют свойство rows:

  • tbody.rows – коллекция строк <tr> секции.

<tr>:

  • tr.cells – коллекция <td> и <th> ячеек, находящихся внутри строки <tr>.
  • tr.sectionRowIndex – номер строки <tr> в текущей секции <thead>/<tbody>/<tfoot>.
  • tr.rowIndex – номер строки <tr> в таблице (включая все строки таблицы).

<td> and <th>:

  • td.cellIndex – номер ячейки в строке <tr>.

Пример использования:

<table id="table">
  <tr>
    <td>один</td><td>два</td>
  </tr>
  <tr>
    <td>три</td><td>четыре</td>
  </tr>
</table>

<script>
  // выводит содержимое первой строки, второй ячейки
  alert( table.rows[0].cells[1].innerHTML ) // "два"
</script>

Спецификация: tabular data.

Существуют также дополнительные навигационные ссылки для HTML-форм. Мы рассмотрим их позже, когда начнём работать с формами.

Пример

Вот пример простого HTML, CSS и JavaScript кода, демонстрирующего работу с DOM-деревом. В этом примере создается список, элементы которого можно добавлять и удалять:

HTML

<!DOCTYPE html>
<html>
<head>
    <title>Пример работы с DOM</title>
</head>
<body>

<div id="listContainer">
    <ul id="myList">
        <li>Элемент 1</li>
        <li>Элемент 2</li>
        <li>Элемент 3</li>
    </ul>
    <input type="text" id="newItemText">
    <button onclick="addItem()">Добавить элемент</button>
</div>

<script src="script.js"></script>
</body>
</html>

CSS

#listContainer {
    margin: 20px;
}

#myList li {
    cursor: pointer;
    margin: 5px;
    padding: 5px;
    background-color: #f0f0f0;
}

#myList li:hover {
    background-color: #e0e0e0;
}

JavaScript (script.js)

function addItem() {
    var ul = document.getElementById("myList");
    var candidate = document.getElementById("newItemText");
    var li = document.createElement("li");
    li.appendChild(document.createTextNode(candidate.value));
    li.onclick = function() { this.remove(); };
    ul.appendChild(li);
}

В этом примере HTML создает структуру страницы, включая неупорядоченный список (ul), поле для ввода и кнопку. CSS обеспечивает базовое оформление. JavaScript функция addItem добавляет новый элемент в список при нажатии кнопки, а также назначает функцию удаления элемента при его клике.

Итого

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

Есть два основных набора ссылок:

  • Для всех узлов: parentNode, childNodes, firstChild, lastChild, previousSibling, nextSibling.
  • Только для узлов-элементов: parentElement, children, firstElementChild, lastElementChild, previousElementSibling, nextElementSibling.

Некоторые виды DOM-элементов, например таблицы, предоставляют дополнительные ссылки и коллекции для доступа к своему содержимому.