JS и DOM: события, манипуляция страницей

DOM (Document Object Model) - это представление HTML-страницы в виде дерева объектов. Каждый тег - узел. JS может находить узлы, менять их, вешать обработчики событий. Это и есть «оживление» статичного HTML.

DOM как дерево

<body>
  <header>
    <h1>BackendStart</h1>
  </header>
  <main>
    <p class="lead">Учим бэкенд.</p>
  </main>
</body>

В дереве:

document
└── html
    └── body
        ├── header
        │   └── h1 ("BackendStart")
        └── main
            └── p.lead ("Учим бэкенд.")

JS видит это как объекты. document - корень. От него вниз по дереву.

Поиск элементов

querySelector и querySelectorAll

// Один элемент (первый совпавший) или null
const title = document.querySelector('h1');
const lead  = document.querySelector('.lead');     // по классу
const email = document.querySelector('#email');    // по id
const btn   = document.querySelector('button[type="submit"]'); // CSS-селектор любой сложности

// Все совпавшие - NodeList (похож на массив)
const items = document.querySelectorAll('.item');
items.forEach(el => console.log(el.textContent));
items.length;

querySelector принимает любой CSS-селектор - то, что ты учил в css-basics. Это удобнее, чем старые getElementById / getElementsByClassName, потому что один API на все случаи.

В DevTools → Console короткий алиас: `$('.lead')` === `document.querySelector('.lead')`. `$('.item')` === `document.querySelectorAll('.item')`. Только в DevTools, в production-коде не сработает (там это jQuery).

Поиск внутри элемента

const form = document.querySelector('form');
const email = form.querySelector('input[name="email"]');  // только внутри формы

Не обязательно искать от document - любой элемент знает свой поддерево.

Чтение и изменение содержимого

const title = document.querySelector('h1');

// Текст
title.textContent;             // "BackendStart"
title.textContent = 'Привет';  // меняет текст

// HTML (опасно! см. ниже)
title.innerHTML;               // "BackendStart"
title.innerHTML = '<em>Привет</em>';  // вставит <em>, его браузер распарсит как тег

// Атрибуты
const link = document.querySelector('a');
link.href;                      // через геттер
link.getAttribute('href');      // или явный метод
link.setAttribute('target', '_blank');
link.removeAttribute('target');

// Свойства типа checked, value, disabled
const input = document.querySelector('input');
input.value;                    // текст в поле
input.value = 'новое значение';
input.disabled = true;          // дизаблит поле

textContent vs innerHTML - это про безопасность

const name = '<img src=x onerror="alert(1)">';

title.textContent = name;  // безопасно: вставит как текст
title.innerHTML = name;    // ОПАСНО: браузер выполнит onerror

Если значение приходит от пользователя (комментарий, поле формы, URL-параметр), всегда используй textContent. innerHTML оставь для безопасного контента, который ты сам генеришь, или для шаблонизатора.

Это и есть XSS (Cross-Site Scripting): атакующий внедряет JS через незаэкранированный пользовательский ввод. Подробности - в уроке url-security. На уровне DOM правило простое: пользовательские данные ставь через textContent.

Классы и стили

const card = document.querySelector('.card');

// Классы
card.classList.add('active');
card.classList.remove('hidden');
card.classList.toggle('expanded');
card.classList.contains('active');  // true/false

// Стили (для разовых правок; для логики держи всё в CSS)
card.style.color = 'red';
card.style.backgroundColor = '#fff';  // в CSS background-color, в JS camelCase

Правило: для смены состояния (visible/hidden, active/disabled, expanded/collapsed) - всегда classList. Стили лежат в CSS, JS только переключает класс. Прямое назначение element.style.x - для одноразовых динамических вычислений (например, рассчитанная высота).

События

Событие - это «что-то произошло»: клик, наведение, ввод символа, отправка формы, загрузка картинки, нажатие клавиши. JS вешает обработчик и реагирует.

addEventListener

const btn = document.querySelector('#save');

btn.addEventListener('click', (event) => {
  console.log('Клик!', event);
});

Сигнатура: element.addEventListener(тип, обработчик). Обработчик получает объект event - что именно случилось.

Самые частые типы событий

click - клик мыши / тап на мобильном
input - каждое изменение значения <input> или <textarea>
change - изменение и потеря фокуса (для select, checkbox)
submit - отправка формы
keydown - нажата клавиша
keyup - клавиша отпущена
focus / blur - поле получило / потеряло фокус
mouseenter / mouseleave - наведение / уход мыши
load - что-то загрузилось (картинка, страница)
DOMContentLoaded - HTML распарсен (но картинки могли ещё не загрузиться)

