Вы написали разметку, открыли страницу в браузере, дописали пару строк на JavaScript, и в консоли появляется null. Браузер как будто читает какой-то другой файл. Он читает именно ваш, просто хранит результат чтения в своём формате: в виде дерева объектов. Это дерево и называется DOM.
Ниже разобрали дерево документа по слоям: как браузер его собирает, из каких узлов оно состоит, как по нему ходить, как искать элементы, менять текст и разметку, ловить клики и не сажать при этом скорость страницы. С таблицами, примерами кода и разбором семи ошибок, на которых спотыкаются почти все новички.
Если разметка пока в новинку, стоит начать с материала «Что такое язык разметки HTML»: там про теги, вложенность и структуру документа, без которых дальше будет тяжело.
Статья написана для тех, кто знает HTML и делает первые шаги в JavaScript. Если вы ещё выбираете, браться ли за язык вообще, у нас есть отдельный разбор: зачем новичку учить JavaScript и что он даёт на практике.
А если хочется системного пути, а не обрывочных статей, загляните в подборку курсов по JavaScript: там 198 программ от коротких интенсивов по вёрстке до годовых курсов фронтенда.
Что такое DOM и почему браузер видит страницу иначе, чем вы её написали

DOM расшифровывается как Document Object Model, по-русски это объектная модель документа. Когда браузер получает от сервера HTML-файл, он не показывает его как есть. Сначала он разбирает текст разметки и строит из него структуру в памяти: каждый тег превращается в объект со своими свойствами, а вложенность тегов превращается во вложенность объектов.
Это DOM дерево браузер держит в памяти всё время, пока открыта вкладка. Дальше вся работа идёт уже с ним. Стили применяются к нему, JavaScript обращается к нему, картинка на экране рисуется по нему. Исходный HTML после разбора никого больше не интересует: он сделал свою работу и остался в истории загрузки.
Отсюда следует вещь, которая многих сбивает с толку в первый месяц. Разметка в файле и дерево документа в браузере расходятся почти сразу. Скрипт добавил блок с уведомлением, пользователь открыл выпадающее меню, библиотека подставила рекламный баннер. В файле ничего этого нет, а в дереве есть. Именно поэтому вкладка Elements в инструментах разработчика показывает совсем не тот текст, который лежит у вас в редакторе.
Простая аналогия. HTML-файл похож на рецепт, а объектная модель документа похожа на готовое блюдо на столе. Рецепт после готовки остаётся прежним, блюдо можно посолить, разложить по тарелкам и половину съесть.
Само дерево браузер отдаёт JavaScript через набор свойств и методов, который называется DOM API. Через него скрипт получает доступ к любому элементу страницы: читает текст, меняет классы, создаёт новые блоки, удаляет старые. Без этого моста язык умел бы считать числа и складывать строки, но не мог бы изменить на экране ни одной буквы.
Как браузер строит DOM-дерево из разметки
Сборка идёт сверху вниз, по мере чтения файла. Браузер встречает открывающий тег, создаёт для него узел, вешает узел на текущего родителя и спускается внутрь. Встречает закрывающий тег, поднимается обратно. Так из плоского текста получается иерархия.
Возьмём небольшую страницу:
<html>
<head>
<title>Мой сайт</title>
</head>
<body>
<h1 class="title">Привет</h1>
<ul id="menu">
<li>Главная</li>
<li>Контакты</li>
</ul>
</body>
</html>
Схема DOM дерева для этой страницы выглядит так:
document
└─ html
├─ head
│ └─ title
│ └─ "Мой сайт" ← текстовый узел
└─ body
├─ h1.title
│ └─ "Привет" ← текстовый узел
└─ ul#menu
├─ li → "Главная"
└─ li → "Контакты"
На вершине сидит объект document, корень всего дерева и точка входа для скрипта. Именно от него вы отталкиваетесь, когда пишете document.querySelector(...). Ниже идёт единственный элемент html, у него двое детей, и дальше по цепочке до самых листьев.
Листьями почти всегда оказываются текстовые узлы: строка «Главная» внутри li живёт отдельным узлом, у которого своих детей уже нет.
Ещё одна деталь, важная на практике: браузер терпелив к кривой разметке. Забыли закрыть <div>, вложили <p> в <p>, поставили <li> без списка: он всё равно построит дерево, просто по своим правилам восстановления. Итог может сильно отличаться от задуманного, и потом полдня уходит на вопрос «почему стиль применился к соседнему блоку». Проверять результат стоит во вкладке Elements, а не в редакторе кода.
Из каких узлов состоит дерево документа

