[Перевод] Как работает JS: пользовательские элементы

[Советуем почитать] Предыдущие 18 частей цикла


Представляем вашему вниманию перевод 19 статьи из серии материалов компании SessionStack, посвящённых особенностям различных механизмов экосистемы JavaScript. Сегодня речь пойдёт о стандарте Custom Elements — о так называемых «пользовательских элементах». Мы поговорим, о том, какие задачи они позволяют решать, и о том, как их создавать и использовать.

image


Обзор


В одном из предыдущих материалов этой серии мы говорили о Shadow DOM и о некоторых других технологиях, которые являются частью более крупного явления — веб-компонентов. Веб-компоненты нацелены на то, чтобы дать разработчикам возможность расширять стандартные возможности HTML, создавая компактные, модульные и подходящие для повторного использования элементы. Это — сравнительно новый стандарт W3C, на который уже обратили внимание производители всех ведущих браузеров. Его можно встретить в продакшне, хотя, конечно, пока его работу обеспечивают полифиллы (о них мы поговорим позже).

Как вы, возможно, уже знаете, браузеры дают нам несколько важнейших средств для разработки веб-сайтов и веб-приложений. Речь идёт об HTML, CSS и JavaScript. HTML используют для структурирования веб-страниц, благодаря CSS им придают приятный внешний вид, а JavaScript отвечает за интерактивные возможности. Однако до появления веб-компонентов связывать действия, реализуемые средствами JavaScript, с HTML-структурой, было не так уж и легко.

Собственно говоря, здесь мы рассмотрим основу веб-компонентов — пользовательские элементы (Custom Elements). Если рассказать о них в двух словах, то API, предназначенное для работы с ними, позволяет программисту создавать собственные HTML-элементы со встроенной в них JavaScript-логикой и стилями, описанными средствами CSS. Многие путают пользовательские элементы с технологией Shadow DOM. Однако, это — две совершенно разные вещи, которые, на самом деле, дополняют друг друга, но не являются взаимозаменяемыми.

Некоторые фреймворки (такие, как Angular или React) пытаются решить ту же проблему, которую решают пользовательские элементы, вводя собственные концепции. Пользовательские элементы можно сравнить с директивами Angular или с компонентами React. Однако пользовательские элементы — это стандартная возможность браузера, для работы с ними не нужно ничего, кроме обычных JavaScript, HTML и CSS. Конечно, это не позволяет говорить о том, что они являются заменой для обычных JS-фреймворков. Современные фреймворки дают нам гораздо большее, нежели лишь возможность имитировать поведение пользовательских элементов. В результате можно говорить о том, что и фреймворки, и пользовательские элементы — это технологии, которые можно использовать совместно для решения задач веб-разработки.

API


Прежде чем мы продолжим, давайте посмотрим, какие возможности нам даёт API для работы с пользовательскими элементами. А именно, речь идёт о глобальном объекте customElements, который имеет несколько методов:

  • Метод define(tagName, constructor, options) позволяет определить (создать, зарегистрировать) новый пользовательский элемент. Он принимает три аргумента — имя тега для пользовательского элемента, соответствующее правилам именования таких элементов, объявление класса и объект с параметрами. В настоящий момент поддерживается лишь один параметр — extends, который представляет собой строку, задающую имя встроенного элемента, который планируется расширить. Эта возможность используется для создания особых версий стандартных элементов.
  • Метод get(tagName) возвращает конструктор пользовательского элемента при условии, что этот элемент уже определён, в противном случае он возвращает undefined. Он принимает один аргумент — имя тега пользовательского элемента.
  • Метод whenDefined(tagName) возвращает промис, который разрешается после того, как пользовательский элемент будет создан. Если элемент уже определён, этот промис разрешается немедленно. Промис отклоняется, если переданное ему имя тега не является допустимым именем тега пользовательского элемента. Этот метод принимает имя тега пользовательского элемента.


Создание пользовательских элементов


Создавать пользовательские элементы очень просто. Для этого надо сделать две вещи: создать объявление класса для элемента, который должен расширять класс HTMLElement и зарегистрировать этот элемент под выбранным именем. Вот как это выглядит:

class MyCustomElement extends HTMLElement {
  constructor() {
    super();
    // …
  }

  // …
}

customElements.define('my-custom-element', MyCustomElement);


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

customElements.define('my-custom-element', class extends HTMLElement {
  constructor() {
    super();
    // …
  }

  // …
});


Как можно видеть из примеров, регистрация пользовательского элемента производится с помощью уже знакомого вам метода customElements.define(...).

Проблемы, которые решают пользовательские элементы


Поговорим о проблемах, которые позволяют решать пользовательские элементы. Одна из них — это улучшение структуры кода и устранение того, что называют «супом из тегов div» (div soup). Это явление представляет собой весьма распространённую в современных веб-приложениях структуру кода, в которой имеется множество вложенных друг в друга элементов div. Вот как это может выглядеть:


