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); // true

Node — базовый интерфейс узлов. 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);

Для удаления нужно передать ту же функцию. Две одинаково записанные стрелочные функции являются разными объектами.

Однократный обработчик:

button.addEventListener('click', handleClick, { once: true });

Отмена стандартного действия:

form.addEventListener('submit', (event) => {
  event.preventDefault();
});

preventDefault() не останавливает распространение события.


Всплытие и погружение

Событие обычно проходит три фазы:

  1. Погружение — от window к целевому элементу.
  2. Целевая фаза — событие достигает цели.
  3. Всплытие — от цели к предкам.
<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:

События полей:

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-классы.