Узел (по-английски node) — общее название для всего, что висит в дереве. Элементы, текст, комментарии, сам документ: всё это разные типы узлов DOM-дерева. Различать их важно: половина странного поведения кода объясняется тем, что вы схватили узел не того типа.
| Тип узла | Что это | Пример | Как часто встречается в коде |
|---|---|---|---|
| Document | Корень дерева, вся страница целиком | document |
Постоянно: с него начинается любой поиск |
| Element | Обычный тег разметки | <div>, <ul>, <img> |
Основной рабочий тип, 90 % времени |
| Text | Текст внутри тега, включая пробелы и переносы строк | «Привет» внутри <h1> |
Часто мешает при переборе детей |
| Comment | Комментарий в разметке | <!-- меню --> |
Почти никогда, но в дереве лежит |
| Attr | Атрибут элемента | class="title" |
Редко напрямую: обычно через getAttribute |
| DocumentFragment | Лёгкий контейнер для сборки узлов вне страницы | Служебный | Пригодится в разделе про скорость |
Почему текстовые узлы портят жизнь
Переносы строк и отступы в разметке тоже становятся текстовыми узлами. Возьмите красиво отформатированный список из примера выше и спросите у браузера количество детей:
const menu = document.getElementById('menu')
console.log(menu.childNodes.length) // 5
console.log(menu.children.length) // 2
Пять против двух. Разница в трёх текстовых узлах: перенос строки перед первым li, между двумя li и после второго. Свойство childNodes отдаёт вообще все узлы, а children отдаёт только элементы. Для повседневных задач нужен второй вариант, и это правило стоит запомнить сразу.
Как ходить по дереву: родители, дети и соседи
Родственные связи в дереве описываются теми же словами, что и в семье. У узла есть родитель, дети, братья и сёстры (соседи по уровню), предки и потомки. У каждой связи своё свойство.
| Свойство или метод | Что возвращает | Учитывает текстовые узлы |
|---|---|---|
parentElement |
Ближайший родитель-элемент | Нет |
children |
Коллекцию дочерних элементов | Нет |
childNodes |
Коллекцию всех дочерних узлов | Да |
firstElementChild, lastElementChild |
Первого и последнего ребёнка-элемент | Нет |
nextElementSibling, previousElementSibling |
Соседа справа и слева | Нет |
closest('селектор') |
Ближайшего предка, подходящего под селектор | Нет |
contains(узел) |
true, если узел лежит внутри |
Да |
На практике чаще всего нужен closest. Допустим, вы делаете список товаров, и в каждой карточке есть кнопка «В корзину». По клику вам нужна карточка целиком: из неё надо прочитать цену и название.
button.addEventListener('click', (event) => {
const card = event.target.closest('.product')
console.log(card.dataset.price)
})
Метод поднимается вверх по цепочке предков, пока не встретит подходящий элемент, и избавляет от конструкций вида parentElement.parentElement.parentElement, которые ломаются от любой правки вёрстки.
Как найти нужный элемент на странице
С поиска начинается любая работа с DOM в JavaScript. Методов несколько, и разница между ними влияет и на скорость, и на то, что именно вам вернётся.
| Метод | Что принимает | Что возвращает | Когда брать |
|---|---|---|---|
getElementById('id') |
Только идентификатор, без решётки | Один элемент или null |
Когда у элемента есть уникальный id |
querySelector('селектор') |
Любой CSS-селектор | Первый подходящий элемент или null |
Универсальный вариант на каждый день |
querySelectorAll('селектор') |
Любой CSS-селектор | Статичный список NodeList | Когда нужны все совпадения сразу |
getElementsByClassName('class') |
Имя класса | Живую коллекцию HTMLCollection | Редко: список меняется под руками |
getElementsByTagName('tag') |
Имя тега | Живую коллекцию HTMLCollection | Редко, для массовых обходов |
Если сомневаетесь, что выбрать, берите querySelector и querySelectorAll. Синтаксис у них совпадает с CSS, который вы уже знаете по вёрстке, и одним методом закрываются все случаи: '#menu', '.card .price', 'input[type="email"]', 'li:last-child'. Отдельный getElementById имеет смысл там, где важна скорость в цикле: по уникальному идентификатору браузер находит узел быстрее, чем разбирает селектор.
Живая коллекция и статичный список
Два семейства методов возвращают разные вещи, и это второй по частоте источник недоумения после текстовых узлов.
querySelectorAll отдаёт NodeList, слепок на момент вызова. Добавили после этого на страницу новый подходящий элемент, в слепке его не будет. getElementsByClassName отдаёт HTMLCollection, которая обновляется сама: элемент появился в документе, и он тут же оказался в коллекции. Звучит удобно, а на деле даёт классическую ловушку с бесконечным циклом, когда внутри перебора вы добавляете элементы с тем же классом.
Обе коллекции похожи на массив, но массивом не являются. У NodeList есть forEach, а вот map и filter нет. Если нужны методы массива, коллекцию сначала разворачивают:
const cards = [...document.querySelectorAll('.card')]
const expensive = cards.filter((card) => Number(card.dataset.price) > 5000)
console.log(expensive.length)
Как менять содержимое и структуру страницы
Дошли до самого приятного: правки, которые видно на экране сразу. Начнём с текста.
const title = document.querySelector('.title')
title.textContent = 'Привет ещё раз'
Свойство textContent кладёт в элемент простой текст. Если в строке встретятся угловые скобки, они останутся скобками и никакой разметкой не станут. Соседнее свойство innerHTML ведёт себя иначе: содержимое строки разбирается как разметка, теги внутри превращаются в настоящие элементы дерева.
Важный нюанс безопасности. Никогда не подставляйте в
innerHTMLто, что ввёл пользователь. Через это поле на страницу попадает чужой скрипт и получает доступ ко всему, до чего дотягивается ваш JavaScript, включая формы и cookie. Это классическая уязвимость под названием XSS. Для пользовательских данных существуетtextContent.
Создание новых элементов состоит из трёх шагов: создать, наполнить, вставить.
const item = document.createElement('li') // создали узел
item.textContent = 'Доставка' // наполнили
item.classList.add('menu__item') // повесили класс
document.getElementById('menu').append(item) // вставили в дерево
Пока элемент не вставлен, на экране его нет: он существует только в памяти. Вставку делают методами append (в конец родителя), prepend (в начало), before и after (рядом с элементом). Удаление делается одной строкой: item.remove().
Работать с этим набором приходится в любой практической задаче: форма подписки, которая после отправки показывает благодарность, корзина с пересчётом суммы, фильтр каталога, галерея с подгрузкой. Всё это сводится к поиску нужного узла, правке текста и вставке новых элементов в нужное место дерева.
Атрибуты, классы и data-атрибуты
Классы удобнее всего менять через classList. У этого свойства четыре метода, и они закрывают почти всё:
const modal = document.querySelector('.modal')
modal.classList.add('is-open') // добавить
modal.classList.remove('is-open') // убрать
modal.classList.toggle('is-open') // переключить
modal.classList.contains('is-open') // проверить, вернёт true или false
Внешним видом правильнее управлять через переключение классов, а стили держать в CSS. Прямая запись в element.style работает, но разбрасывает оформление по скриптам, и через месяц никто не найдёт, откуда взялся розовый фон.
Обычные атрибуты читаются и пишутся через три метода: getAttribute('href'), setAttribute('href', '/contacts/') и removeAttribute('disabled'). А для своих данных в разметке есть отдельный механизм: атрибуты с приставкой data-:
<button data-product-id="482" data-price="5900">В корзину</button>
const button = document.querySelector('button')
console.log(button.dataset.productId) // '482'
console.log(button.dataset.price) // '5900'
Обратите внимание на превращение data-product-id в productId: дефисы уходят, следующая буква становится заглавной. И ещё одно: в dataset всё лежит строками, поэтому перед арифметикой значение приводят к числу через Number().
События: как страница отвечает на действия пользователя
Клик, ввод в поле, отправка формы, прокрутка: всё это события. Скрипт подписывается на нужное событие у нужного элемента и получает управление, когда оно случается:
const form = document.querySelector('.subscribe')
form.addEventListener('submit', (event) => {
event.preventDefault() // отменяем перезагрузку страницы
const email = form.querySelector('input[type="email"]').value
form.textContent = `Спасибо, письмо ушло на ${email}`
})
В обработчик приходит объект события. Из него чаще всего берут два свойства: event.target (элемент, на котором событие случилось) и event.currentTarget (элемент, на котором висит обработчик). Метод preventDefault() отменяет поведение браузера по умолчанию: перезагрузку страницы при отправке формы, переход по ссылке.
Делегирование: один обработчик вместо сотни
События всплывают вверх по дереву. Клик по кнопке внутри карточки сначала срабатывает на кнопке, потом на карточке, потом на списке, потом на body. Это свойство используют, чтобы не вешать обработчик на каждый элемент по отдельности:
document.querySelector('.catalog').addEventListener('click', (event) => {
const button = event.target.closest('.buy')
if (!button) return
addToCart(button.dataset.productId)
})
Один обработчик на весь каталог отлавливает клики по всем кнопкам, включая те, которых на странице ещё не было в момент загрузки. Для динамических списков делегирование остаётся единственным рабочим подходом: обычная подписка на элемент, который появится через минуту, просто не найдёт этот элемент.
Почему работа с DOM тормозит страницу
Каждое обращение к дереву стоит времени, а некоторые правки заставляют браузер пересчитывать положение элементов на экране. Пересчёт геометрии называется reflow, перерисовка без изменения размеров называется repaint. Первое дороже второго в разы.
Типичный пример неудачного кода, когда тысяча строк вставляется по одной:
// плохо: тысяча вставок и тысяча пересчётов
for (const name of names) {
const li = document.createElement('li')
li.textContent = name
list.append(li)
}
Тот же результат за одну вставку через DocumentFragment (лёгкий контейнер, который живёт в памяти и не является частью страницы):
// хорошо: собрали в памяти, вставили один раз
const fragment = document.createDocumentFragment()
for (const name of names) {
const li = document.createElement('li')
li.textContent = name
fragment.append(li)
}
list.append(fragment)
Ещё три привычки, которые экономят время браузера. Сохраняйте найденные элементы в переменные, вместо того чтобы искать их заново в каждой строке цикла. Меняйте классы вместо десятка правок style подряд. И не читайте геометрию (offsetHeight, getBoundingClientRect()) вперемешку с записью стилей: такое чередование заставляет браузер пересчитывать раскладку на каждом шаге.
Виртуальный DOM и Shadow DOM: где вы их встретите
Два похожих названия, за которыми стоят разные вещи, и каждое отличается от обычного дерева документа. Разберём коротко, потому что оба слова попадаются на первом же собеседовании.
Виртуальный DOM — приём из React и подобных библиотек. Библиотека держит в памяти обычный объект JavaScript, описывающий, как должна выглядеть страница. При изменении данных она собирает новое описание, сравнивает со старым и переносит в настоящее дерево только отличия. Смысл в том, чтобы делать меньше дорогих обращений к странице и не заставлять разработчика вручную следить за каждой правкой.
Shadow DOM — возможность самого браузера. Она позволяет спрятать внутреннее устройство компонента в отдельное поддерево, куда не достают внешние стили и селекторы. На этом механизме работают встроенные элементы вроде <video> с его кнопками и ползунком, а также пользовательские веб-компоненты.
Нужен ли DOM, когда есть React. Да, и первым делом. Библиотека избавляет от ручных вставок узлов, но её собственные события, ссылки на элементы и работа с формами построены поверх той же модели. Разработчик, который не понимает дерево документа, отлаживает чужую абстракцию вслепую.
Семь ошибок новичка при работе с DOM

