Статьи

Основы манипуляции с DOM в ванильном JavaScript (без jQuery)

Эта статья включена в нашу антологию, современный JavaScript . Если вы хотите, чтобы все в одном месте было в курсе современного JavaScript, зарегистрируйтесь в SitePoint Premium и загрузите себе копию.

DOM Manipulation в ванильном JavaScript

Всякий раз, когда нам нужно выполнить DOM-манипуляции, мы все быстро достигаем jQuery. Тем не менее, ванильный JavaScrpt DOM API на самом деле вполне способен, и, поскольку IE <11 был официально заброшен , его теперь можно использовать без каких-либо забот.

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

  • запрашивать и модифицировать DOM,
  • изменение классов и атрибутов,
  • прослушивание событий и
  • анимация.

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

Итак, давайте вернемся к этому …

DOM Manipulation: запрос к DOM

Обратите внимание: я не буду подробно описывать API-интерфейс Vanilla DOM, а лишь коснусь его поверхности. В примерах использования вы можете встретить методы, которые я не представил явно. В этом случае просто обратитесь к превосходной сети разработчиков Mozilla для получения подробной информации.

DOM может быть запрошен с использованием .querySelector() , который принимает произвольный селектор CSS в качестве аргумента:

 const myElement = document.querySelector('#foo > div.bar') 

Это вернет первое совпадение (сначала глубина). И наоборот, мы можем проверить, соответствует ли элемент селектору:

 myElement.matches('div.bar') === true 

Если мы хотим получить все вхождения, мы можем использовать:

 const myElements = document.querySelectorAll('.bar') 

Если у нас уже есть ссылка на родительский элемент, мы можем просто запросить дочерние элементы этого элемента вместо всего document . Сузив контекст таким образом, мы можем упростить селекторы и повысить производительность.

 const myChildElemet = myElement.querySelector('input[type="submit"]') // Instead of // document.querySelector('#foo > div.bar input[type="submit"]') 

Тогда зачем .getElementsByTagName() использовать другие, менее удобные методы, такие как .getElementsByTagName() ? Ну, одно важное отличие состоит в том, что результат .querySelector() не является живым , поэтому, когда мы динамически добавляем элемент (см. Раздел 3 ), который соответствует селектору, коллекция не будет обновляться.

 const elements1 = document.querySelectorAll('div') const elements2 = document.getElementsByTagName('div') const newElement = document.createElement('div') document.body.appendChild(newElement) elements1.length === elements2.length // false 

Другое соображение заключается в том, что такая живая коллекция не должна иметь всю информацию .querySelectorAll() , тогда как .querySelectorAll() сразу собирает все в статический список, делая его менее производительным .

Работа с ноделистами

Теперь есть две распространенные ошибки, касающиеся .querySelectorAll() . Во-первых, мы не можем вызывать методы Node для результата и распространять их на его элементы (как если бы вы могли использоваться из объектов jQuery). Скорее мы должны явно перебрать эти элементы. И это еще одна проблема: возвращаемое значение — NodeList, а не Array. Это означает, что обычные методы Array не доступны напрямую. Существует несколько соответствующих реализаций .forEach , таких как .forEach , которые, тем не менее, не поддерживаются ни одним IE. Поэтому мы должны сначала преобразовать список в массив или «позаимствовать» эти методы из прототипа Array.

 // Using Array.from() Array.from(myElements).forEach(doSomethingWithEachElement) // Or prior to ES6 Array.prototype.forEach.call(myElements, doSomethingWithEachElement) // Shorthand: [].forEach.call(myElements, doSomethingWithEachElement) 

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

 myElement.children myElement.firstElementChild myElement.lastElementChild myElement.previousElementSibling myElement.nextElementSibling 

Поскольку интерфейс Element наследуется от интерфейса Node , также доступны следующие свойства:

 myElement.childNodes myElement.firstChild myElement.lastChild myElement.previousSibling myElement.nextSibling myElement.parentNode myElement.parentElement 

Где первые только ссылочные элементы, последние (за исключением .parentElement ) могут быть узлами любого типа, например текстовыми узлами. Затем мы можем проверить тип данного узла, например, например

 myElement.firstChild.nodeType === 3 // this would be a text node 