Подобный HTML-код используют по вполне оправданным причинам — он описывает устройство страницы и обеспечивает её правильный вывод на экран. Однако это ухудшает читабельность HTML-кода и усложняет его сопровождение.

Предположим, у нас имеется компонент, выглядящий так, как показано на следующем рисунке.

85a949ef606dd68521cd83fd845d09ba.png


Внешний вид компонента

При использовании традиционного подхода к описанию подобных вещей этому компоненту будет соответствовать следующий код:


А теперь представьте себе, что мы могли бы, вместо этого кода, воспользоваться вот таким описанием компонента:


  
    
    
    
    
  


Уверен, все согласятся с тем, что второй фрагмент кода выглядит куда лучше. Такой код легче читать, легче поддерживать, он понятен и разработчику, и браузеру. Всё сводится к тому, что он — проще, чем тот, в котором имеются множество вложенных тегов div.

Следующая проблема, которую можно решить с помощью пользовательских элементов — это повторное использование кода. Код, который пишут разработчики, должен быть не только работающим, но и поддерживаемым. Повторное использование кода, в противовес постоянному написанию одних и тех же конструкций, улучшает возможности по поддержке проектов.
Вот простой пример, который позволит лучше разобраться в этой идее. Предположим, у нас имеется следующий элемент:


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

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



Однако современные веб-приложения — это гораздо больше, чем статический HTML-код. Они интерактивны. Источником их интерактивности является JavaScript. Обычно, для обеспечения подобных возможностей, создают некие элементы, потом подключают к ним прослушиватели событий, что позволяет им реагировать на воздействия пользователя. Например, они могут реагировать на щелчки, на «зависание» над ними указателя мыши, на перетаскивание их по экрану, и так далее. Вот как к элементу подключают прослушиватель события, возникающего при щелчке по нему мышью:

var myDiv = document.querySelector('.my-custom-element');

myDiv.addEventListener('click', _ => {
  myDiv.innerHTML = ' I have been clicked ';
});


А вот HTML-код этого элемента:

I have not been clicked yet.


Благодаря использованию API для работы с пользовательскими элементами вся эта логика может быть включена в сам элемент. Для сравнения — ниже показан код объявления пользовательского элемента, включающего в себя обработчик событий:

class MyCustomElement extends HTMLElement {
  constructor() {
    super();

    var self = this;

    self.addEventListener('click', _ => {
      self.innerHTML = ' I have been clicked ';
    });
  }
}

customElements.define('my-custom-element', MyCustomElement);


А вот как он выглядит в HTML-коде страницы:


  I have not been clicked yet


На первый взгляд может показаться, что для создания пользовательского элемента требуется больше строк JS-кода. Однако в реальных приложениях редко когда бывает так, чтобы подобные элементы создавали бы лишь для того, чтобы воспользоваться ими только один раз. Ещё одно типичное в современных веб-приложениях явление заключается в том, что большинство элементов в них создаётся динамически. Это приводит к необходимости поддержки двух различных сценариев работы с элементами — ситуаций, когда они добавляются на страницу динамически, средствами JavaScript, и ситуаций, когда они описаны в исходной HTML-структуре страницы. Благодаря применению пользовательских элементов работа в этих двух ситуациях упрощается.

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

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

Требования


Прежде чем вы приступите к разработке собственных пользовательских элементов, вам стоит знать о некоторых правилах, которым надо следовать при их создании. Вот они:

  • Имя компонента должно включать в себя дефис (символ -). Благодаря этому HTML-парсер может различать встроенные и пользовательские элементы. Кроме того, такой подход обеспечивает отсутствие коллизий имён со встроенными элементами (и с теми, что есть сейчас, и с теми, которые появятся в будущем). Например, действительное имя пользовательского элемента — это >my-custom-element<, а имена >myCustomElement< и  являются неподходящими.
  • Запрещено более одного раза регистрировать один и тот же тег. Попытка сделать это приведёт к выдаче браузером ошибки DOMException. Пользовательские элементы нельзя переопределять.
  • Пользовательские теги не могут быть самозакрывающимися. HTML-парсер поддерживает лишь ограниченный набор стандартных самозакрывающихся тегов (например — , ,
    ).


Возможности


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

Одна из самых заметных возможностей пользовательских элементов заключается в том, что объявление класса элемента относится к самому DOM-элементу. Это означает, что в объявлении можно использовать ключевое слово this для подключения прослушивателей событий, для доступа к свойствам, к дочерним узлам, и так далее.

class MyCustomElement extends HTMLElement {
  // ...

  constructor() {
    super();

    this.addEventListener('mouseover', _ => {
      console.log('I have been hovered');
    });
  }

  // ...
}


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

