Веб компоненты. Часть 1: Пользовательские элементы

  • Tutorial
Вступление

Данная статья — первая часть из небольшой серии статей о создании веб-компонентов нативными средствами HTML и JS


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


Для реализации такого подхода, в настоящее время разрабатываются три спецификации, о первой из которых, пойдет речь в этой статье. Итак, знакомимся — спецификация пользовательских элементов (custom elements), рабочий черновик которой оупбликован 13.10.2016 и последняя версия которого датирована 04.12.2017.


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


  • определение (собственно, создание) нового элемента
  • упаковка нестандартного функционала и данных в один тег

В общих чертах


За создание пользовательских элементов веб-страницы отвечает интерфейс CustomElementRegistry, который позволяет регистрировать элементы, возвращает сведения о зарегистрированных элементах и т.д. Данный интерфейс доступен нам как объект window.customElement, у которого есть три интересующих нас метода:


  • define(name, constructor [,options]), метод определяющий пользовательский элемент, о его работе и параметрах речь пойдет в разделе «Определение» данной статьи;
  • get(name) метод, возвращающий конструктор пользовательского элемента по переданному имени, или undefined, если такой элемент не определен;
  • whenDefined(name)метод, возвращающий промис (Promise), который разрешается, когда элемент с указанным именем определен (или уже выполненный промис, если такой элемент уже определен).

Опубликованная версия спецификация предлагает создание пользовательских элементов в одной из двух форм: autonomous custom element (автономный пользовательский элемент) и customized built-in element (кастомизированный встроенный элемент).


О различиях


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


В свою очередь кастомизированный встроенный элемент должен быть определен с указанием свойства extends (расширяет). Создаваемый кастомизированный элемент, таким образом, получает возможность наследовать семантику элемента, указанного значением свойства extends. Необходимость этой особенности авторы спецификации обуславливают тем, что не все существующие поведения HTML элементов могут быть дублированы с использованием только автономных элементов
Различия также заметны в синтаксисе объявления элементов и их использования, но это гораздо проще рассмотреть на примерах (то есть далее по тексту данной статьи).


Об атрибутах


Кастомизированным встроенным элементам присущ атрибут is, который принимает в значение название кастомизированного встроенного элемента (по которому он был объявлен).
Естественно атрибут is, если будет объявлен на автономном элементе (чего, согласно спецификации, делать нельзя ), эффекта не будет.

В остальном атрибуты для обоих видов элемента могут быть любыми, при условии что они XML-совместимы(соответствуют www.w3.org/TR/xml/#NT-Name и не содержат U+003A — двоеточия) и не содержат ASCII заглавных букв (https://html.spec.whatwg.org/multipage/infrastructure.html#uppercase-ascii-letters).


Определение


Определение пользовательского элемента включает:


  1. Имя


    В соответствии с действующей спецификацией, имена являются валидными, если они соответствуют следующему виду:

    [a-z] (PCENChar)* '-' (PCENChar)*
    PCENChar ::=
    "-" | "." | [0-9] | "_" | [a-z] | #xB7 | [#xC0-#xD6] | [#xD8-#xF6] | [#xF8-#x37D] | [#x37F-#x1FFF] | [#x200C-#x200D] | [#x203F-#x2040] | [#x2070-#x218F] | [#x2C00-#x2FEF] | [#x3001-#xD7FF] | [#xF900-#xFDCF] | [#xFDF0-#xFFFD] | [#x10000-#xEFFFF]

    , в нотации Extended Backus-Naur Form (EBNF) спецификации XML(https://www.w3.org/TR/xml/#sec-notation).

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

    Имена не могут иметь следующих значений: annotation-xml, color-profile, font-face, font-face-src, font-face-uri, font-face-format, font-face-name, missing-glyph.


  2. Локальное имя


    Для автономного пользовательского элемента это имя из определения (defined name), а для кастомизированного встроенного элемента — значение, переданное в его опцию extends(в то время как имя из определения используется как значение is атрибута)


  3. Конструктор


    Конструктор вызывается когда инстанс создается или апгрейдится, подходит для инициализации состояния, установки наблюдателей или создания shadow dom. Однако есть некоторые ограничения. Так, первым вызовом в теле конструктора должен быть вызов super() без параметров; ключевое слово return не должно фигурировать в теле конструктора, если только это не обычный ранний return (return или return this); не должен вызывать document.write() или document.open(), потомки и атрибуты не должны создаваться на этом этапе, также обращение к ним не должно происходить; тут должна производится только та работа, которая действительно потребуется только единожды, а вся прочая по возможности должна быть вынесена в connectedCallback (см. далее).


  4. Прототип, объект JS


  5. Список observedAttributes


    Список тех атрибутов, изменение которых приведет к вызову метода attributeChangedCallback (см.далее). Определяется статичным геттером, который должен вернуть массив строковых значений.


  6. Коллекция методов жизненного цикла


    Представлены 4 метода, соответствующие жизненному циклу компонента:


    • connectedCallback

      вызывается каждый раз когда элемент внедряется в DOM. Тут уместно запрашивать ресурсы и производить рендеринг. Большинство работы лучше откладывать на этот метод;


    • disconnectedCallback

      вызывается каждый раз при удалении элемента из DOM и используется для освобождения памяти (отмена запросов, отмена интервалов, таймеров и обработчиков и пр.);


    • adoptedCallback

      вызывается когда элемент был перемещен в новый документ, например вызовом document.adoptNode();


    • attributeChangedCallback

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



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


  7. Стек конструирования (construction stack)


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


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


Минимальный синтаксис создания прост:
Создается класс, который расширяет класс HTMLElement. Разметка будущего компонента задается в this.innerHTML внутри connectedCallback.


class AcEl extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<p>I'm an autonomous custom element</p>`;
  }
}

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