Как и для любого объекта, мы можем проверить цепочку прототипов узла, используя оператор instanceof :

 myElement.firstChild.nodeType instanceof Text 

Модификация Классов и Атрибутов

Изменить классы элементов так же просто, как:

 myElement.classList.add('foo') myElement.classList.remove('bar') myElement.classList.toggle('baz') 

Вы можете прочитать более подробное обсуждение того, как изменять классы, в этом кратком совете Яфи Берхану . Свойства элемента могут быть доступны, как и свойства любого другого объекта

 // Get an attribute value const value = myElement.value // Set an attribute as an element property myElement.value = 'foo' // Set multiple properties using Object.assign() Object.assign(myElement, { value: 'foo', id: 'bar' }) // Remove an attribute myElement.value = null 

Обратите внимание, что существуют также методы .getAttibute() , .setAttribute() и. removeAttribute() . Они напрямую изменяют атрибуты HTML (в отличие от свойств DOM) элемента, вызывая перерисовку браузера (вы можете наблюдать за изменениями, осматривая элемент с помощью инструментов разработчика вашего браузера). Такой перерисовка браузера не только обходится дороже, чем просто установка свойств DOM, но и эти методы могут иметь неожиданные результаты .

Как правило, используйте их только для атрибутов, которые не имеют соответствующего свойства DOM (например, colspan ), или если вы действительно хотите «сохранить» эти изменения в HTML (например, чтобы сохранить их при клонировании элемента или изменение .innerHTML его родителя — см. раздел 3 ).

Добавление стилей CSS

Правила CSS могут применяться как любое другое свойство; Обратите внимание, что свойства в верблюжьей оболочке в JavaScript:

 myElement.style.marginLeft = '2em' 

Если нам нужны определенные значения, мы можем получить их через свойство .style . Однако это даст нам только те стили, которые были применены явно. Чтобы получить вычисленные значения, мы можем использовать, .window.getComputedStyle() . Он берет элемент и возвращает CSSStyleDeclaration, содержащий все стили самого элемента, а также те, которые унаследованы от его родителей:

 window.getComputedStyle(myElement).getPropertyValue('margin-left') 

Модификация DOM

Мы можем перемещать элементы так:

 // Append element1 as the last child of element2 element1.appendChild(element2) // Insert element2 as child of element 1, right before element3 element1.insertBefore(element2, element3) 

Если мы не хотим перемещать элемент, но вставляем копию, мы можем клонировать его следующим образом:

 // Create a clone const myElementClone = myElement.cloneNode() myParentElement.appendChild(myElementClone) 

Метод .cloneNode() может принимать логическое значение в качестве аргумента; если установлено значение true, будет создана глубокая копия, то есть ее потомки также будут клонированы.

Конечно, мы также можем создавать совершенно новые элементы или текстовые узлы:

 const myNewElement = document.createElement('div') const myNewTextNode = document.createTextNode('some text') 

который мы можем затем вставить, как показано выше. Если мы хотим удалить элемент, мы не можем сделать это напрямую, но мы можем удалить дочерние элементы из родительского элемента, например, так:

 myParentElement.removeChild(myElement) 

Это дает нам небольшую работу вокруг, то есть фактически может косвенно удалить элемент, ссылаясь на его родительский элемент:

 myElement.parentNode.removeChild(myElement) 

Свойства элемента

Каждый элемент также имеет свойства .innerHTML и .textContent (а также .innerText , который аналогичен .textContent , но имеет некоторые важные различия ). Они содержат HTML и текстовое содержимое соответственно. Это доступные для записи свойства, то есть мы можем напрямую изменять элементы и их содержимое:

 // Replace the inner HTML myElement.innerHTML = ` <div> <h2>New content</h2> <p>beep boop beep boop</p> </div> ` // Remove all child nodes myElement.innerHTML = null // Append to the inner HTML myElement.innerHTML += ` <a href="foo.html">continue reading...</a> <hr/> ` 

Добавление разметки к HTML, как показано выше, обычно является плохой идеей, поскольку мы потеряем все ранее сделанные изменения свойств для затронутых элементов (если только мы не сохранили эти изменения в виде атрибутов HTML, как показано в разделе 2 ) и прослушивателей связанных событий. Установка .innerHTML для полного удаления разметки и замены ее чем-то другим, например, разметкой, выполняемой сервером. Поэтому добавление элементов лучше сделать так:

 const link = document.createElement('a') const text = document.createTextNode('continue reading...') const hr = document.createElement('hr') link.href = 'foo.html' link.appendChild(text) myElement.appendChild(link) myElement.appendChild(hr) 

