Не паси задних: используй DOM как профессионал

Готов стать лучшим веб-разработчиком? Читай статью и перестанешь бояться DOM, будешь использовать его на полную катушку, даже полюбишь.
Не паси задних: используй DOM как профессионал

Когда я начал карьеру профессионального веб-разработчика в 2008 году, то знал понемногу HTML, CSS и PHP. А также изучал JavaScript, потому что с помощью него я показывал и скрывал элементы, делал классные вещи, такие как выпадающие меню.

Тогда я работал в небольшой компании, которая создавала системы CMS для клиентов, и потребовался загрузчик нескольких файлов. То, что невозможно сделать в то время с нативным JavaScript.

После поисков я нашёл забавное решение на основе Flash и библиотеки JavaScript под названием MooTools. Там есть классная функция $ для выбора элементов DOM и модули, такие как индикаторы выполнения и запросы Ajax. Две недели спустя я открыл для себя jQuery и поразился.

Нет многословных, неуклюжих манипуляций с DOM, только простые связанные селекторы и масса полезных плагинов.

Перенесёмся в 2019 год, где миром правят фреймворки. Если ты начал путь веб-разработчика в последнее десятилетие, велика вероятность, что в будущем не столкнёшься с «сырым» DOM. Возможно, тебе это и не нужно.

Хотя такие фреймворки, как Angular и React, привели к стремительному спаду популярности jQuery, он по-прежнему используется ошеломляющими 66 миллионами веб-сайтов. По оценкам это составляет 74% всех сайтов в мире.

Наследие jQuery впечатляет, и показательный пример его влияния на стандарты – методы querySelector и querySelectorAll, которые имитируют функцию jQuery $.

По иронии судьбы эти два метода стали главной причиной снижения популярности jQuery, поскольку заменили наиболее используемую функциональность jQuery: простой выбор элементов DOM.

Но чистый DOM API многословен.

Я имею в виду $ в сравнении с document.querySelectorAll.

И это то, что мешает разработчикам использовать нативный DOM API. Но здесь на самом деле нет необходимости.

Нативный DOM API великолепен и чрезвычайно полезен. Да, многословен, но это потому, что предоставляет низкоуровневые блоки для построения абстракций. И если переживаешь насчёт дополнительных нажатий клавиш, расслабься: у всех современных редакторов и IDE превосходное автозавершение кода. Ещё вариант: создавай псевдонимы наиболее часто используемых функций.

Поехали!

Выбор элементов

Один элемент

