Сила и мощь веб-компонентов

Обложка поста

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

Для использования стороннего кода требуются шаблонные вставки на JavaScript или решение CSS-конфликтов с участием ужасного !important. В мире React и других современных фреймворков дела обстоят чуть лучше, но задействовать целый фреймворк только для того, чтобы использовать готовый виджет, будет излишним.

HTML5 добавил несколько новых элементов типа <video> и <input type="date">, которые позволили внедрить в веб-платформу некоторые востребованные виджеты общего назначения. Тем не менее, добавление в стандарт нового элемента для каждого достаточно распространённого UI-паттерна не выглядит подходящим вариантом.

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

Развитие стандартов

Упомянутый набор стандартов известен как веб-компоненты. Ранние версии стандартов в той или иной форме доступны в Chrome ещё с 2014, а полифиллы неуклюже заполняют пробелы в других браузерах.

Не так давно стандарты были усовершенствованы. Первоначальная версия теперь называется нулевой — v0, а для более зрелой первой версии виднеется поддержка во всех основных браузерах. В Firefox 63 добавлена поддержка двух стандартов из требуемых — Custom Elements и Shadow DOM — так что пора узнать, как можно стать HTML-изобретателем!

Разговоры о веб-компонентах идут давно, и существует множество доступных материалов по ним. Статью следует рассматривать как вводную по новым возможностям и ресурсам. Если вы хотите окунуться в тему глубже (а это определённо следует сделать), то можно почитать о веб-компонентах на MDN Web Docs и Google Developers.

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

Новый взгляд на элемент <template>

Первый элемент не так нов, как остальные, поскольку потребность в нём возникла раньше идеи веб-компонентов. Иногда нужно просто где-нибудь хранить готовый HTML: может, это некоторая разметка, которую вы повторяете в нескольких местах, или же пока не законченный UI. Элемент <template> содержит HTML-код, парсинг которого происходит без вставок в текущий документ.

<template>

    <h1>Это не отобразится!</h1>

    <script>alert("этот алерт не сработает!");</script>

</template>

Но куда вставляется разобранный HTML, если не в документ? Он добавляется в DocumentFragment — легковесную обёртку для хранения фрагмента HTML-документа, которая «растворяется» при вставке в DOM. Эти обёртки полезны для хранения набора элементов, требуемых позже, и выступают в роли контейнера, которым не нужно управлять вручную.

Теперь у нас есть DOM в неком «исчезающем» контейнере, а как им пользоваться?

Оказывается, можно просто вставить фрагмент в текущий документ:

let template = document.querySelector('template');
document.body.appendChild(template.content);

Этот код работает прекрасно за тем исключением, что вы только что удалили контейнер. Если вы запустите код ещё раз, то получите ошибку, так как template.content исчез. Вместо этого сделайте копию фрагмента перед вставкой:

document.body.appendChild(template.content.cloneNode(true));

Название метода cloneNode говорит само за себя, но обратите внимание, что он принимает флаг для указания необходимости глубокого копирования.

Тег template подходит в любой ситуации, когда требуется повторять некоторую HTML-структуру. Он особенно полезен для задания внутренней структуры компонента, и потому <template> входит в «клуб» веб-компонентов.

Новые возможности:

  • Элемент, который хранит HTML-код, но не добавляет его в текущий документ.

Обзорные материалы:

Пользовательские элементы (Custom Elements)

Пользовательские элементы — флагман технологии веб-компонентов. Название говорящее — они позволяют разработчикам создать свои собственные HTML-элементы. Синтаксис версии v0 был громоздким, но сейчас пользовательские элементы в основном опираются на классы ES6. Если вы с ними знакомы, то знаете, как описать новый класс на базе существующего (наследование):

class MyClass extends BaseClass {
    // определение класса находится здесь
}

А что, если мы сделаем так?

class MyElement extends HTMLElement {}

До недавнего времени это считалось ошибкой. Браузеры запрещали наследовать как встроенный класс HTMLElement, так и любой из его потомков. Custom Elements снимают данное ограничение.

Браузер знает, что тегу <p> соответствует класс HTMLParagraphElement, но как он поймет, какой тег сопоставлен пользовательскому элементу? В дополнение к возможности наследования встроенных классов для установки такого соответствия доступно Registry пользовательских элементов:

customElements.define('my-element', MyElement);

Для страницы теперь каждый тег <my-element> ассоциирован с новым экземпляром класса MyElement. То есть конструктор класса MyElement будет вызван всякий раз, когда браузер встретит тег <my-element>.

Но зачем в имени тега нужен дефис? Органы стандартизации хотят сохранить себе возможность вводить новые HTML-элементы, так что разработчики не могут просто так начать определять свои теги <h7> или <vr>. Во избежание в будущем конфликтов все пользовательские элементы должны содержать дефис, а органы стандартизации обещают никогда не вводить новые HTML-теги с этим символом. Проблема решена!

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

  • connectedCallback вызывается при добавлении элемента в документ (в общем случае несколько раз, так как элемент может быть перемещён или удалён и заново добавлен);
  • disconnectedCallback — в противоположность connectedCallback;
  • attributeChangeCallback срабатывает при модификации атрибутов из специального списка.

Немного более выразительный пример:

class GreetingElement extends HTMLElement {
  constructor() {
    super();
    this._name = 'Stranger';
  }
  connectedCallback() {
    this.addEventListener('click', e => alert(`Hello, ${this._name}!`));
  }
  attributeChangedCallback(attrName, oldValue, newValue) {
    if (attrName === 'name') {
      if (newValue) {
        this._name = newValue;
      } else {
        this._name = 'Stranger';
      }
    }
  }
}
GreetingElement.observedAttributes = ['name'];
customElements.define('hey-there', GreetingElement);

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