Однако при таком подходе мы вызовем две перерисовки браузера — по одной для каждого добавленного элемента — тогда как изменение .innerHTML вызывает только одну. Чтобы обойти эту проблему производительности, мы можем сначала собрать все узлы в DocumentFragment , а затем просто добавить этот единственный фрагмент:

 const fragment = document.createDocumentFragment() fragment.appendChild(text) fragment.appendChild(hr) myElement.appendChild(fragment) 

Прослушивание событий

Это, пожалуй, самый известный способ привязки слушателя события:

 myElement.onclick = function onclick (event) { console.log(event.type + ' got fired') } 

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

Вместо этого мы можем использовать гораздо более .addEventListener() метод .addEventListener() чтобы добавить столько событий .addEventListener() количества типов, сколько нам нужно. Он принимает три аргумента: тип события (например, click ), функция, которая вызывается всякий раз, когда событие происходит в элементе (эта функция получает объект события), и необязательный объект конфигурации, который будет объяснен ниже.

 myElement.addEventListener('click', function (event) { console.log(event.type + ' got fired') }) myElement.addEventListener('click', function (event) { console.log(event.type + ' got fired again') }) 

В функции прослушивателя event.target ссылается на элемент, для которого было инициировано событие (как this , если, конечно, мы не используем функцию стрелки ). Таким образом, вы можете легко получить доступ к его свойствам следующим образом:

 // The `forms` property of the document is an array holding // references to all forms const myForm = document.forms[0] const myInputElements = myForm.querySelectorAll('input') Array.from(myInputElements).forEach(el => { el.addEventListener('change', function (event) { console.log(event.target.value) }) }) 

Предотвращение действий по умолчанию

Обратите внимание, что event всегда доступно в функции слушателя, но хорошо бы точно передать его в любом случае, когда это необходимо (и мы можем назвать его, как нам нравится, конечно же). Не вдаваясь в подробности о самом интерфейсе Event , одним особенно заслуживающим внимания методом является .preventDefault() , который, в .preventDefault() , предотвратит поведение браузера по умолчанию, например, .preventDefault() по ссылке. Другим распространенным вариантом использования является условное предотвращение отправки формы в случае сбоя проверки формы на стороне клиента.

 myForm.addEventListener('submit', function (event) { const name = this.querySelector('#name') if (name.value === 'Donald Duck') { alert('You gotta be kidding!') event.preventDefault() } }) 

Другой важный метод события — это .stopPropagation() , который предотвратит всплывающее событие DOM. Это означает, что если у нас есть прослушиватель щелчков (скажем), останавливающий распространение на элементе, и другой слушатель щелчков на одном из его родителей, событие щелчка, которое запускается на дочернем элементе, не сработает на родительском элементе — иначе это сработало бы на обоих.

Теперь .addEventListener() принимает необязательный объект конфигурации в качестве третьего аргумента, который может иметь любое из следующих логических свойств (все из которых по умолчанию имеют значение false ):

  • capture : событие будет инициировано на элементе перед любым другим элементом, находящимся под ним в DOM (захват и всплытие событий — это отдельная статья, подробнее см. здесь )
  • once : Как вы можете догадаться, это означает, что событие будет инициировано только один раз.
  • passive : это означает, что event.preventDefault() будет игнорироваться (и обычно event.preventDefault() предупреждение в консоли)

Наиболее распространенным вариантом является .capture ; на самом деле, это так часто, что для этого есть сокращение: вместо того, чтобы указывать его в объекте конфигурации, вы можете просто передать здесь логическое значение:

 myElement.addEventListener(type, listener, true) 

.removeEventListener() событий могут быть удалены с помощью .removeEventListener() , который принимает тип события и ссылку на функцию обратного вызова, которую необходимо удалить; например, опция Once также может быть реализована как

 myElement.addEventListener('change', function listener (event) { console.log(event.type + ' got triggered on ' + this) this.removeEventListener('change', listener) }) 

Делегация мероприятия