Существует несколько методов, которые позволяют выполнять код в определённые моменты жизненного цикла элемента.

  • Метод constructor вызывается один раз, при создании или «обновлении» (upgrade) элемента (об этом мы поговорим ниже). Чаще всего он используется для инициализации состояния элемента, для подключения прослушивателей событий, создания Shadow DOM, и так далее. Не забывайте о том, что в конструкторе всегда нужно вызывать super().
  • Метод connectedCallback вызывается каждый раз, когда элемент добавляется в DOM. Его можно использовать (и именно так и рекомендуется его использовать) для того, чтобы откладывать выполнение каких-либо действий до момента, когда элемент окажется на странице (например, так можно отложить загрузку каких-то данных).
  • Метод disconnectedCallback вызывается, когда элемент удаляется из DOM. Обычно он используется для освобождения ресурсов. Учитывайте, что этот метод не вызывается, если пользователь закрывает вкладку браузера со страницей. Поэтому не полагайтесь на него при необходимости выполнения каких-то особенно важных действий.
  • Метод attributeChangedCallback вызывается, когда добавляется, удаляется, обновляется или заменяется атрибут элемента. Кроме того, он вызывается при создании элемента парсером. Однако обратите внимание на то, что этот метод применяется лишь для атрибутов, которые перечислены в свойстве observedAttributes.
  • Метод adoptedCallback вызывается при вызове метода document.adoptNode(...), используемого для перемещения узла в другой документ.


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

Отражение свойств


У встроенных HTML-элементов есть одна очень удобная возможность: отражение свойств (property reflection). Благодаря этому механизму значения некоторых свойств напрямую отражаются в DOM в виде атрибутов. Скажем, это характерно для свойства id. Например, выполним такую операцию:

myDiv.id = 'new-id';


Соответствующие изменения затронут и DOM:

...


Этот механизм действует и в обратном направлении. Он весьма полезен, так как позволяет декларативно конфигурировать элементы.

У пользовательских элементов нет подобной встроенной возможности, но её можно реализовать самостоятельно. Для того чтобы некоторые свойства пользовательских элементов вели себя похожим образом, можно настроить их геттеры и сеттеры.

class MyCustomElement extends HTMLElement {
  // ...

  get myProperty() {
    return this.hasAttribute('my-property');
  }

  set myProperty(newValue) {
    if (newValue) {
      this.setAttribute('my-property', newValue);
    } else {
      this.removeAttribute('my-property');
    }
  }

  // ...
}


Расширение существующих элементов


API пользовательских элементов позволяет не только создавать новые HTML-элементы, но и расширять существующие. Причём, речь идёт и о стандартных элементах, и о пользовательских. Делается это с помощью использования ключевого слова extends при объявлении класса:

class MyAwesomeButton extends MyButton {
  // ...
}

customElements.define('my-awesome-button', MyAwesomeButton);
В случае со стандартными элементами нужно, кроме того, использовать, при вызове метода customElements.define(...), объект со свойством extends и со значением, представляющим собой имя тега расширяемого элемента. Это сообщает браузеру о том, какой именно элемент является основой нового пользовательского элемента, так как множество встроенных элементов имеют одинаковые DOM-интерфейсы. Без указания того, какой именно элемент используется в качестве основы для пользовательского элемента, браузер не будет знать о том, на какой именно функциональности базируется новый элемент.

class MyButton extends HTMLButtonElement {
  // ...
}

customElements.define('my-button', MyButton, {extends: 'button'});


Расширенные стандартные элементы ещё называются «кастомизированными встроенными элементами» (customized built-in element).

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

Обратите внимание на то, что сейчас кастомизированные встроенные элементы поддерживаются лишь в Chrome 67+. Это появится и в других браузерах, однако, известно, что разработчики Safari решили эту возможность не реализовывать.

Обновление элементов


Как уже было сказано, метод customElements.define(...) используется для регистрации пользовательских элементов. Однако регистрацию нельзя назвать тем действием, которое нужно выполнять в первую очередь. Регистрацию пользовательского элемента можно на некоторое время отложить, причём, это время может настать даже тогда, когда элемент уже добавлен в DOM. Этот процесс называют обновлением элемента (upgrade). Для того чтобы узнать, когда элемент будет зарегистрирован, браузер предоставляет метод customElements.whenDefined(...). Ему передают имя тега элемента, а он возвращает промис, который разрешается после регистрации элемента.

customElements.whenDefined('my-custom-element').then(_ => {
  console.log('My custom element is defined');
});


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

Shadow DOM


Как уже было сказано, пользовательские элементы и Shadow DOM — это взаимодополняющие технологии. Первая позволяет инкапсулировать в пользовательских элементах JS-логику, а вторая позволяет создавать изолированные окружения для фрагментов DOM, на которые не влияет то, что находится за их пределами. Если вы чувствуете, что вам нужно лучше разобраться с концепцией Shadow DOM — взгляните на одну из наших предыдущих публикаций.

Вот как использовать Shadow DOM для пользовательского элемента:

class MyCustomElement extends HTMLElement {
  // ...

  constructor() {
    super();

    let shadowRoot = this.attachShadow({mode: 'open'});
    let elementContent = document.createElement('div');
    shadowRoot.appendChild(elementContent);
  }

  // ...
});


Как видите, здесь ключевую роль играет вызов this.attachShadow.

Шаблоны


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