customElements.define('ac-el', AcEl);

Пример с добавлением простейшего поведения:


class TimerElement extends HTMLElement {
  connectedCallback() {
   this.render();
   this.interval = setInterval(() => this.render(), 1000);
  }
 
  disconnectedCallback() {
   clearInterval(this.interval); //очистка
  }

  render() {
    this.innerHTML = `
     <div>${new Date().toLocaleString({hour: '2-digit', minute: '2-digit', second: '2-digit' })}</div>
    `;
  }
}

customElements.define('timer-element', TimerElement);

Использование автономных пользовательских элементов возможно как путем указания их в виде тега:



<timer-element></timer-element>

или


const timer = document.createElement('timer-element');

или


const timer = new TimerElement();
document.body.appendChild(timer);

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


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


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


Итак, я планирую создать три пользовательских элемента: элемент навигации, элемент содержимого, и элемент-обертка. Элемент навигации будет принимать атрибут target, его содержимое будет связывать элемент с соответствующим ему элементом навигации и, одновременно, будет выводится как текст навигационного элемента. Реализация:


class TabNavigationItem extends HTMLElement {
 constructor() { 
   super(); //вызов конструктора родителя без передачи параметров
   this._target = null; 
 }

 connectedCallback() {  
   this.render();  //
 }

 static get observedAttributes() {
   return ['target']; 
}                    
// произошла установка списка атрибутов для срабатывания attributeChangedCallback

 attributeChangedCallback(attr, prev, next) {
   if(prev !== next) {
     this[`_${attr}`] = next;
     this.render();
  }
}
//не делать лишней работы когда значения атрибута не изменились

 render() {
   if(!this.ownerDocument.defaultView) return; 
   this.innerHTML = `
      <a href="#${this._target}">${this._target}</a>
     `;
 }
}

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


class TabContentItem extends HTMLElement {
 constructor() {
   super();
   this._target = null;
   this._content = null;
 }

 connectedCallback() {
   this.render();
 }

 static get observedAttributes() {
   return ['target', 'content'];
 }

 attributeChangedCallback(attr, prev, next) {
   if(prev !== next) {
     this[`_${attr}`] = next;
     this.render();
   }
 }

 render() {
   if(!this.ownerDocument.defaultView) return;
   this.innerHTML = `
      <div>${this._content}</div>
     `;
 }
}

Непосредственно элемент обертка будет содержать функциональную логику — он получит все навигационные элементы и навесит обработчиков на событие клик, который свойство _target определит и покажет нам нужную табу.


class TabElement extends HTMLElement {

  connectedCallback() {
    this.listener = this.showTab.bind(this);
    this.init();
  }

  disconnectedCallback(){
    this.navs.forEach(nav => nav.removeEventListener('click', this.listener));
  }

  showTab(e) {
    e.preventDefault();
    e.stopImmediatePropagation();
    const target = e.target.closest('tab-nav-item')._target;
    [...this.tabs, ...this.navs].forEach(el => {
      if (el._target === target) el.classList.add('active');
      else el.classList.remove('active');
    });
  }

