JavaScript — DOM
DOM (Document Object Model) — объектная модель HTML-документа. Браузер преобразует HTML в дерево объектов, а JavaScript получает возможность находить узлы, изменять содержимое, создавать элементы и реагировать на события.
<body>
<main id="app">
<h1 class="title">Задачи</h1>
<ul class="task-list"><li>Изучить DOM</li></ul>
</main>
</body>Упрощённое дерево:
document
└── html
└── body
└── main#app
├── h1.title
└── ul.task-list
└── liОсновная точка входа — объект document:
console.log(document.documentElement); // <html>
console.log(document.head); // <head>
console.log(document.body); // <body>Если скрипт подключён в <head>, обычно используют defer, чтобы он выполнился после разбора HTML:
<script src="app.js" defer></script>Дерево DOM и узлы
DOM состоит из узлов. HTML-элемент — важный, но не единственный тип узла.
| Тип | Пример | nodeType |
|---|---|---|
| Документ | document |
9 |
| Элемент | <p> |
1 |
| Текст | текст внутри <p> |
3 |
| Комментарий | <!-- ... --> |
8 |
const title = document.querySelector('h1');
console.log(title.nodeType); // 1
console.log(title.nodeName); // H1
console.log(title instanceof Node); // true
console.log(title instanceof Element); // trueNode — базовый интерфейс узлов. Element представляет HTML- или SVG-элемент и предоставляет методы для работы с селекторами, атрибутами и классами.
Навигация по дереву
| Все узлы | Только элементы |
|---|---|
parentNode |
parentElement |
childNodes |
children |
firstChild |
firstElementChild |
lastChild |
lastElementChild |
previousSibling |
previousElementSibling |
nextSibling |
nextElementSibling |
const list = document.querySelector('.task-list');
console.log(list.childNodes); // включая текстовые узлы
console.log(list.children); // только элементы
console.log(list.firstElementChild);Из-за пробелов и переносов строк firstChild может вернуть текстовый узел. Когда нужен HTML-элемент, удобнее свойства с Element в имени.
Полезные проверки:
const app = document.querySelector('#app');
const title = document.querySelector('.title');
app.contains(title); // находится ли узел внутри app
title.matches('.title'); // соответствует ли селектору
title.closest('#app'); // ближайший подходящий предокВыбор элементов
querySelector()
Возвращает первый элемент, подходящий под CSS-селектор, или null:
const title = document.querySelector('.title');
const item = document.querySelector('.task-list li');
const submit = document.querySelector('button[type="submit"]');Искать можно внутри конкретного элемента:
const list = document.querySelector('.task-list');
const firstItem = list.querySelector('li');querySelectorAll()
Возвращает статический NodeList со всеми совпадениями:
const items = document.querySelectorAll('.task-list li');
items.forEach((item) => {
console.log(item.textContent);
});
const array = [...items];Статическая коллекция не обновится автоматически после добавления новых элементов.
getElementById()
const app = document.getElementById('app');Перед id не ставится #. Если элемент не найден, метод возвращает null.
Другие методы:
const cards = document.getElementsByClassName('card');
const paragraphs = document.getElementsByTagName('p');Они возвращают живые HTMLCollection, которые обновляются при изменении DOM.
Результат поиска нужно проверять, если элемент может отсутствовать:
const button = document.querySelector('.button');
if (button) {
button.addEventListener('click', () => console.log('Нажатие'));
}Изменение содержимого
textContent
Читает или задаёт текст:
const title = document.querySelector('.title');
title.textContent = 'Новый заголовок';
console.log(title.textContent);HTML в строке не интерпретируется и выводится как текст. Поэтому textContent подходит для пользовательских данных.
innerHTML
Читает или заменяет внутреннюю HTML-разметку:
const container = document.querySelector('.container');
container.innerHTML = `
<article class="card">
<h2>Карточка</h2>
</article>
`;Нельзя без очистки помещать туда внешние данные:
// Опасно: возможна XSS-уязвимость
container.innerHTML = userInput;
// Безопаснее для обычного текста
container.textContent = userInput;При замене innerHTML прежние дочерние узлы удаляются.
Атрибуты и свойства
const link = document.querySelector('a');
link.getAttribute('href');
link.setAttribute('href', 'https://example.com');
link.hasAttribute('target');
link.removeAttribute('target');Многие атрибуты доступны как свойства:
const input = document.querySelector('input');
const button = document.querySelector('button');
input.value = 'Текст';
input.checked = true;
button.disabled = true;Атрибут описывает исходное значение в HTML, а свойство часто отражает текущее состояние. Например, после ввода текста input.value меняется, но атрибут value может остаться прежним.
data-* и dataset
<button data-product-id="42" data-action="buy">Купить</button>const button = document.querySelector('[data-action="buy"]');
console.log(button.dataset.productId); // "42"
button.dataset.state = 'loading';data-product-id превращается в dataset.productId. Значения dataset являются строками.
classList
classList управляет отдельными классами элемента:
const card = document.querySelector('.card');
card.classList.add('card--active');
card.classList.remove('card--loading');
card.classList.contains('card--active');
card.classList.replace('card--old', 'card--new');
card.classList.toggle('card--selected');Второй аргумент toggle() явно задаёт состояние:
card.classList.toggle('card--invalid', !isValid);className заменяет всю строку классов:
card.className = 'card card--active';Когда нужно изменить один класс и сохранить остальные, предпочтительнее classList.
Стили можно менять напрямую:
card.style.backgroundColor = '#2563eb';
card.style.setProperty('border-radius', '12px');
card.style.removeProperty('background-color');Но визуальные состояния обычно лучше хранить в CSS и переключать классами.
Создание и удаление элементов
createElement() и добавление
const item = document.createElement('li');
item.className = 'task-list__item';
item.textContent = 'Изучить события';
const list = document.querySelector('.task-list');
list.append(item); // в конец
list.prepend(item); // в началоТакже доступны:
current.before(newElement);
current.after(newElement);
list.appendChild(item);append() принимает несколько узлов и строки, а appendChild() — один узел.
Вставка HTML
list.insertAdjacentHTML(
'beforeend',
'<li class="task-list__item">Новая задача</li>'
);Позиции: beforebegin, afterbegin, beforeend, afterend. Для непроверенных данных этот способ небезопасен.
DocumentFragment
Для подготовки группы элементов используют фрагмент:
const fragment = document.createDocumentFragment();
for (let index = 1; index <= 100; index += 1) {
const item = document.createElement('li');
item.textContent = `Элемент ${index}`;
fragment.append(item);
}
list.append(fragment);Удаление, замена и копирование
item.remove();
oldItem.replaceWith(newItem);
container.replaceChildren(first, second);
container.replaceChildren(); // очиститьПовторное добавление существующего узла перемещает его. Для копии применяется cloneNode():
const copy = item.cloneNode(true); // копия вместе с потомкамиОбработчики из addEventListener() не копируются. Нельзя создавать дубликаты уникальных id.
События и addEventListener()
const button = document.querySelector('.button');
function handleClick(event) {
console.log(event.type);
console.log(event.target);
console.log(event.currentTarget);
}
button.addEventListener('click', handleClick);
button.removeEventListener('click', handleClick);target— элемент, на котором событие возникло;currentTarget— элемент, обработчик которого выполняется.
Для удаления нужно передать ту же функцию. Две одинаково записанные стрелочные функции являются разными объектами.
Однократный обработчик:
button.addEventListener('click', handleClick, { once: true });Отмена стандартного действия:
form.addEventListener('submit', (event) => {
event.preventDefault();
});preventDefault() не останавливает распространение события.
Всплытие и погружение
Событие обычно проходит три фазы:
- Погружение — от
windowк целевому элементу. - Целевая фаза — событие достигает цели.
- Всплытие — от цели к предкам.
<div class="outer"><button class="inner">Нажать</button></div>outer.addEventListener('click', () => console.log('outer'));
inner.addEventListener('click', () => console.log('inner'));При нажатии сначала сработает inner, затем outer. Обработчик погружения включается через capture:
outer.addEventListener('click', handleClick, { capture: true });Остановка распространения:
inner.addEventListener('click', (event) => {
event.stopPropagation();
});stopImmediatePropagation() также останавливает остальные обработчики этого события на текущем элементе. Использовать остановку следует осторожно: она может нарушить делегирование.
Не все события всплывают. Например, вместо focus и blur для делегирования используют focusin и focusout.
Делегирование событий
Делегирование — один обработчик на общем предке вместо обработчика на каждом дочернем элементе.
<ul class="task-list">
<li data-task-id="1">
<span>Задача</span>
<button data-action="remove">Удалить</button>
</li>
</ul>const list = document.querySelector('.task-list');
list.addEventListener('click', (event) => {
const button = event.target.closest('[data-action="remove"]');
if (!button || !list.contains(button)) {
return;
}
button.closest('[data-task-id]')?.remove();
});closest() нужен, потому что пользователь может нажать на вложенную иконку внутри кнопки. Проверка contains() подтверждает, что найденный элемент принадлежит нужному контейнеру.
Преимущества делегирования:
- меньше обработчиков;
- динамически добавленные элементы работают автоматически;
- подписками проще управлять.
Работа с формами через DOM
<form id="profile-form">
<label>
Имя
<input name="username" required minlength="2">
</label>
<label>
Email
<input name="email" type="email" required>
</label>
<label>
<input name="notifications" type="checkbox">
Уведомления
</label>
<button type="submit">Сохранить</button>
</form>Доступ к полям:
const form = document.querySelector('#profile-form');
const username = form.elements.username;
console.log(username.value);
console.log(form.elements.notifications.checked);Обрабатывать следует submit формы, а не только click кнопки: тогда работает и отправка через Enter.
form.addEventListener('submit', (event) => {
event.preventDefault();
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const data = new FormData(form);
const values = Object.fromEntries(data.entries());
console.log(values);
form.reset();
});Особенности FormData:
- учитываются поля с
name; - неотмеченный checkbox обычно отсутствует;
- несколько одинаковых имён читаются через
getAll(); - большинство значений являются строками, файлы — объектами
File.
События полей:
username.addEventListener('input', (event) => {
console.log(event.currentTarget.value);
});
form.elements.notifications.addEventListener('change', (event) => {
console.log(event.currentTarget.checked);
});Собственное сообщение валидации:
const email = form.elements.email;
email.addEventListener('input', () => {
email.setCustomValidity(
email.validity.typeMismatch ? 'Введите корректный email' : ''
);
});После исправления ошибки сообщение нужно сбрасывать пустой строкой. Каждое поле должно иметь связанный <label>.
Анимации через DOM
Для простых переходов удобнее CSS и переключение класса:
.panel {
opacity: 0;
transform: translateY(12px);
transition: opacity 250ms ease, transform 250ms ease;
}
.panel--visible {
opacity: 1;
transform: translateY(0);
}panel.classList.add('panel--visible');requestAnimationFrame()
Метод планирует функцию перед следующей перерисовкой и передаёт временную метку:
const box = document.querySelector('.box');
const duration = 1000;
const distance = 300;
let startTime = null;
function animate(timestamp) {
if (startTime === null) {
startTime = timestamp;
}
const elapsed = timestamp - startTime;
const progress = Math.min(elapsed / duration, 1);
const x = progress * distance;
box.style.transform = `translateX(${x}px)`;
if (progress < 1) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);Движение рассчитывается по времени, а не по количеству кадров: частота обновления экранов отличается.
Отмена запланированного кадра:
const animationId = requestAnimationFrame(animate);
cancelAnimationFrame(animationId);Для анимации предпочтительны transform и opacity: они обычно дешевле частого изменения width, height, top и left.
Следует учитывать настройку уменьшения движения:
const reduceMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
if (reduceMotion) {
box.style.transform = `translateX(${distance}px)`;
} else {
requestAnimationFrame(animate);
}Производительность и безопасность
DOM-операции могут запускать вычисление стилей, макета и перерисовку. При массовом создании элементов полезно применять DocumentFragment, сохранять ссылки на часто используемые контейнеры и группировать чтения и записи.
const widths = [...items].map((item) => item.offsetWidth);
items.forEach((item, index) => {
item.style.width = `${widths[index] + 10}px`;
});Так чтения геометрии выполняются отдельно от изменений.
Состояние приложения удобно хранить отдельно от DOM:
let tasks = [
{ id: 1, title: 'Изучить DOM', completed: false },
];DOM при этом является визуальным представлением данных.
При выводе внешних данных используйте textContent. Непроверенный ввод в innerHTML может привести к XSS. Инлайн-обработчики также лучше не использовать:
<!-- Не рекомендуется -->
<button onclick="handleClick()">Нажать</button>// Предпочтительно
button.addEventListener('click', handleClick);Общий пример: список задач
index.html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Задачи</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<main class="app">
<h1>Список задач</h1>
<form class="task-form">
<label for="task-title">Новая задача</label>
<input id="task-title" name="title" minlength="2" required>
<button type="submit">Добавить</button>
<p class="task-form__error" aria-live="polite"></p>
</form>
<p class="task-summary" aria-live="polite"></p>
<ul class="task-list"></ul>
</main>
</body>
</html>app.js
const form = document.querySelector('.task-form');
const input = form.elements.title;
const error = document.querySelector('.task-form__error');
const summary = document.querySelector('.task-summary');
const list = document.querySelector('.task-list');
let nextId = 1;
let tasks = [];
function createTaskElement(task) {
const item = document.createElement('li');
const checkbox = document.createElement('input');
const text = document.createElement('span');
const remove = document.createElement('button');
item.dataset.taskId = String(task.id);
item.className = 'task-list__item';
checkbox.type = 'checkbox';
checkbox.dataset.action = 'toggle';
checkbox.checked = task.completed;
text.className = 'task-list__text';
text.textContent = task.title;
remove.type = 'button';
remove.dataset.action = 'remove';
remove.textContent = 'Удалить';
item.append(checkbox, text, remove);
return item;
}
function updateSummary() {
const completed = tasks.filter((task) => task.completed).length;
summary.textContent = `Всего: ${tasks.length}. Выполнено: ${completed}.`;
}
form.addEventListener('submit', (event) => {
event.preventDefault();
const title = input.value.trim();
if (title.length < 2) {
error.textContent = 'Введите не менее двух символов';
input.focus();
return;
}
const task = { id: nextId, title, completed: false };
nextId += 1;
tasks = [...tasks, task];
const item = createTaskElement(task);
list.append(item);
requestAnimationFrame(() => {
item.classList.add('task-list__item--visible');
});
error.textContent = '';
form.reset();
input.focus();
updateSummary();
});
list.addEventListener('change', (event) => {
const checkbox = event.target.closest('[data-action="toggle"]');
if (!checkbox || !list.contains(checkbox)) return;
const item = checkbox.closest('[data-task-id]');
const id = Number(item.dataset.taskId);
tasks = tasks.map((task) =>
task.id === id ? { ...task, completed: checkbox.checked } : task
);
item.classList.toggle('task-list__item--completed', checkbox.checked);
updateSummary();
});
list.addEventListener('click', (event) => {
const button = event.target.closest('[data-action="remove"]');
if (!button || !list.contains(button)) return;
const item = button.closest('[data-task-id]');
const id = Number(item.dataset.taskId);
tasks = tasks.filter((task) => task.id !== id);
item.remove();
updateSummary();
});
updateSummary();Пример использует выбор элементов, форму, createElement(), dataset, classList, делегирование и requestAnimationFrame().
Краткая памятка
| Задача | API |
|---|---|
Найти по id |
getElementById() |
| Найти первый элемент | querySelector() |
| Найти все элементы | querySelectorAll() |
| Найти предка | closest() |
| Изменить текст | textContent |
| Управлять атрибутами | getAttribute(), setAttribute() |
Работать с data-* |
dataset |
| Управлять классами | classList |
| Создать элемент | createElement() |
| Добавить элемент | append(), prepend() |
| Удалить элемент | remove() |
| Подписаться на событие | addEventListener() |
| Отменить действие | preventDefault() |
| Остановить всплытие | stopPropagation() |
| Собрать форму | FormData |
| Запланировать кадр | requestAnimationFrame() |
Главный принцип: хранить данные отдельно от отображения, безопасно выводить текст, использовать события вместо инлайн-обработчиков и менять визуальные состояния через CSS-классы.