Как перестать бояться DOM, использовать его в полной мере и действительно начать любить его

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

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

После непродолжительных поисков я нашел навороченное решение на основе Flash и эту библиотеку JavaScript под названием MooTools. В 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(/* your selector */)

Здесь вы можете использовать любой селектор:

document.querySelector('.foo')            // class selector
document.querySelector('#foo')            // id selector
document.querySelector('div')             // tag selector
document.querySelector('[name="foo"]')    // attribute selector
document.querySelector('div + p > span')  // you go girl!

Если не найдено ни одного элемента, он вернет null.

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

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

document.querySelectorAll('p')  // selects all <p> elements

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

NodeList - это повторяемый объект, который подобен массиву, но не в действительности массив, поэтому у него разные методы. На нем можно запустить forEach, но нельзя, например, map, reduce или find.

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

const arr = [...document.querySelectorAll('p')];
or
const arr = Array.from(document.querySelectorAll('p'));
arr.find(element => {...});  // .find() now works

Метод 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')  // finds all <p> tags in #container only

Но все равно многословно!

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

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');

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

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

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

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

<a href="/home" class="active">Home</a>

Вам нужно будет сделать:

const link = document.createElement('a');
link.setAttribute('href', '/home');
link.className = 'active';
link.textContent = 'Home';
document.body.appendChild(link);

А теперь представьте, что вам нужно сделать это для 10 элементов…

По крайней мере, jQuery позволяет:

$('body').append('<a href="/home" class="active">Home</a>');

Ну что ж? Есть нативный эквивалент:

document.body.insertAdjacentHTML('beforeend', 
'<a href="/home" class="active">Home</a>');

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

  • 'beforebegin': перед элементом
  • 'afterbegin': внутри элемента перед его первым дочерним элементом
  • 'beforeend': внутри элемента после его последнего дочернего элемента
  • '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>Title</h1>
</div>
<div class="second">
  <h2>Subtitle</h2>
</div>

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

const h1 = document.querySelector('h1');
const h2 = document.querySelector('h2');
h1.insertAdjacentElement('afterend', h2);

он будет просто перемещен, не скопирован:

<div class="first">
  <h1>Title</h1>
  <h2>Subtitle</h2>
</div>
<div class="second">
  
</div>

Замена элементов

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

someElement.replaceWith(otherElement);

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

<div class="first">
  <h1>Title</h1>
</div>
<div class="second">
  <h2>Subtitle</h2>
</div>
const h1 = document.querySelector('h1');
const h2 = document.querySelector('h2');
h1.replaceWith(h2);
// result:
<div class="first">
  <h2>Subtitle</h2>
</div>
<div class="second">
  
</div>

Удаление элементов

Просто вызовите его remove метод:

const container = document.querySelector('#container');
container.remove();  // hasta la vista, baby

Намного лучше, чем по старинке:

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">Home</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, does not have class "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 is contained by container and follows container
container.compareDocumentPosition(h1); 
// 10: container contains h1 and precedes it
h1.compareDocumentPosition(container);
// 4: h2 follows h1
h1.compareDocumentPosition(h2);
// 2: h1 precedes h2
h2.compareDocumentPosition(h1);

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

Итак, учитывая синтаксис node.compareDocumentPostion(otherNode), значение возвращаемого значения таково:

  • 1: узлы не являются частью одного документа
  • 2: otherNode предшествует node
  • 4: otherNode следует за node
  • 8: otherNode содержит node
  • 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.

Этот объект параметров принимает следующие ключи:

  • attributes: если установлено значение true, будут отслеживаться изменения атрибутов узла.
  • attributeFilter: массив имен атрибутов для наблюдения, когда attributes равно true и он не установлен, будут отслеживаться изменения всех атрибутов узла.
  • attributeOldValue: при установке на true предыдущее значение атрибута будет записываться всякий раз, когда происходит изменение
  • characterData: при значении true будут записываться изменения текста текстового узла, поэтому это работает только с Textnodes, а не HTMLElements. Чтобы это работало, наблюдаемый узел должен быть Text узлом или, если наблюдатель отслеживает HTMLElement, параметр subtree должен быть установлен на true, чтобы также отслеживать изменения в дочерних узлах.
  • characterDataOldValue: при установке на true предыдущее значение разделенных данных будет записываться всякий раз, когда происходит изменение
  • subtree: установите значение true, чтобы также наблюдать изменения в дочерних узлах наблюдаемого элемента.
  • childList: установите значение true для отслеживания элемента на предмет добавления и удаления дочерних узлов. Если для subtree установлено значение true, дочерние элементы также будут отслеживаться на предмет добавления и удаления дочерних узлов.

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

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

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

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

const target = document.querySelector('#container');
const callback = (mutations, observer) => {
  mutations.forEach(mutation => {
    switch (mutation.type) {
      case 'attributes':
        // the name of the changed attribute is in
        // mutation.attributeName
        // and its old value is in mutation.oldValue
        // the current value can be retrieved with 
        // target.getAttribute(mutation.attributeName)
        break;
      case 'childList':
        // any added nodes are in mutation.addedNodes
        // any removed nodes are in mutation.removedNodes
        break;
    }
  });
};
const observer = new MutationObserver(callback);
observer.observe(target, {
  attributes: true,
  attributeFilter: ['foo'], // only observe attribute 'foo'
  attributeOldValue: true,
  childList: true
});

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

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

Не бойтесь DOM

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

Дополнительные нажатия клавиш не должны отпугивать вас от использования его в полной мере.

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

Вы станете лучшим разработчиком для этого.