  init() {
    this.navs = this.querySelectorAll('tab-nav-item');
    this.tabs = this.querySelectorAll('tab-content-item');
    this.navs.forEach(nav => nav.addEventListener('click', this.listener));
  }
}

* апдейт, исправлено снятие обработчика


Последним, но самым значимым этапом идет объявление элементов:


customElements.define('tab-element', TabElement);
customElements.define('tab-nav-item', TabNavigationItem);
customElements.define('tab-content-item', TabContentItem);

Пример работающих таб можно посмотреть тут


Подробнее: Кастомизированные встроенные элементы


кастомизированные встроенные элементы имеют два отличия от автономных пользовательских элементов: элемент может наследовать встроенные классы элементов HTML, и при объявлении такого элемента третий аргумент метода .define() становится обязательным.
Рассмотрим такой пример:


class JumpingButton extends HTMLButtonElement {
  constructor() {
    super();

    this.addEventListener("hover", () => {
      // animate here
    });
  }
}

customElements.define('jumping-button', JumpingButton, { extends: ‘button’ });

Такой элемент унаследует семантику HTMLButtonElement и будет иметь возможность расширить ее.
При использовании элемента будет указан тег встроенного HTML элемента, в нашем примере — button с атрибутом is, которому будет передано имя из определения пользовательского элемента, таким образом:


<button is="jumping-button">Click Me!</button>

а его создание методами js будет выглядеть так:


const jb = document.createElement("button", { is: "jumping-button" });

Желательно не забывать, что .localName такого элемента будет “button” в отличие от автономного пользовательского элемента, для которых .localName равен имени из определения.


На сегодняшний день ни один браузер не реализовал customized built-in elements, потому рассматривать примеры пока приходится теоретически.


Вместо заключения или два слова про апгрейд


Поскольку добавление определения пользовательского элемента в CustomElementRegistry (с нашей стороны это зависит от вызова метода define()) может произойти в любой момент, обычный (не пользовательский) элемент может быть создан, после чего он позднее может стать пользовательским элементом после регистрации соответствующего определения (вызова .define()). Алгоритм апгрейда предусматривает ход событий при котором может быть предпочтительна регистрация определения пользовательского элемента после того как соответствующий элемент был изначально создан. Это позволяет реализовывать progressive enhancement контента пользовательских элементов. Агрейды, при этом, доступны только для элементов в DOM дереве (т.е. для теневого DOM, shadowRoot должен быть в документе ).



Следующая статья: Веб компоненты. Часть 2: теневой DOM


Прошу не судить строго. С уважением Tania_N

  • +16
  • 11,2k
  • 9
Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 9
  • +3
    > Понаблюдав за работой таймера в инструментах разработчика можно заметить что страница не перегружается, изменения в DOM вносятся точечно. Очень напоминает реакт

    А почему страница вообще должна перегружаться? Вы когда просто с DOM работаете у вас она это разве делает? Причём тут реакт вообще)) Такое ощущение что это buzz-word который нужно присунуть в любую статью и без него статья хорошей быть по определению не может.

    По поводу custom elements. Всегда удивляла способность ребят из стандарта портить хорошие идеи отвратными api. Имхо
    • 0
      Подскажите, а будет ли работать удаление обработчика события клика с элемента при такой реализации удаления?
      disconnectedCallback(){
        this.navs.forEach(nav => nav.removeEventListener('click', (e) => this.showTab(e)));
      }
      
      init() {
        this.navs = this.querySelectorAll('tab-nav-item');
        this.tabs = this.querySelectorAll('tab-content-item');
        this.navs.forEach(nav => nav.addEventListener('click', (e) => this.showTab(e)));
      }
      • +2
        Нет не будет. Вы должны передать «ту самую» функцию в removeEventListener, иначе она просто не будет найдена.
        • 0
          Не будет
          • 0
            Спасибо, поправила.
          • +1
            Ни слова про поддержку браузерами. Где это уже можно использовать?
          • 0
            Существуют полифиллы.

            Можно подробнее описать, как работают полифиллы? Ведь здесь идёт по сути расширение HTML как часть технологии (вторая часть — API для JS)
            • +1
              Честно говоря, я еще не разбиралась с их работой, просто знаю об их существовании. Если Вам интересно, можно почитать вот тут и вот тут.

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

            Самое читаемое