<hey-there>Greeting</hey-there>

<hey-there name="Potch">Personalized Greeting</hey-there>

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

class GreetingElement extends HTMLButtonElement

Также необходимо указать в Registry, что мы расширяем существующий тег:

customElements.define('hey-there', GreetingElement, { extends: 'button' });

Так как мы расширяем существующий тег, то используем его имя вместо пользовательского. Далее устанавливаем специальный атрибут is, чтобы браузер знал, какой вид кнопки требуется:

<button is="hey-there" name="World">Howdy</button>

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

С этого момента уже можно применять традиционные техники веб-виджетов. Можно установить ряд обработчиков событий, стилизовать элемент и даже повторять его внутреннюю структуру через <template>. Люди могут комбинировать ваш пользовательский элемент с их собственным кодом: через HTML-шаблонизацию, работу с DOM и даже в новомодных фреймворках, некоторые из которых поддерживают пользовательские теги в своих реализациях виртуального DOM. Пользовательские элементы используют стандартные интерфейсы, поэтому они позволяют реализовать действительно переносимые виджеты.

Новые возможности:

  • Наследование встроенного класса HTMLElement и его подклассов.
  • Registry пользовательских элементов и customElements.define() для работы с ним.
  • Специальные методы жизненного цикла для реагирования на создание элемента, вставку в DOM, изменений атрибутов и так далее.

Обзорные материалы:

Теневая модель документа (Shadow DOM)

Мы сделали свой «дружелюбный» элемент и даже накинули немного стилизации. Теперь мы хотим использовать его на всех своих сайтах и делиться кодом с другими. Как предотвратить кошмар конфликтов, когда наш кастомизированный элемент <button> столкнётся лицом к лицу с CSS других сайтов? Решение предоставляет Shadow DOM (теневой DOM).

Стандарт теневого DOM вводит концепцию теневого корня. Говоря поверхностно, теневой корень поддерживает стандартные DOM-методы и может быть добавлен так же, как и любой другой DOM-узел. Теневой корень отличается тем, что его родительский узел не видит содержимое:

// attachShadow создаёт теневой корень.
let shadow = div.attachShadow({ mode: 'open' });
let inner = document.createElement('b');
inner.appendChild(document.createTextNode('Hiding in the shadows'));
// теневой корень поддерживает стандартный метод appendChild
shadow.appendChild(inner);
div.querySelector('b'); // пусто

В примере выше <div> содержит <b>, и <b> отображается на странице, но он невидим для традиционных DOM-методов. Более того, он также невидим и для стилей страницы. Это означает, что внешние по отношению к теневому корню стили не могут «проникнуть» внутрь, а его внутренние стили не могут «просочиться» во внешний мир. Данные ограничения не предназначены для контроля доступа к теневому корню. Другой скрипт на странице может отловить его создание и через ссылку получить прямой доступ к содержимому.

Содержимое теневого корня стилизуется через добавление к нему <style> (или <link>):

let style = document.createElement('style');
style.innerText = 'b { font-weight: bolder; color: red; }';
shadowRoot.appendChild(style);
let inner = document.createElement('b');
inner.innerHTML = "I'm bolder in the shadows";
shadowRoot.appendChild(inner);

Ух, а прямо сейчас мы действительно можем использовать <template>! В любом случае, элемент <b> будет изменяем со стороны стилей внутри корня, а для всех внешних стилей — нет.

Что, если пользовательский элемент имеет нетеневой контент? Мы можем заставить их работать вместе, используя новый специальный элемент <slot>:

<template>

Hello, <slot></slot>!

</template>

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

<hey-there>World</hey-there>

Будет отображена так:

Hello, World!

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

Новые возможности:

  • Как бы «невидимая» DOM-структура, которая называется теневым корнем.
  • DOM APIs для создания теневых корней и доступа к ним.
  • Ограничение области видимости внутренних стилей теневого корня.
  • Новые псевдоклассы CSS для работы с теневыми корнями и их стилями.
  • Элемент <slot>

Собираем всё вместе

Давайте сделаем модную кнопку! Будем креативны и назовём элемент <fancy-button>. Что именно сделает кнопку модной? У неё будут кастомные стили, которые позволят нам установить иконку и настроить внешний вид. Хотелось бы, чтобы вид кнопки сохранялся вне зависимости от сайта, на котором она используется, так что мы собираемся инкапсулировать стили в теневом корне.

Вы можете увидеть законченный пользовательский элемент в интерактивном примере. Обязательно посмотрите как на JS-код, так и на <template> в HTML, который нужен для определения стиля и структуры элемента.

Заключение

Стандарты веб-компонентов основаны на той идее, что имея множество низкоуровневых средств, люди будут комбинировать их способами, которые никто не предвидел во время написания спецификаций. Пользовательские элементы уже были использованы для упрощения создания VR-контента в вебе, породили несколько UI-тулкитов и многое другое. Несмотря на длительный процесс стандартизации, обещанные возможности веб-компонентов придают разработчикам больше сил. Теперь, когда технология доступна в браузерах, будущее веб-компонентов в ваших руках. Что вы построите?

Перевод статьи «The Power of Web Components»

Борис Рязанцев

Как Яндекс использует ваши данные и машинное обучение для персонализации сервисов — читать и смотреть YaC 2019.