DOM

Модуль 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
  • Кастомными событиями

Настройки

Цветовая схема

Тема