Другой полезный шаблон — делегирование событий : скажем, у нас есть форма, и мы хотим добавить прослушиватель события change для всех его input дочерних элементов. Один из способов сделать это — перебрать их, используя myForm.querySelectorAll('input') как показано выше. Однако в этом нет необходимости, когда мы можем просто добавить его в саму форму и проверить содержимое event.target .

 myForm.addEventListener('change', function (event) { const target = event.target if (target.matches('input')) { console.log(target.value) } }) 

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

Анимация

Обычно самый чистый способ выполнения анимации — это применять классы CSS со свойством transition или использовать CSS @keyframes . Но если вам нужна большая гибкость (например, для игры), это можно сделать и с помощью JavaScript.

Наивным подходом было бы вызывать саму функцию window.setTimeout() , пока нужная анимация не будет завершена. Однако это неэффективно вызывает быстрое перепечатывание документов; и эта раскрутка макета может быстро привести к заиканию, особенно на мобильных устройствах. Кроме того, мы можем синхронизировать обновления с помощью window.requestAnimationFrame() чтобы запланировать все текущие изменения в следующем кадре перерисовки браузера. Он принимает обратный вызов в качестве аргумента, который получает текущую метку времени (высокое разрешение):

 const start = window.performance.now() const duration = 2000 window.requestAnimationFrame(function fadeIn (now)) { const progress = now - start myElement.style.opacity = progress / duration if (progress < duration) { window.requestAnimationFrame(fadeIn) } } 

Таким образом, мы можем получить очень плавную анимацию. Для более подробного обсуждения, пожалуйста, взгляните на эту статью Марка Брауна.

Написание собственных вспомогательных методов

Правда, всегда нужно перебирать элементы, чтобы что-то с ними делать, может быть довольно громоздким по сравнению с лаконичным и цепным синтаксисом jQuery $('.foo').css({color: 'red'}) . Так почему бы просто не написать наши собственные сокращенные методы для таких вещей?

 const $ = function $ (selector, context = document) { const elements = Array.from(context.querySelectorAll(selector)) return { elements, html (newHtml) { this.elements.forEach(element => { element.innerHTML = newHtml }) return this }, css (newCss) { this.elements.forEach(element => { Object.assign(element.style, newCss) }) return this }, on (event, handler, options) { this.elements.forEach(element => { element.addEventListener(event, handler, options) }) return this } // etc. } } 

Таким образом, у нас есть сверхтонкая DOM-библиотека с только теми методами, которые нам действительно нужны, и без веса обратной совместимости. Обычно у нас есть эти методы в прототипе нашей коллекции. Вот (несколько более сложный) смысл с некоторыми идеями, как реализовать таких помощников. В качестве альтернативы, мы могли бы сделать это так просто, как

 const $ = (selector, context = document) => context.querySelector(selector) const $$ = (selector, context = document) => context.querySelectorAll(selector) const html = (nodeList, newHtml) => { Array.from(nodeList).forEach(element => { element.innerHTML = newHtml }) } // And so on... 

демонстрация

Чтобы завершить эту статью, вот CodePen, который демонстрирует многие из концепций, объясненных выше, для реализации простой техники лайтбокса. Я призываю вас потратить некоторое время на просмотр исходного кода и сообщить мне в комментариях ниже, если у вас есть какие-либо замечания или вопросы.

Вывод

Я надеюсь, что смогу показать, что манипулирование DOM простым JavaScript не является ракетостроением и что на самом деле многие методы jQuery имеют прямые эквиваленты в нативном API DOM. Это означает, что в некоторых случаях повседневного использования (таких как навигационное меню или модальное всплывающее окно) дополнительные издержки библиотеки DOM могут быть неуместны.

И хотя это правда, что некоторые части нативного API являются многословными или неудобными (например, приходится постоянно перебирать списки узлов вручную), мы довольно легко можем написать наши собственные небольшие вспомогательные функции для абстрагирования таких повторяющихся задач.

Но теперь это для вас. Как вы думаете? Вы предпочитаете избегать сторонних библиотек там, где это возможно, или же ваши собственные библиотеки просто не стоят когнитивных накладных расходов? Позвольте мне знать в комментариях ниже.

Эта статья была рецензирована Вильданом Софтиком и Джоан Инь . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!