Чтобы выбрать один элемент с использованием любого допустимого селектора CSS, напиши:

        document.querySelector(/* твой селектор */
    

 Подставляй в код любой селектор:

        document.querySelector('.foo')            // селектор класса
document.querySelector('#foo')            // селектор идентификатора
document.querySelector('div')             // селектор тега
document.querySelector('[name="foo"]')    // селектор атрибута
document.querySelector('div + p > span')  // так держать!
    

Когда нет соответствующих элементов, возвращается null.

Несколько элементов

Для выбора нескольких элементов используй: 

        document.querySelectorAll('p')  // выбирает все элементы <p>
    

Применяй document.querySelectorAll, как и document.querySelector. Подойдёт любой действительный селектор CSS, и единственное отличие: querySelector вернёт один элемент, а querySelectorAll – статический NodeList с найденными элементами. Если не найдено ни одного элемента, получишь пустой NodeList.

A NodeList – итерируемый объект, похожий на массив, но не массив, поэтому у него нет тех же методов. Ты запустишь forEach на нём, но не map, reduce или find.

Если для этого объекта понадобились методы массива, то преобразуй его в массив с помощью деструктуризации или Array.from:

        const arr = [...document.querySelectorAll('p')];
or
const arr = Array.from(document.querySelectorAll('p'));
arr.find(element => {...});  // .find () теперь работает
    

querySelectorAll отличается от методов, наподобие getElementsByTagName и getElementsByClassName тем, что они возвращают динамическую коллекцию HTMLCollection, а querySelectorAll статический NodeList.

И если сделать getElementsByTagName('p') и убрать один <p> из документа, он удаляется также из полученной HTMLCollection.

Но если напишешь querySelectorAll('p') и удалишь один <p> из документа, он останется в полученномNodeList.

Другое отличие состоит в том, что HTMLCollection содержит элементы HTMLElement, а NodeList – любой тип Node.

Относительные поиски

Нет необходимости вызывать querySelector(All) с document. Запускай относительный поиск на любом HTMLElement:

        const div = document.querySelector('#container');
div.querySelectorAll('p')  // находит все теги <p> только в #container
    

Но это по-прежнему многословно!

Если ты всё ещё беспокоишься о дополнительных нажатиях клавиш, применяй оба:

        const $ = document.querySelector.bind(document);
$('#container');

const $$ = document.querySelectorAll.bind(document);
$$('p');
    

Поднимаемся по дереву DOM

Использование CSS-селекторов для выбора DOM-элементов означает, что мы спускаемся по дереву DOM. Нет CSS-селекторов для движения вверх и выбора родителей.

Но перемещаться вверх по дереву DOM можно с помощью closest(), который также принимает любой валидный CSS-селектор:

        document.querySelector('p').closest('div');
    

Это найдёт ближайший родительский элемент абзаца, выбранного выражением document.querySelector('p'). Свяжи эти вызовы, чтобы подняться выше по дереву:

        document.querySelector('p').closest('div').closest('.content');

    

Добавление элементов

Код для добавления одного или нескольких элементов в дерево DOM пользуется дурной славой за счёт быстрого нагромождения словарных конструкций. Чтобы добавить следующую ссылку на страницу:

        <a href="/home" class="active">Главная страница</a>
    

Тебе понадобится:

        const link = document.createElement('a');
a.setAttribute('href', '/home');
a.className = 'active';
a.textContent = 'Главная страница';

document.body.appendChild(link);
    

Представь, что придётся сделать это для 10 элементов...

А в jQuery получится так:

        $('body').append('<a href="/home" class="active">Главная страница</a>');

    

А знаешь что? Есть нативный эквивалент:

        document.body.insertAdjacentHTML('beforeend', 
'<a href="/home" class="active">Главная страница</a>');
    

Метод insertAdjacentHTML вставляет произвольную действительную HTML-строку в DOM на четыре позиции, указанные в первом параметре:

  1. 'beforebegin': перед элементом.
  2. 'afterbegin': внутри элемента перед его первым потомком. 
  3. 'beforeend': внутри элемента после его последнего потомка.
  4.  'afterend': после элемента .
        <!-- beforebegin -->
<p>
  <!-- afterbegin -->
  foo
  <!-- beforeend -->
</p>
<!-- afterend -->
    

Это также упрощает указание точного места для вставки нового элемента. Допустим, нужен <a> прямо перед этим <p>. Без insertAdjacentHTML сделали бы:

        const link = document.createElement('a');
const p = document.querySelector('p');

p.parentNode.insertBefore(link, p);
    

Теперь хватит этого:

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

p.insertAdjacentHTML('beforebegin', '<a></a>');
    

Здесь также не обошлось без эквивалентного метода для вставки элементов DOM:

        const link = document.createElement('a');
const p = document.querySelector('p');

p.insertAdjacentElement('beforebegin', link);
    

и текста:

        p.insertAdjacentText('afterbegin', 'foo');
    

Двигаем элементы

Метод insertAdjacentElement также используется для перетасовки существующих элементов в том же документе. Когда элемент, который вставляем с помощью insertAdjacentElement, уже присутствует в документе, он просто перемещается.

Когда такой HTML:

        <div class="first">
  <h1>Заголовок</h1>
</div>

<div class="second">
  <h2>Подзаголовок</h2>
</div>
    

 и <h2> вставляется после <h1>:

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

h1.insertAdjacentElement('afterend', h2);
    

он просто сдвигается, а не копируется:

        <div class="first">
  <h1>Заголовок</h1>
  <h2>Подзаголовок</h2>
</div>

<div class="second">
  
</div>
    

Заменяем элементы

Элемент DOM заменяется любым другим элементом DOM с использованием метода replaceWith:

        someElement.replaceWith(otherElement);
    

Заменой выступает новый элемент, созданный с помощью document.createElement, или элемент, который уже есть в том же документе (тогда он снова будет перемещён, а не скопирован):

        <div class="first">
  <h1>Заголовок</h1>
</div>

<div class="second">
  <h2>Подзаголовок</h2>
</div>

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

h1.replaceWith(h2);

// результат:

<div class="first">
  <h2>Подзаголовок</h2>
</div>

<div class="second">
  
</div>
    

Удаляем элементы

Просто вызывай у элемента метод remove:

        const container = document.querySelector('#container');
container.remove();  // до свидания, детка
    

Лучше, чем по-старому:

        const container = document.querySelector('#container');
container.parentNode.removeChild(container);
    

Создаём элемент из сырого HTML

insertAdjacentHTML вставляет сырой HTML в документ, но что если хотим создать элемент из сырого HTML и использовать его позже?

Для этого понадобится объект DomParser и метод parseFromString. DomParser преобразует исходный код HTML или XML в документ DOM. Используем метод parseFromString для создания документа с одним элементом и возвращаем только этот элемент:

        const createElement = domString => new 
DOMParser().parseFromString(domString, 'text/html').body.firstChild;

const a = createElement('<a href="/home" class="active">Главная страница</a>');
    

Инспектируем DOM

Стандартный DOM API также предоставляет методы для инспекции DOM. Например, matches проверяет соответствие элемента определённому селектору:

        <p class="foo">Hello world</p>

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

p.matches('p');     // true
p.matches('.foo');  // true
p.matches('.bar');  // false, нет класса "bar"
    

А также проверим, будет ли элемент дочерним по отношению к другому элементу с помощью contains:

        <div class="container">
  <h1 class="title">Foo</h1>
</div>

<h2 class="subtitle">Bar</h2>

const container = document.querySelector('.container');
const h1 = document.querySelector('h1');
const h2 = document.querySelector('h2');

container.contains(h1);  // true
container.contains(h2);  // false
    

Чтобы получить больше информации об элементах, используй compareDocumentPosition. Этот метод определяет, предшествует ли один элемент другому элементу или следует за ним, или же один из них содержит другой. Возвращает целое число, которое представляет собой отношение между сравниваемыми элементами.

Вот пример с элементами из предыдущего фрагмента:

        <div class="container">
  <h1 class="title">Foo</h1>
</div>

<h2 class="subtitle">Bar</h2>

const container = document.querySelector('.container');
const h1 = document.querySelector('h1');
const h2 = document.querySelector('h2');

//  20: h1 содержится в элементе container и следует за container
container.compareDocumentPosition(h1); 

// 10: 10: container содержит h1 и предшествует ему
h1.compareDocumentPosition(container);

// 4: h2 следует за h1
h1.compareDocumentPosition(h2);

// 2: h1 предшествует h2
h2.compareDocumentPosition(h1);
    

Возвращаемое значение compareDocumentPosition – целое число, биты которого представляют собой отношение между узлами касательно аргумента, указанного в этом методе.

Таким образом, с учётом синтаксиса node.compareDocumentPostion(otherNode) возвращаемое число означает:

  1. 1: узлы не части одного и того же документа.
  2. 2: otherNode предшествует node
  3. 4: otherNode следует за node.
  4. 8: otherNode содержит node.  
  5. 16: otherNode содержится вnode.  

Поскольку устанавливается не один бит, в приведённом выше примере container.compareDocumenPosition(h1) возвращает 20, когда ожидалось 16, ведь h1 содержится в container. Но h1 также следует за элементом container (4), поэтому полученное значение равно 16 + 4 = 20.

Больше подробностей, пожалуйста!

Чтобы отслеживать изменения в любом узле DOM, используй интерфейс MutationObserver. Это включает в себя изменения текста, добавление или удаление узлов из наблюдаемого элемента или изменение атрибутов узла.

MutationObserver – невероятно мощный API для наблюдения практически за любыми изменениями, которые происходят в элементе DOM и его дочерних узлах.

Новый MutationObserver создаётся путём вызова конструктора с функцией обратного вызова. Этот обратный вызов будет запускаться всякий раз, когда изменяется наблюдаемый узел:

        const observer = new MutationObserver(callback);
    

Чтобы отслеживать элемент, вызываем метод наблюдателя observe, где исследуемый узел будет первым аргументом и объект с параметрами – вторым.

        const target = document.querySelector('#container');
const observer = new MutationObserver(callback);

observer.observe(target, options);
    

Наблюдение начинается после вызова observe. Объект параметров принимает следующие ключи:

  1. attributes: когда значение true, изменения атрибутов узла будут отслеживаться.
  2. attributeFilter: массив имён атрибутов для мониторинга, когда attributes равно true, а этот ключ не задан, наблюдаются изменения всех атрибутов узла.
  3. attributeOldValue: при установке true записывается предыдущее значение атрибута при каждом изменении.
  4. characterData: когда значение true, регистрирует изменения текста текстового узла, так что подходит для элементов Text, а не HTMLElement. Чтобы это работало, узел должен быть объектом Text или, если наблюдатель отслеживает HTMLElement, требуется значение true у параметра subtree для мониторинга изменений дочерних узлов.
  5. characterDataOldValue: если true, регистрируется предыдущее значение символьных данных при каждом изменении.
  6. subtree: когда true, также отслеживаются изменения дочерних узлов наблюдаемого элемента. 
  7. childList: установи true, чтобы контролировать добавление и удаление дочерних узлов элемента. Еслиsubtree задано значение true, для дочерних элементов также отслеживаются удаление и добавление дочерних узлов. 

Когда начинаешь мониторинг элемента при запуске observe, обратный вызов в конструкторе MutationObserver вызывается с массивом объектов MutationRecord, описывающих произошедшие изменения, и наблюдателем в качестве второго параметра.

A MutationRecord содержит следующие свойства:

  1. type: тип изменения, attributes, characterData либо childList.
  2. target: изменённый элемент: его атрибуты, символьные данные или дочерние элементы.
  3. addedNodes: список добавленных узлов или пустой NodeList, если ничего не добавлялось.
  4. removedNodes: список удалённых узлов или пустой NodeList, если ничего не удалялось.
  5. attributeName: имя изменённого атрибута или null, если атрибуты не изменялись.
  6. previousSibling: предыдущий смежный элемент добавленных или удалённых узлов или null.
  7. nextSibling: следующий смежный элемент добавленных или удалённых узлов или null.

Допустим, будем наблюдать изменения в атрибутах и ​​дочерних узлах:

        const target = document.querySelector('#container');
const callback = (mutations, observer) => {
  mutations.forEach(mutation => {
    switch (mutation.type) {
      case 'attributes':
        // имя изменённого атрибута находится в
        // mutation.attributeName
        // и его старое значение содержится в mutation.oldValue
        // текущее значение получаем с помощью 
        // target.getAttribute(mutation.attributeName)
        break;
      case 'childList':
        // добавленные узлы хранятся в mutation.addedNodes
        // удалённые узлы – в mutation.removedNodes
        break;
    }
  });
};

const observer = new MutationObserver(callback);

observer.observe(target, {
  attributes: true,
  attributeFilter: ['foo'], // отслеживает только атрибут 'foo'
  attributeOldValue: true,
  childList: true
});
    

Когда завершаем мониторинг, отключаем наблюдателя и при необходимости вызываем метод takeRecords для получения незавершённых изменений, которые ещё не доставлены в обратный вызов:

        const mutations = observer.takeRecords();
callback(mutations);
observer.disconnect();
    

Не бойся DOM

DOM API потрясает мощностью и универсальностью, хотя и многословен. Помни о его предназначении предоставлять разработчикам низкоуровневые строительные блоки для создания абстракций. В этом смысле он требует многословности, чтобы гарантировать однозначный и понятный API.

Пусть дополнительные нажатия клавиш не отпугивают от использования полного потенциала этого интерфейса.

DOM – необходимое знание для каждого JavaScript разработчика, поскольку, скорее всего, используешь его каждый день. Не бойся этого, используй в полной мере.

И тогда станешь лучшим разработчиком. 

DOM уже не кажется таким страшным? :)

Комментарии

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