Ошибка 1: скрипт запускается раньше разметки
Самая частая причина null в консоли. Скрипт подключён в <head> и выполняется, когда нужного элемента в дереве ещё нет. Лечится атрибутом defer у тега <script>, подключением скрипта перед закрывающим </body> или подпиской на событие DOMContentLoaded, которое срабатывает после сборки дерева.
Ошибка 2: решётка и точка там, где их не ждут
В getElementById идентификатор передают без решётки: getElementById('menu'). В querySelector наоборот, полным селектором: querySelector('#menu'). Перепутанный синтаксис возвращает null без единого сообщения об ошибке.
Ошибка 3: коллекцию принимают за элемент
document.querySelectorAll('.card').style.color = 'red' не сработает никогда. Метод вернул список, а свойство style есть у отдельного элемента. Нужен перебор через forEach либо обращение по индексу.
Ошибка 4: пользовательский текст уходит в innerHTML
Имя из формы, комментарий, поисковый запрос: всё это подставляют через textContent. Через innerHTML на страницу попадает чужой код, и уязвимость XSS открывается за одну строку.
Ошибка 5: обработчики вешают в цикле по каждому элементу
Для десяти кнопок разница незаметна, для тысячи строк таблицы уже ощутима. Плюс новые элементы, добавленные позже, останутся без обработчика. Решение описано выше: делегирование через общего родителя.
Ошибка 6: поиск повторяется внутри цикла
Строка document.querySelector('.total') внутри перебора из пятисот элементов выполняет пятьсот одинаковых обходов дерева. Результат поиска сохраняют в переменную до цикла.
Ошибка 7: правки вносят по одной
Десять подряд идущих изменений style и десять вставок узлов заставляют браузер пересчитывать раскладку снова и снова. Собирайте изменения через фрагмент и переключайте классы вместо точечных правок оформления.
Как потренироваться прямо сейчас в консоли браузера
Для первых экспериментов не нужны ни редактор, ни установка чего-либо. Откройте любой сайт, нажмите F12 (на macOS это Cmd+Option+I) и перейдите на вкладку Console. Всё, что вы там напишете, выполнится на живой странице.
Попробуйте по шагам:
document.title: посмотреть заголовок вкладки, а потом присвоить ему свою строку и увидеть, как меняется название вкладки.document.querySelectorAll('a').length: посчитать все ссылки на странице.document.querySelector('h1').textContent = 'Моя версия': переписать главный заголовок.document.body.style.background = 'papayawhip': перекрасить фон.- Обновить страницу и убедиться, что всё вернулось на место.
Последний пункт снимает главный страх новичка: сломать чужой сайт из консоли невозможно. Вы правите копию дерева у себя в браузере, на сервере ничего не меняется, и перезагрузка страницы стирает все эксперименты.
Рядом с консолью живёт вкладка Elements: это те же инструменты разработчика, которые чаще называют DevTools. Там дерево документа показано в том виде, в каком оно существует прямо сейчас, со всеми правками скриптов. Клик по узлу подсвечивает элемент на странице, правая кнопка даёт возможность изменить разметку, а панель справа показывает применённые стили. Это самое быстрое место, чтобы понять, почему получилось не то, что вы задумали.
Где научиться работать с DOM
Дерево документа само по себе изучается за неделю, а уверенное владение приходит вместе с языком: событиями, асинхронностью, работой с сервером и форматом JSON, в котором приходят данные. Проще пройти обучение по готовой программе, где темы идут в правильном порядке и есть практика с проверкой, чем собирать знания из разрозненных статей.
Отдельные программы разбирают работу с деревом документа подробно: у нас собрана подборка курсов с блоком по DOM: 27 программ, где тема разбирается с практикой, а не одним слайдом.
| Курс | Школа | Стоимость со скидкой | В рассрочку | Длительность | Обзор курса от Checkroi |
|---|---|---|---|---|---|
| JavaScript Перейти на сайт курса | 63 829 ₽ | 4086 ₽/мес. | 3 месяца | Обзор курса | |
| Факультет fullstack JavaScript Перейти на сайт курса | 134 700 ₽ | 3742 ₽/мес. | 15 месяцев | Обзор курса | |
| Node.js-разработчик Перейти на сайт курса | 95 000 ₽ | 3958 ₽/мес. | 8 месяцев | Обзор курса | |
| Fullstack-разработчик на JavaScript Перейти на сайт курса | 158 760 ₽ | 6615 ₽/мес. | 11 месяцев | Обзор курса | |
| Фулстек-разработчик на JavaScript Перейти на сайт курса | 153 348 ₽ | 4296 ₽/мес. | 11 месяцев | Обзор курса | |
| Backend-разработка на Node.js Перейти на сайт курса | 45 000 ₽ | 2500 ₽/мес. | 6 месяцев | Обзор курса | |
| Fullstack-разработчик на Node.js Перейти на сайт курса | 125 100 ₽ | 7141 ₽/мес. | 12 месяцев | Обзор курса | |
| Fullstack разработчик JavaScript Перейти на сайт курса | 75 000 ₽ | 20 000 ₽/мес. | 6 месяцев | Обзор курса | |
| Автоматизированное тестирование веб-приложений на JavaScript Перейти на сайт курса | 42 900 ₽ | 3575 ₽/мес. | 4 месяца | Обзор курса | |
| Node.js-разработчик Перейти на сайт курса | 74 970 ₽ | 4280 ₽/мес. | 10 месяцев | Обзор курса |
Больше программ — в полном каталоге курсов по JavaScript
Если смотреть шире, работа с деревом документа входит в профессию фронтендера. Что входит в эту роль дальше и сколько за неё платят, разобрали в материалах «Как стать фронтенд-разработчиком» и «Кто такой JavaScript-разработчик». А тем, кто пока выбирает язык и путается в названиях, пригодится разбор разницы между Java и JavaScript.