Объект event

form.addEventListener('submit', (event) => {
  event.preventDefault();          // отменить стандартное поведение (для submit - перезагрузку)
  event.target;                    // элемент, который послал событие (форма)
  event.currentTarget;             // элемент, на который повесили обработчик (та же форма)
  event.stopPropagation();         // не пускать событие вверх по дереву
});

input.addEventListener('input', (event) => {
  console.log(event.target.value); // что в поле сейчас
});

document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') {
    closeModal();
  }
});

preventDefault - отменить дефолтное поведение браузера. Для submit - не перезагружать страницу. Для клика по ссылке - не переходить. Для keydown - не вводить символ.

Снятие обработчика

function onClick() { console.log('клик'); }

btn.addEventListener('click', onClick);
btn.removeEventListener('click', onClick);  // нужна та же ссылка на функцию

Стрелочную функцию-литерал снять нельзя - она каждый раз новая ссылка. Если нужно снимать, объяви функцию отдельно.

Делегирование событий

Допустим, на странице 100 кнопок «удалить»:

<ul>
  <li>Задача 1 <button class="delete">x</button></li>
  <li>Задача 2 <button class="delete">x</button></li>
  ... ещё 98
</ul>

Плохо - вешать 100 обработчиков. Хорошо - один на родителя:

document.querySelector('ul').addEventListener('click', (event) => {
  if (event.target.classList.contains('delete')) {
    event.target.closest('li').remove();
  }
});

Событие всплывает вверх по дереву (bubble). Обработчик на <ul> ловит клик с любого <button> внутри. event.target - кто реально кликнут. Это и есть делегирование - экономит память и работает для динамически добавленных элементов.

Создание и удаление элементов

// Создать элемент
const li = document.createElement('li');
li.textContent = 'Новая задача';
li.classList.add('task');

// Добавить в DOM
const list = document.querySelector('ul');
list.appendChild(li);              // в конец
list.prepend(li);                  // в начало
list.insertBefore(li, list.firstChild);  // перед первым

// Удалить
li.remove();

В современном коде используется appendChild для одного элемента и append(a, b, c) для нескольких (или для текста + элементов вперемешку).

Работа с формами из JS

<form id="signup">
  <input name="email" type="email" required />
  <input name="password" type="password" required />
  <button type="submit">Регистрация</button>
</form>
const form = document.querySelector('#signup');

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

  // Способ 1: руками
  const email = form.elements.email.value;
  const password = form.elements.password.value;

  // Способ 2: через FormData (видели в forms)
  const data = new FormData(form);
  const obj = Object.fromEntries(data);
  // obj = { email: '...', password: '...' }

  console.log(obj);
  // Отправка через fetch - следующий урок.
});

form.elements.<name> достаёт поле по атрибуту name. FormData собирает все поля одним вызовом.

Когда выполнять JS

<!-- JS до того, как DOM появился - querySelector вернёт null -->
<head>
  <script>
    const btn = document.querySelector('button');  // null
  </script>
</head>
<body>
  <button>Жми</button>
</body>

Решения:

<!-- Скрипт в конце body -->
<body>
  <button>Жми</button>
  <script>
    const btn = document.querySelector('button');  // OK
  </script>
</body>

<!-- Атрибут defer - выполнится после парсинга HTML -->
<head>
  <script src="app.js" defer></script>
</head>

<!-- DOMContentLoaded -->
<script>
  document.addEventListener('DOMContentLoaded', () => {
    const btn = document.querySelector('button');
  });
</script>

В реальных проектах используется defer или модули (type="module" - они тоже defer по умолчанию).

Что пропустили намеренно

  • Шаблонизаторы (<template>, lit, htm) - пригодится в крупном проекте.
  • Virtual DOM и реактивность - это уже React/Vue, отдельная история.
  • Web Components (Custom Elements, Shadow DOM) - нишевая тема.

Для бэкендера достаточно уметь: найти элемент, повесить обработчик, изменить текст или классы, прочитать форму. Этим и закроем 90% случаев.

Мини-задание

  • Сделай HTML со списком из 3 пунктов и кнопкой «Добавить»
  • По клику на «Добавить» добавь новый пункт в список (через createElement + appendChild)
  • По клику на любой пункт списка (делегирование!) - удали его
  • Добавь поле ввода, и пусть кнопка «Добавить» брала текст из него; если пусто - ничего не добавляй
  • Открой DevTools → Elements, посмотри, как DOM меняется в реальном времени

На следующем уроке научимся отправлять данные в твой backend через fetch и обрабатывать ответ - без перезагрузки страницы.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.