Модуль 7: Продвинутый DOM
Глубокое погружение в Document Object Model: эффективные селекторы, манипуляции, события и оптимизация.
Структура DOM
Что такое DOM?
DOM (Document Object Model) — программный интерфейс для HTML/XML документов. Браузер преобразует HTML в дерево объектов.
<!-- HTML -->
<html>
<head>
<title>Страница</title>
</head>
<body>
<h1>Привет</h1>
<p>Текст</p>
</body>
</html>
<!-- DOM дерево:
document
└── html
├── head
│ └── title
│ └── "Страница" (текстовый узел)
└── body
├── h1
│ └── "Привет"
└── p
└── "Текст"
-->
Типы узлов
| Тип | nodeType | Описание |
|---|---|---|
| Element | 1 | HTML-элементы |
| Text | 3 | Текстовое содержимое |
| Comment | 8 | HTML-комментарии |
| Document | 9 | Корень документа |
| DocumentFragment | 11 | Виртуальный контейнер |
// Проверка типа узла
element.nodeType === Node.ELEMENT_NODE; // 1
element.nodeType === Node.TEXT_NODE; // 3
// Имя узла
element.nodeName; // "DIV", "P", "#text"
element.tagName; // "DIV", "P" (только для элементов)
Селекторы DOM
Современные методы
// querySelector — первый элемент
const header = document.querySelector('header');
const btn = document.querySelector('.btn-primary');
const input = document.querySelector('input[type="email"]');
// querySelectorAll — все элементы (NodeList)
const items = document.querySelectorAll('.list-item');
const links = document.querySelectorAll('a[href^="https"]');
// Поиск внутри элемента
const nav = document.querySelector('nav');
const navLinks = nav.querySelectorAll('a');
// CSS селекторы работают!
document.querySelector('.card:first-child');
document.querySelector('.menu > li:nth-child(2)');
document.querySelector('input:not([disabled])');
document.querySelector('.item:has(.active)'); // Chrome 105+
Старые методы (всё ещё полезны)
// По ID (самый быстрый!)
const app = document.getElementById('app');
// По классу (HTMLCollection — живая!)
const buttons = document.getElementsByClassName('btn');
// По тегу
const paragraphs = document.getElementsByTagName('p');
// По имени (для форм)
const inputs = document.getElementsByName('email');
NodeList vs HTMLCollection
// NodeList (querySelectorAll) — статическая
const items1 = document.querySelectorAll('.item');
// Добавление нового .item НЕ обновит items1
// HTMLCollection (getElementsByClassName) — живая
const items2 = document.getElementsByClassName('item');
// Добавление нового .item ОБНОВИТ items2
// Преобразование в массив
const arr1 = Array.from(items1);
const arr2 = [...items1];
// NodeList имеет forEach
items1.forEach(item => console.log(item));
// HTMLCollection — нет forEach, нужно преобразовать
[...items2].forEach(item => console.log(item));
closest() и matches()
// closest — ближайший родитель (включая себя)
const button = document.querySelector('button');
const card = button.closest('.card'); // Найти родительскую карточку
const form = button.closest('form'); // Найти родительскую форму
// matches — проверка соответствия селектору
if (element.matches('.active')) {
// элемент имеет класс active
}
if (element.matches(':hover')) {
// элемент под курсором
}
// Полезно в делегировании событий
document.addEventListener('click', (e) => {
if (e.target.matches('.delete-btn')) {
// Клик по кнопке удаления
}
});
Навигация по DOM
Родители, дети, соседи
const element = document.querySelector('.item');
// Родители
element.parentNode; // Родительский узел (любой)
element.parentElement; // Родительский элемент
// Дети
element.childNodes; // Все дочерние узлы (включая текст!)
element.children; // Только дочерние элементы
element.firstChild; // Первый дочерний узел
element.firstElementChild; // Первый дочерний элемент
element.lastChild; // Последний дочерний узел
element.lastElementChild; // Последний дочерний элемент
// Соседи
element.previousSibling; // Предыдущий узел
element.previousElementSibling; // Предыдущий элемент
element.nextSibling; // Следующий узел
element.nextElementSibling; // Следующий элемент
Важно
Свойства без "Element" возвращают любые узлы, включая текстовые (пробелы, переносы). Используйте версии с "Element" для HTML-элементов.
Практические примеры
// Найти все родительские элементы
function getParents(el) {
const parents = [];
while (el.parentElement) {
parents.push(el.parentElement);
el = el.parentElement;
}
return parents;
}
// Найти всех предков с определённым классом
function findAncestor(el, selector) {
while (el && !el.matches(selector)) {
el = el.parentElement;
}
return el;
}
// Получить индекс элемента среди соседей
function getIndex(el) {
return [...el.parentElement.children].indexOf(el);
}
// Получить всех соседей
function getSiblings(el) {
return [...el.parentElement.children].filter(child => child !== el);
}
Манипуляции с DOM
Создание элементов
// Создание элемента
const div = document.createElement('div');
div.className = 'card';
div.id = 'new-card';
div.textContent = 'Новая карточка';
// Создание текстового узла
const text = document.createTextNode('Привет');
// Создание фрагмента (для batch-вставки)
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li);
}
list.appendChild(fragment); // Одна операция DOM!
Вставка элементов
const parent = document.querySelector('.container');
const newEl = document.createElement('div');
// Старые методы
parent.appendChild(newEl); // В конец
parent.insertBefore(newEl, refEl); // Перед refEl
parent.replaceChild(newEl, oldEl); // Заменить oldEl
// Современные методы (рекомендуются!)
parent.append(newEl); // В конец (можно несколько)
parent.prepend(newEl); // В начало
parent.append('текст', newEl); // Можно текст + элементы
// Относительно элемента
element.before(newEl); // Перед элементом
element.after(newEl); // После элемента
element.replaceWith(newEl); // Заменить элемент
// insertAdjacentHTML (быстрее для HTML-строк)
element.insertAdjacentHTML('beforebegin', '<div>До</div>');
element.insertAdjacentHTML('afterbegin', '<div>В начало</div>');
element.insertAdjacentHTML('beforeend', '<div>В конец</div>');
element.insertAdjacentHTML('afterend', '<div>После</div>');
// insertAdjacentElement (для элементов)
element.insertAdjacentElement('beforeend', newEl);
Удаление и клонирование
// Удаление
element.remove(); // Современный способ
parent.removeChild(element); // Старый способ
// Клонирование
const clone = element.cloneNode(false); // Только элемент
const deepClone = element.cloneNode(true); // С детьми
// Перемещение (appendChild перемещает, а не копирует!)
newParent.appendChild(existingElement);
innerHTML vs textContent
// textContent — только текст (безопасно!)
element.textContent = 'Привет <script>alert("XSS")</script>';
// Результат: текст "Привет <script>..."
// innerHTML — парсит HTML (ОПАСНО с пользовательским вводом!)
element.innerHTML = '<strong>Жирный</strong>';
// Результат: <strong>Жирный</strong>
// innerText — учитывает CSS (скрытый текст не вернётся)
element.innerText; // Только видимый текст
// outerHTML — включает сам элемент
console.log(element.outerHTML); // "<div class="item">...</div>"
Безопасность
Никогда не используйте innerHTML с пользовательским вводом! Это XSS-уязвимость. Используйте textContent или санитизацию.
Атрибуты и свойства
Разница между атрибутами и свойствами
// Атрибуты — в HTML
<input id="name" value="Иван" type="text">
// Свойства — в DOM объекте
input.id; // "name" (синхронизировано)
input.value; // Текущее значение (может отличаться!)
input.type; // "text"
// После ввода пользователем:
input.getAttribute('value'); // "Иван" (начальное)
input.value; // "Пётр" (текущее)
Работа с атрибутами
// Получение
element.getAttribute('href');
element.getAttribute('data-id');
// Установка
element.setAttribute('href', '/new-page');
element.setAttribute('disabled', '');
// Удаление
element.removeAttribute('disabled');
// Проверка наличия
element.hasAttribute('disabled'); // true/false
// Все атрибуты
element.attributes; // NamedNodeMap
[...element.attributes].forEach(attr => {
console.log(attr.name, attr.value);
});
Data-атрибуты
<div id="user"
data-id="123"
data-user-name="Иван"
data-is-admin="true">
</div>
const user = document.getElementById('user');
// Чтение через dataset
user.dataset.id; // "123"
user.dataset.userName; // "Иван" (camelCase!)
user.dataset.isAdmin; // "true" (строка!)
// Запись
user.dataset.role = 'editor';
// Результат: data-role="editor"
// Удаление
delete user.dataset.isAdmin;
// Преобразование типов
const isAdmin = user.dataset.isAdmin === 'true';
const id = parseInt(user.dataset.id, 10);
Классы
// classList (рекомендуется!)
element.classList.add('active');
element.classList.remove('hidden');
element.classList.toggle('expanded');
element.classList.toggle('dark', isDark); // Условное toggle
element.classList.replace('old', 'new');
element.classList.contains('active'); // true/false
// Несколько классов
element.classList.add('one', 'two', 'three');
element.classList.remove('one', 'two');
// className (строка)
element.className = 'btn btn-primary';
element.className += ' active'; // Осторожно с пробелами!
Работа со стилями
Inline стили
// Установка стилей
element.style.color = 'red';
element.style.backgroundColor = '#f0f0f0'; // camelCase!
element.style.fontSize = '16px';
element.style.setProperty('--custom-color', '#6c63ff');
// Чтение inline стилей
element.style.color; // "" если не задан inline!
// Удаление стиля
element.style.color = '';
element.style.removeProperty('color');
// Множественное присваивание
element.style.cssText = 'color: red; font-size: 16px;';
Object.assign(element.style, {
color: 'red',
fontSize: '16px',
marginTop: '10px'
});
Вычисленные стили
// getComputedStyle — все применённые стили
const styles = getComputedStyle(element);
styles.color; // "rgb(255, 0, 0)"
styles.fontSize; // "16px"
styles.getPropertyValue('--custom-color');
// Для псевдоэлементов
const beforeStyles = getComputedStyle(element, '::before');
beforeStyles.content; // '"→"'
Размеры и позиция
// Размеры элемента
element.offsetWidth; // Ширина + padding + border
element.offsetHeight; // Высота + padding + border
element.clientWidth; // Ширина + padding (без border и scrollbar)
element.clientHeight; // Высота + padding
element.scrollWidth; // Полная ширина контента
element.scrollHeight; // Полная высота контента
// Позиция относительно offsetParent
element.offsetTop;
element.offsetLeft;
element.offsetParent; // Ближайший positioned родитель
// getBoundingClientRect — позиция относительно viewport
const rect = element.getBoundingClientRect();
rect.top; // От верха viewport
rect.left; // От левого края viewport
rect.bottom; // От верха до низа элемента
rect.right; // От левого края до правого края
rect.width; // Ширина
rect.height; // Высота
rect.x; // То же что left
rect.y; // То же что top
// Позиция скролла
element.scrollTop; // Сколько прокручено сверху
element.scrollLeft; // Сколько прокручено слева
// Прокрутка
element.scrollTo(0, 100);
element.scrollTo({ top: 100, behavior: 'smooth' });
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
События: основы
addEventListener
// Базовое использование
element.addEventListener('click', function(event) {
console.log('Клик!', event);
});
// Стрелочная функция
element.addEventListener('click', (e) => {
console.log(this); // Осторожно: this !== element!
});
// Именованная функция (можно удалить)
function handleClick(e) {
console.log('Клик!');
}
element.addEventListener('click', handleClick);
element.removeEventListener('click', handleClick);
// Опции
element.addEventListener('click', handler, {
once: true, // Сработает один раз
passive: true, // Не вызовет preventDefault (оптимизация)
capture: true, // Фаза захвата вместо всплытия
signal: controller.signal // Для AbortController
});
// AbortController для удаления
const controller = new AbortController();
element.addEventListener('click', handler, { signal: controller.signal });
// Позже:
controller.abort(); // Удалит обработчик
Популярные события
| Категория | События |
|---|---|
| Мышь | click, dblclick, mousedown, mouseup, mousemove, mouseenter, mouseleave |
| Клавиатура | keydown, keyup, keypress (устарел) |
| Формы | submit, reset, input, change, focus, blur |
| Документ | DOMContentLoaded, load, beforeunload, unload |
| Скролл | scroll, resize |
| Touch | touchstart, touchmove, touchend |
| Drag & Drop | dragstart, drag, dragend, drop |
Объект события
Общие свойства
element.addEventListener('click', (e) => {
e.type; // "click"
e.target; // Элемент, на котором произошло событие
e.currentTarget; // Элемент, на котором висит обработчик
e.timeStamp; // Время события
e.isTrusted; // true если от пользователя, false если из кода
e.preventDefault(); // Отменить действие по умолчанию
e.stopPropagation(); // Остановить всплытие
e.stopImmediatePropagation(); // + остановить другие обработчики
});
События мыши
element.addEventListener('click', (e) => {
// Координаты
e.clientX, e.clientY; // Относительно viewport
e.pageX, e.pageY; // Относительно документа
e.screenX, e.screenY; // Относительно экрана
e.offsetX, e.offsetY; // Относительно элемента
// Кнопка мыши
e.button; // 0=левая, 1=средняя, 2=правая
e.buttons; // Битовая маска нажатых кнопок
// Модификаторы
e.ctrlKey; // Ctrl зажат
e.shiftKey; // Shift зажат
e.altKey; // Alt зажат
e.metaKey; // Cmd (Mac) / Win (Windows)
});
События клавиатуры
document.addEventListener('keydown', (e) => {
e.key; // "a", "Enter", "Escape", "ArrowUp"
e.code; // "KeyA", "Enter", "Escape", "ArrowUp"
e.keyCode; // Устарел! Не используйте
// Модификаторы
e.ctrlKey, e.shiftKey, e.altKey, e.metaKey;
// Повторное нажатие (зажатая клавиша)
e.repeat; // true если клавиша зажата
// Примеры
if (e.key === 'Escape') {
closeModal();
}
if (e.ctrlKey && e.key === 's') {
e.preventDefault();
save();
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
submit();
}
});
Фазы события
// 1. Capturing (захват) — от window вниз к target
// 2. Target — на целевом элементе
// 3. Bubbling (всплытие) — от target вверх к window
element.addEventListener('click', handler, true); // Capturing
element.addEventListener('click', handler, false); // Bubbling (default)
// Проверка фазы
element.addEventListener('click', (e) => {
e.eventPhase; // 1=capturing, 2=target, 3=bubbling
});
// Остановка всплытия
e.stopPropagation(); // Не пойдёт к родителям
Делегирование событий
Проблема
// Плохо: обработчик на каждый элемент
document.querySelectorAll('.item').forEach(item => {
item.addEventListener('click', handleClick);
});
// Проблемы:
// - Много обработчиков в памяти
// - Новые элементы не получат обработчик
Решение: делегирование
// Хорошо: один обработчик на родителе
document.querySelector('.list').addEventListener('click', (e) => {
// Найти нужный элемент
const item = e.target.closest('.item');
if (!item) return;
// Проверить что внутри нашего контейнера
if (!e.currentTarget.contains(item)) return;
handleClick(item);
});
// Работает с динамическими элементами!
Паттерн с data-атрибутами
<div class="toolbar">
<button data-action="save">Сохранить</button>
<button data-action="delete">Удалить</button>
<button data-action="copy">Копировать</button>
</div>
const actions = {
save() { console.log('Сохранение...'); },
delete() { console.log('Удаление...'); },
copy() { console.log('Копирование...'); }
};
document.querySelector('.toolbar').addEventListener('click', (e) => {
const action = e.target.dataset.action;
if (action && actions[action]) {
actions[action]();
}
});
Практический пример: Todo список
const todoList = document.querySelector('.todo-list');
todoList.addEventListener('click', (e) => {
const todo = e.target.closest('.todo-item');
if (!todo) return;
const id = todo.dataset.id;
// Кнопка удаления
if (e.target.matches('.delete-btn')) {
deleteTodo(id);
todo.remove();
return;
}
// Кнопка редактирования
if (e.target.matches('.edit-btn')) {
editTodo(id, todo);
return;
}
// Чекбокс
if (e.target.matches('input[type="checkbox"]')) {
toggleTodo(id, e.target.checked);
todo.classList.toggle('completed', e.target.checked);
}
});
Кастомные события
CustomEvent
// Создание события
const event = new CustomEvent('user:login', {
detail: {
userId: 123,
username: 'ivan'
},
bubbles: true, // Всплывает
cancelable: true // Можно отменить
});
// Отправка
element.dispatchEvent(event);
// Обработка
document.addEventListener('user:login', (e) => {
console.log('Пользователь вошёл:', e.detail.username);
});
// Проверка отмены
const cancelled = !element.dispatchEvent(event);
if (cancelled) {
console.log('Событие было отменено');
}
Примеры использования
// Компонент модального окна
class Modal {
constructor(element) {
this.element = element;
}
open() {
this.element.classList.add('open');
this.element.dispatchEvent(new CustomEvent('modal:open', {
bubbles: true,
detail: { modal: this }
}));
}
close() {
const event = new CustomEvent('modal:close', {
bubbles: true,
cancelable: true,
detail: { modal: this }
});
if (this.element.dispatchEvent(event)) {
this.element.classList.remove('open');
}
}
}
// Использование
document.addEventListener('modal:close', (e) => {
if (hasUnsavedChanges) {
e.preventDefault(); // Отменить закрытие
}
});
Observers API
IntersectionObserver
Отслеживание видимости элементов во viewport.
// Ленивая загрузка изображений
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.classList.add('loaded');
observer.unobserve(img);
}
});
}, {
rootMargin: '100px', // Загрузка чуть раньше
threshold: 0.1 // 10% видимости
});
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
// Анимация при появлении
const animationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entry.target.classList.toggle('animate', entry.isIntersecting);
});
}, { threshold: 0.2 });
document.querySelectorAll('.animate-on-scroll').forEach(el => {
animationObserver.observe(el);
});
MutationObserver
Отслеживание изменений DOM.
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
console.log('Добавлены:', mutation.addedNodes);
console.log('Удалены:', mutation.removedNodes);
}
if (mutation.type === 'attributes') {
console.log('Изменён атрибут:', mutation.attributeName);
}
});
});
observer.observe(element, {
childList: true, // Отслеживать детей
subtree: true, // И всех потомков
attributes: true, // Атрибуты
attributeFilter: ['class', 'data-id'], // Только эти атрибуты
characterData: true // Текстовое содержимое
});
// Отключение
observer.disconnect();
ResizeObserver
Отслеживание изменения размеров элементов.
const observer = new ResizeObserver((entries) => {
entries.forEach(entry => {
const { width, height } = entry.contentRect;
console.log(`Новый размер: ${width}x${height}`);
// Адаптивная логика
entry.target.classList.toggle('compact', width < 400);
});
});
observer.observe(element);
Оптимизация DOM
Минимизация reflow/repaint
// Плохо: много reflow
for (let i = 0; i < 100; i++) {
element.style.left = i + 'px';
const width = element.offsetWidth; // Форсирует reflow!
}
// Хорошо: batch чтение и запись
const width = element.offsetWidth; // Чтение
for (let i = 0; i < 100; i++) {
element.style.transform = `translateX(${i}px)`; // Только запись
}
DocumentFragment
// Плохо: много вставок
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
list.appendChild(li); // Каждый раз reflow!
}
// Хорошо: одна вставка
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li');
fragment.appendChild(li);
}
list.appendChild(fragment); // Один reflow!
Debounce и Throttle
// Debounce — выполнить после паузы
function debounce(fn, delay) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => fn(...args), delay);
};
}
// Для поиска
const search = debounce((query) => {
fetchResults(query);
}, 300);
input.addEventListener('input', (e) => search(e.target.value));
// Throttle — выполнять не чаще чем раз в N мс
function throttle(fn, limit) {
let inThrottle;
return (...args) => {
if (!inThrottle) {
fn(...args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
// Для скролла
window.addEventListener('scroll', throttle(() => {
console.log('Скролл!');
}, 100));
Passive event listeners
// Для scroll/touch/wheel — всегда passive: true
// Если не нужен preventDefault
window.addEventListener('scroll', handler, { passive: true });
element.addEventListener('touchstart', handler, { passive: true });
// Это позволяет браузеру не ждать JS и сразу скроллить
Итоговый проект
Создайте интерактивный список задач с:
- Делегированием событий
- Drag & Drop для сортировки
- Анимацией появления (IntersectionObserver)
- Оптимизированными обновлениями DOM
- Кастомными событиями