Pull to refresh

Веб-компоненты: обзор и использование в продакшне

Website development *JavaScript *HTML *
Sandbox
Tutorial

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


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



Список материалов для детального изучения — в конце статьи.


Содержание:



Вступление


Я работаю фронтэнд-разработчиком сервисов в одной из крупных международных кампаний и в настоящий момент второй раз переписываю фронтэнд проекта.


В первой версии, написанной согласно канонам 1C-Bitrix, меня ожидало мясо из скриптов и стилей во множестве шаблонов. Скрипты, конечно, были на jQuery, а стили, разумеется, были абсолютно хаотичны, без какой-либо структуры или порядка. В связи с переездом на новую платформу выпал шанс полностью переписать проект. Для наведения порядка была разработана своя компонентная система с использованием БЭМ-методологии. На каждый экземпляр БЭМ-блока в разметке после загрузки страницы создаётся один объект соответствующего класса, который начинает управлять логикой. Таким образом, всё довольно строго систематизировано — блоки (логика и стили) реиспользуемы и изолированы друг от друга.


По прошествии года поддержки и доработки проекта обнаружился ряд недостатков такой системы. Разметка, на основе которой работают мои псевдокомпоненты, держится на внимательности и «честном слове» разработчика: JS надеется, что верстальщик правильно расставил все нужные элементы и прописал к ним классы. Если компонент модифицирует свой DOM, который содержит другие БЭМ-блоки, либо же разметка подгружается через ajax, компоненты этой разметки приходится инициализировать вручную. Это всё казалось простым при ежедневной работе до тех пор, пока на проекте не появился второй человек. Документация, к сожалению, хоть и была довольно объёмной, но охватывала только базовые принципы (объём в данном случае стал минусом). В жизни же шаг влево или шаг вправо ломал установленные «принципы», а чтение готовых компонентов только вносили сумбур, так как они были написаны по разному и в разное время.


Всё это, а также потенциальное увеличение количества проблем в разработке при запланированном переходе к SPA/PWA подтолкнули к очередной переработке фронта. Велосипеды, которым, в том числе, является моя компонентная система, очень полезны во время обучения чему-либо (в моём случае — JS), но в качественном проекте с несколькими разработчиками необходимо что-то более надёжное и структурированное. В настоящее время (впрочем, уже давно) в вебе мы имеем массу фреймворков, среди которых есть, что выбрать: Preact предлагает маленький размер и максимальное сходство в разработке с уже знакомым мне React-ом, Angular манит встроенным прелестным TypeScript-ом, Vue выглядывает из-за угла и хвастается своей простотой, и много другого. Особняком держится стандарт: оказывается, можно писать реиспользуемые веб-компоненты, с зашитой внутри логикой и стилями, которые не надо будет искусственно (за счёт БЭМ и дополнительного JS-а) связывать с уже написанной разметкой. И всё это должно работать из коробки. Чудо, не правда ли?


Из-за моей дикой любви к стандартам и вере в светлое будущее, а также схожести имеющейся системы компонентов с веб-компонентами (один JS-класс на один «компонент» с изолированной логикой), решено было попробовать использовать именно веб-компоненты. Больше всего напрягала поддержка этого дела в браузерах: веб-компоненты слишком молоды, чтобы охватывать сколько-нибудь широкий круг браузеров, а уж тем более охватывать браузеры, которые необходимо поддерживать коммерческому продукту (например, Android 4.4 stock browser и Internet Explorer 11). Мысленно был принят некоторый уровень боли и ограничений, который могли меня ожидать, и рамок, в которые я согласен вписываться при разработке, и я погрузился в изучение теории и практические эксперименты: как писать фронт на веб-компонентах и выкатывать это в продакшн так, чтобы оно работало.


Необходимый теоретический минимум для чтения статьи: чистый JavaScript на уровне базовых манипуляций с DOM-деревом, понимание синтаксиса классов в ES2015, плюсом будет знакомство с каким-либо из фреймворков из разряда React.js/Angular/Vue.js.


Теория


Обзор


Веб-компоненты — это совокупность стандартов, которые позволяют делать декларативно описываемые, реиспользуемые «виджеты» с изолированными стилями и скриптами в виде собственных тегов. Стандарты развиваются независимо, и связываются в веб-компоненты довольно условно — в принципе, можно использовать каждую из используемых технологий отдельно. Но именно вместе они максимально эффективны.


Обычно все четыре стандарта — пользовательские элементы (Custom Elements), теневой DOM (Shadow DOM), шаблоны (HTML Templates) и HTML-импорты (HTML Imports) — рассматриваются отдельно, а уже потом соединяются вместе. Так как по отдельности они слабо полезны, мы рассмотрим все возможности стандартов кумулятивно, прибавляя их к уже изученному ранее.


Стоит напомнить, что веб-компоненты — довольно молодая технология, и стандарты претерпевали множество изменений. Для нас в основном это выражается в нескольких версиях стандартов Custom Elements и Shadow DOM — v0 и v1. v0 в настоящий момент не актуальны. Мы будем рассматривать только v1. Будьте внимательны при поиске дополнительных материалов! Версии v1 сформировались только в 2016 году, а значит, все статьи и видео до 2016 года гарантированно говорят именно о старой версии спецификации.


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


Пользовательские элементы — это возможность создавать новые HTML-теги с произвольными именами и поведением, например, <youtube-player src=""></youtube-player> или <yandex-map lat="34.86974" lon="-111.76099" zoom="7"></yandex-map>.


Регистрация пользовательского элемента


Конечно, мы и так можем «создать» собственный теги (например, браузеры вполне корректно обработают тег <noname></noname>), но при этом в DOM-дереве элемент регистрируется как объект класса HTMLUnknownElement и не имеет никакого поведения по умолчанию. «Оживлять» каждый такой элемент придётся вручную.


Спецификация пользовательских элементов позволяет регистрировать новые теги и задавать их поведение в соответствии с жизненным циклом — создание, вставка в DOM, изменение атрибутов, удаление из DOM. Для того чтобы предотвратить возможный конфликт новых тегов стандарта HTML и пользовательских тегов, имена последних обязаны содержать как минимум один дефис — например, <custom-tag></custom-tag> или <my-awesome-tag></my-awesome-tag>. Также пользовательские теги на текущий момент не могут быть самозакрывающимися, даже теги без содержимого должны быть парными.


Так как лучший способ обучения это практика, напишем элемент, схожий по функционалу с элементом <summary>. Назовём его <x-spoiler>.


При добавлении такого элемента в DOM-дерево он также станет объектом класса HTMLUnknownElement. Для того, чтобы зарегистрировать элемент, как пользовательский, и добавить ему своё поведение, нужно воспользоваться методом define глобального объекта customElements. Первым аргументом передаётся имя тега, вторым — класс, описывающий поведение. Класс при этом должен расширять класс HTMLElement, чтобы наш элемент обладал всеми качествами и возможностями других HTML элементов. Итого:


class XSpoiler extends HTMLElement {}

customElements.define("x-spoiler", XSpoiler);

После этого браузер пересоздаст все имеющиеся в разметке теги x-spoiler как объекты класса XSpoiler, а не HTMLUnknownElement. Все новые теги x-spoiler, которые добавляются в документ через innerHTML, insertAdjacentHTML, append или другие методы для работы с HTML, сразу создаются на основе класса XSpoiler. Также такие DOM-элементы можно создавать и через document.createElement.


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


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


Теперь пользовательский элемент, конечно, зарегистрирован, но ничего полезного он не делает. Чтобы действительно оживить наш пользовательский элемент, рассмотрим его жизненный цикл.


Жизненный цикл пользовательского элемента


Мы можем добавить методы-коллбэки на создание элемента, добавление его в DOM, на изменение атрибутов, на удаление элемента из DOM и на изменение родительского документа. Мы воспользуемся этим, чтобы реализовать логику работы спойлера: компонент будет содержать кнопку с текстом «Свернуть»/«Развернуть» и секцию с изначальным содержимым тега. Видимость секции будет управляться кликом по кнопке или значением атрибута. Текст кнопок также можно будет настроить через атрибуты.


Коллбэком на создание элемента является конструктор класса. Чтобы он корректно отработал, сперва необходимо вызвать родительский конструктор через super. В конструкторе можно задать разметку, навесить обработчики событий, сделать какую-то другую подготовительную работу. В конструкторе, как и в других методах, this будет ссылаться на сам DOM-элемент, а благодаря тому, что наш пользовательский элемент расширяет HTMLElement, у this есть такие методы как querySelector и такие свойства как classList.


Добавим в конструкторе значения для текстов кнопки, разметку компонента и навесим обработчик на клик по кнопке, который будет менять наличие атрибута opened.


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

    this.text = {
      "when-close": "Развернуть",
      "when-open": "Свернуть",
    }

    this.innerHTML = `
      <button type="button">${this.text["when-close"]}</button>
      <section style="display: none;">${this.innerHTML}</section>
    `;

    this.querySelector("button").addEventListener("click", () => {
      const opened = (this.getAttribute("opened") !== null);      

      if (opened) {
        this.removeAttribute("opened");
      } else {
        this.setAttribute("opened", "");
      }
    });
  }
}

Подробно разберём каждую часть конструктора.


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


this.text — так как this это объект, мы можем добавить и свои свойства. В данном случае я буду хранить в объекте text вспомогательные тексты, которые выводятся на кнопке.


this.innerHTML установит разметку нашего DOM-элемента. При этом мы используем текст, заданный чуть выше.


this.querySelector("button").addEventListener добавит обработчик события click по кнопке, который будет устанавливать или снимать атрибут opened. Мы будем работать с ним как с логическим значением — спойлер либо открыт, либо закрыт, следовательно, атрибут либо есть, либо нет. В обработчике мы будем проверять наличие атрибута через сравнение с null, а затем либо устанавливать, либо удалять атрибут.


Теперь при клике по созданной кнопке будет меняться атрибут opened. Пока что изменение атрибута ничего не даёт. Прежде чем перейти к этой теме, немного модифицируем код.


Помните, что у тега <button> есть атрибут disabled, который отключает кнопку? Если мы пропишем его в разметке, кнопка перестанет быть активной, если удалим — вновь станет кликабельной. Работать с атрибутом мы можем и из JavaScript-кода, используя методы getAttribute, setAttribute и removeAttribute. Но это не очень удобно, нам нужно целых три метода для работы с атрибутами, они длинные, и, к тому же, работают только со строками (значение атрибута — всегда строка). Поэтому в DOM-элементах зачастую используется «отражение» атрибутов в одноимённые свойства. Так, свойство button.disabled вернёт наличие или отсутствие атрибута. А теперь сравнените два подхода, через прямую работу с атрибутами и через свойства:


// Получение текущего значения:
// атрибуты:
const isDisabled = button.getAttribute("disabled") !== null;
// свойства:
const isDisabled = button.disabled;

// Установка блокировки:
// атрибуты:
button.setAttribute("disabled", "");
// свойства:
button.disabled = true;

// Снятие блокировки:
// атрибуты:
button.removeAttribute("disabled");
// свойства:
button.disabled = false;

// Изменение значения на противоположное:
// атрибуты:
if (button.getAttribute("disabled") !== null) {
  button.removeAttribute("disabled");
} else {
  button.setAttribute("disabled", "");
}
// свойства:
button.disabled = !button.disabled;

Согласитесь, работа через свойства гораздо удобнее? Реализуем такой же механизм и с нашим атрибутом opened, чтобы его значение можно было легко получить и установить. Для этого используем возможность геттеров и сеттеров свойств в классах:


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

    this.text = {
      "when-close": "Развернуть",
      "when-open": "Свернуть",
    }

    this.innerHTML = `
      <button type="button">${this.text["when-close"]}</button>
      <section style="display: none;">${this.innerHTML}</section>
    `;

    this.querySelector("button").addEventListener("click", () => {
      this.opened = !this.opened;
    });
  }

  get opened() {
    return (this.getAttribute("opened") !== null);
  }

  set opened(state) {
    if (!!state) {
      this.setAttribute("opened", "");
    } else {
      this.removeAttribute("opened");
    }
  }
}

Для строковых свойств (как для встроенных id у любых элементов и href у ссылок) геттер и сеттер будут выглядеть немного проще, но идея сохраняется.


Можно ещё добавить, что такое «отражение» может быть не всегда полезным с точки зрения производительности. Например, у элементов формы атрибут value работает иначе.


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


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


Метод применяет три параметра: имя атрибута, старое значение, новое значение. Так как вызывать этот метод на изменение абсолютно ВСЕХ атрибутов было бы нерационально с точки зрения производительности, срабатывает он только при изменении свойств, которые перечислены в статическом массиве observedAttributes текущего класса.


Наш компонент должен реагировать на изменение трёх атрибутов — opened, text-when-open и text-when-close. Первый будет влиять на отображение спойлера, а два других будут управлять текстом кнопки. Первым делом добавим к нашему классу имена этих атрибутов в статический массив observedAttributes:


static get observedAttributes() {
  return [
    "opened",
    "text-when-open",
    "text-when-close",
  ]
}

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


attributeChangedCallback(attrName, oldVal, newVal) {
  switch (attrName) {
    case "opened":
      const opened = newVal !== null;
      const button = this.querySelector("button");
      const content = this.querySelector("section");
      const display = opened ? "block" : "none";
      const text = this.text[opened ? "when-open" : "when-close"];
      content.style.display = display;
      button.textContent = text;
      break;

    case "text-when-open":        
      this.text["when-open"] = newVal;
      if (this.opened) {
        this.querySelector("button").textContent = newVal;
      }
      break;

    case "text-when-close":
      this.text["when-close"] = newVal;
      if (!this.opened) {
        this.querySelector("button").textContent = newVal;
      }
      break;
  }
}

Обратите внимание, что метод attributeChangedCallback сработает даже тогда, когда требуемые атрибуты изначально присутствуют у элемента. То есть, если наш компонент будет вставлен в разметку сразу с атрибутом opened, спойлер действительно будет открыт, т.к. attributeChangedCallback сработает сразу после constructor. Поэтому никакой дополнительной работы по обработке начального значения атрибутов в конструкторе производить не нужно (если, конечно, атрибут — отслеживаемый).


Теперь наш компонент действительно работает! При клике по кнопке меняется значение атрибута opened, после этого срабатывает коллбэк attributeChangedCallback, который в свою очередь управляет видимостью содержимого. Управление состоянием именно через атрибуты и attributeChangedCallback позволяет управлять изначальным состоянием (мы можем добавить opened в разметку сразу, если хотим показать открытый спойлер) или управлять состоянием снаружи (любой другой JS-код может установить или снять атрибут с нашего элемента и это будет корректно обработано). В качестве бонуса мы можем настроить текст управляющей кнопки. Демо результата, смотреть в свежем Chrome!


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


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


Точно так же, как можно отследить вставку элемента в DOM, можно отследить и удаление. За это отвечает метод disconnectedCallback. Он срабатывает, например, при удалении элемента из DOM методом remove(). Обратите внимание: если элемент удалён из DOM-дерева, но у вас есть ссылка на элемент, он опять может быть вставлен в DOM, и при этом повторно сработает connectedCallback. При удалении можно, например, прекратить обновлять данные в компоненте, удалить таймеры, удалить назначенные в connectedCallback обработчики событий или закрыть соединение с сервером. Обратите внимание, что disconnectedCallback не гарантирует выполнение своего кода — например, при закрытии пользователем страницы метод вызван не будет.


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


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


Управляется компонент, как мы уже поняли, значением атрибутов напрямую или через свойства. А вот для того чтобы передать данные из компонента наружу, можно использовать CustomEvents. В случае нашего компонента будет рационально добавить событие изменения состояния, чтобы его можно было прослушать и отреагировать. Для этого добавим в конструкторе свойство events с двумя объектами CustomEvent:


this.events = {
  "close": new CustomEvent("x-spoiler.changed", {
    bubbles: true,
    detail: {opened: false},
  }),
  "open": new CustomEvent("x-spoiler.changed", {
    bubbles: true,
    detail: {opened: true},
  }),
};

Также отредактируем attributeChangedCallback, чтобы при изменении opened отправлялось то или иное событие:


this.dispatchEvent(this.events[opened ? "open" : "close"]);

Теперь мы можем прослушивать интересующие нас события, чтобы узнавать об изменении состояния спойлера.


Новое демо.


Ещё немного о customElements


При регистрации элемента мы использовали метод define глобального объекта customElements. Помимо этого у него есть ещё два полезных метода.


customElements.get(name) вернёт конструктор пользовательского элемента, зарегистрированного под именем name, если такой есть, либо undefined.


customElements.whenDefined(name) вернёт промис, который будет выполнен успешно тогда, когда элемент с именем name будет зарегистрирован, или незамедлительно, если элемент уже зарегистрирован. Особенно удобно это с использванием await, но это уже другая тема.


Расширение пользовательских элементов


Мы можем наследоваться от класса пользовательского элемента, чтобы создавать новые пользовательские элементы на основе существующих. Например, мы можем расширить наш спойлер и добавить ему при необходимости какой-либо функционал. При этом важно не забыть вызвать аналогичный метод родительского класса через super.methodName(), если это необходимо (в случае конструктора и super() это обязательно).


Расширение стандартных элементов


Спецификация разрешает расширять и стандартные HTML-теги. Например, вам нужна своя реализация кнопки, но при этом вам желательно сохранить уже имеющийся функционал браузерных кнопок, например, правильную работу с атрибутами disabled, tabindex, type и прочими. Однако, тут есть ряд особенностей.


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


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


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


Выглядеть это будет так:


class FancyButton extends HTMLButtonElement { }

customElements.define("fancy-button", FancyButton, {extends: "button"});

// создание элемента через document.createElement
let button = document.createElement("button", {is: "fancy-button"});

<!-- вставка элемента в HTML -->
<button is="fancy-button" disabled>Fancy button!</button>

Стилизация элементов до регистрации


Между тем, как пользователь получит HTML-разметку, и тем, как будет скачан и выполнен JavaScript-код, пройдёт некоторое время. Чтобы как-то стилизовать пользовательские элементы, которые отрисованы в DOM, но ещё не зарегистрированы и не работают должным образом, можно использовать псевдокласс :defined. Самый простой пример использования — скрыть все незарегистрированные пользовательские элементы:


*:not(:defined) {
  display: none;
}

Итого


Мы сделали реиспользуемый веб-компонент на основе технологии пользовательских элементов. Однако, у него есть много минусов. Так, например, у нас нет изоляции стилей: правило section {display: block !important} легко сломает логику работы нашего компонента. Да и вообще навешивать стили непосредственно в JS — плохой тон. Также сложно поменять содержимое спойлера: наши кнопка, секция и обработчик клика пропадут при установке нового содержимого через innerHTML. Для того чтобы действительно поменять содержимое, нужно будет знать и учитывать структуру компонента. А ещё и разметка у нас хранится прямо в конструкторе. Всё это явно не то, что мы хотим от простого реиспользуемого компонента. Для того, чтобы исправить все эти минусы, мы будем использовать другие спецификации.


Теневой DOM (Shadow DOM)


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


Инкапсуляция DOM


Стандартная модель DOM, к которой мы привыкли, предполагает, что все потомки элемента доступны через childNodes, их можно найти через querySelector(), и так далее. DOM — сквозной, где бы не находился параграф текста, он всегда будет найден через document.querySelectorAll("p"). Однако, есть возможность отображать не то, что находится в DOM-дереве, а какую-либо другую разметку, причём так, чтобы она была игнорировалась привычными childNode и querySelector. Самым простым примером такого поведения будет тег <video> с несколькими <source> внутри. Мы добавляем в DOM только <source>, а видим полноценный видеопроигрыватель, с собственной изолированной разметкой (блоками, кнопками и прочим). Всё, что мы видим на экране, как раз располагается в теневом DOM. Как это работает?


В любой элемент времени мы можем добавить теневой DOM с помощью метода attachShadow(). В этот момент вместо обычного DOM-дерева у элемента появляется сразу три других: Shadow DOM, Light DOM и Flattened DOM. Рассмотрим их по отдельности.


Light DOM — это то, что раньше являлось обычным DOM-деревом элемента: все элементы, которые доступны через обычные innerHTML, childNodes или по которым ищет метод querySelectorAll.


Shadow DOM — это DOM-дерево, которое лежит в свойстве shadowRoot элемента. Сразу после вызова attachShadow свойство shadowRoot пустое, но мы можем задать какую-либо разметку через стандартные innerHTML, appendChild или другие способы работы с DOM, вызывая их относительно this.shadowRoot.


Flattened DOM — это результат объединения Shadow DOM и Light DOM. Это то, что пользователь в действительности видит на экране. Это искусственное понятие, необходимое только для понимания механизма работы Shadow DOM. Если получить разметку Light DOM можно через element.innerHTML, разметку Shadow DOM можно через element.shadowRoot.innerHTML, то на Flattened DOM можно только посмотреть в окне браузера. Flattened DOM строится на основе Shadow DOM и в самом простом варианте использования строго ему равен. Таким образом, Light DOM может вообще не отображаться на экране. Например:


<x-demo>Привет!</x-demo>

class Demo extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: "open"});
    this.shadowRoot.innerHTML = "Привет из тени...";
  }
}

customElements.define("x-demo", Demo);

До момента регистрации элемента пользователь будет видеть обычный DOM, т.е. в нашем случае текст «Привет!». Но как только элемент будет зарегистрирован, текст пропадёт, вместо него появится фраза «Привет из тени...».


Обратите внимание: при добавлении Shadow DOM нужно в объекте опций указать режим, в котором этот Shadow DOM будет создан. Для пользовательских элементов рекомендуется использовать open. Это позволит при необходимости взаимодействовать с Shadow DOM через свойство shadowRoot.


Пока не очень полезно. Гораздо больше возможностей нам дают тег <slot> и его атрибут name в Shadow DOM и атрибут slot в Light DOM. Они объяснят, где именно во Flattened DOM нужно отображать содержимое Lignt DOM. Работает это так: элементы (их может быть как 0, так и 1 и более) с атрибутом slot и каким-либо значением из Light DOM отображаются в качестве содержимого тега <slot> с соответствующим значением атрибута name в Shadow DOM. Если для какого-либо тега <slot> не нашлось подходящего содержимого, отображается его содержимое из Shadow DOM. Если есть тег <slot> без атрибута name, в нём отображается всё содержимое Light DOM без атрибута slot.


Таким образом, самый простой вариант комбинации Shadow DOM и Light DOM это когда Shadow DOM содержит только один тег <slot> без атрибутов, тогда всё содержимое Light DOM будет отображаться как содержимое тега <slot>.


Этот вариант хорошо подходит для нашего компонента спойлера: попробуем убрать всю «обвязку» (секцию и кнопку) в Shadow DOM, и отобразим содержимое Light DOM с помощью тега <slot>. Для этого исправим конструктор:


this.attachShadow({mode: "open"});
this.shadowRoot.innerHTML = `
  <button type="button">${this.text["when-close"]}</button>
  <section style="display: none;"><slot></slot></section>
`;

Так как кнопка для открытия теперь находится в Shadow DOM, необходимо также заменить this.querySelector("button") на this.shadowRoot.querySelector("button"). Также нужно поступить и с поиском тега section.


Теперь мы можем безопасно менять текст спойлера, например, через textContent пользовательского элемента. Наша разметка инкапсулирована и никак не мешает работать с содержимым элемента. Разработчику не нужно знать, как устроен элемент изнутри, чтобы работать с ним. Демо.


Рассмотрим пример посложнее: это будет искусственный пример, который будет раскидывать по четырём цветным «коробкам» разных цветов DOM-элементы из Light DOM. Можете попробовать поменять содержимое тега, чтобы посмотреть, как оно работает: демо.


В этом примере видно, что если в Light DOM находится несколько элементов с одинаковым значением атрибута slot, все они попадут в <slot> с аналогичным name (красная секция). Если у элемента в Light DOM не указан slot, он попадёт в Shadow DOM в тег <slot>, который не имеет атрибута name (белая секция). Ну и если у нас вообще нет элементов с атрибутом slot с каким-либо значением, соответствующий тег <slot> останется со своей собственной разметкой (желтая секция).


Инкапсуляция стилей


С изоляцией DOM и соотношением Light DOM / Shadow DOM вроде разобрались, теперь поговорим об изоляции стилей.


В последнем примере я уже начал использовать изоляцию стилей. Если в Shadow DOM находится тег <style>, правила из него распространяются только на содержимое Shadow DOM. Таким образом, правило


section {
  height: 50%;
  width: 50%;
}

Будет работать только для тех тегов section, которые лежат в Shadow DOM. Это справедливо и в обратную сторону: для section из Shadow DOM не будут применяться стили из основного документа. Это позволяет полностью инкапсулировать стили внутрь компонента. Помимо такой возможности есть ещё ряд псевдоклассов и псевдоэлементов, которые позволяют ещё больше контролировать стилизацию элементов.


Самый простой псевдокласс вы уже могли заметить в последнем демо: это :host. Он соответствует самому пользовательскому элементу. В скобках можно передать дополнительный селектор, который будет проверяться на совпадение. Так можно стилизовать пользовательский элемент, например, в зависимости от наличия атрибута.


Теперь, когда мы можем изолировать наши стили, вернёмся к компоненту спойлера и уберём лишний код, который отвечает за стилизацию содержимого. Изменения коснулись содержимого Shadow DOM и метода attributeChangedCallback. Демо.


Помимо этого есть возможность стилизовать элемент в зависимости от вложенности в какой-либо элемент. Выглядит это так: селектор :host-context(.red) будет применяться к пользовательскому элементу только тогда, когда он вложен в элемент с классом .red. Эта возможность редко используется, так как сложно предусмотреть все варианты, где может быть вставлен элемент.


Также есть возможность стилизовать контент из Light DOM, который отображается в слотах. Для этого используется псевдоэлемент ::slotted, а в скобках ему необходимо передать селектор, который будет соответствовать каким-либо элементам из Light DOM. При этом работать оно будет только для тех элементов, которые не являются вложенными. Пример:


<name-badge>
  <h2>Eric Bidelman</h2>
  <span class="title">
    Digital Jedi, <span class="company">Google</span>
  </span>
</name-badge>

<style>
::slotted(h2) {
  margin: 0;
  font-weight: 300;
  color: red;
}
::slotted(.title) {
   color: orange;
}
/* НЕ БУДЕТ РАБОТАТЬ (работает только с элементами на верхнем уровне).
::slotted(.company),
::slotted(.title .company) {
  text-transform: uppercase;
}
*/
</style>
<slot></slot>

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


В случае с самим пользовательским элементом всё просто — стили на нём при совпадении со стилями в :host будут перебиты. Таким образом, стилизация по тегу будет перебивать стилизацию через :host. Это позволит управлять контейнером пользовательского элемента.


Для управления элементами в Shadow DOM можно использовать пользовательские свойства CSS, если, конечно, вы поддерживаете только те браузеры, которые позволяют использовать пользовательские свойства.


Взаимодействие с теневым DOM


Пользовательские элементы с теневым DOM могут быть использованы в теневом DOM другого компонента, на это нет никаких ограничений. Также можно использовать и браузерные теги, которые используют теневой DOM, например, тег <video>.


Доступ к теневому DOM открыт любому JavaScript-коду при условии, что теневой DOM был добавлен с опцией {mode: "open"}.


Что ещё?


У вас есть возможность создавать так называемый «закрытый» Shadow DOM, передав опцию mode со значением closed. При этом Shadow DOM будет недоступен через свойство shadowRoot, оно будет возвращать null. Ссылку на настоящий shadowRoot вернёт сам метод attachShadow(), и если сохранить её в переменную, можно работать с shadowRoot в пределах конструтора и нельзя как-то повлиять в последующем. Это сильно ограничивает работу с Shadow DOM, поэтому не рекомендуется к использованию в пользовательских элементах.


Теги <slot> генерируют событие slotchange, если после инициализации пользовательского элемента произошло изменение в Light DOM, которое затронуло этот слот. Таким образом, можно отслеживать изменения и реагировать на них.


Также теги <slot> имеют метод assignedNodes, который возвращает массив DOM-узлов из Light DOM, которые были помещены в этот слот. Если в слот не попал ни один DOM-узел и в качестве аргумента переданы опции {flatten: true}, будет возвращено содержимое слота по умолчанию (иначе оно игнорируется и возвращается пустой массив).


В свою очередь у помещённых в слоты элементов из Light DOM есть метод assignedSlot, который вернёт слот, в который попал элемент.


Шаблоны (HTML Templates)


Наш компонент спойлера уже сейчас довольно хорош, но его можно улучшить с помощью спецификации шаблонов HTML.


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


Вместо этого предлагается использовать тег <template>, который предлагает спецификация HTML Templates. Данный тег может содержать в себе любую разметку, которая будет парситься наравне с остальными тегами, но при это не будет выполняться. Это значит, что разметка не будет добавлена в DOM-дерево, а значит, содержимое тега <script> не будет выполнено, файлы <link> не подгрузятся и не повлияют на страницу, изображения и видеофайлы также не будут загружаться, дочерние элементы не будут находиться методами querySelector и так далее.


При этом у DOM-элемента <template> есть свойство content, которое содержит DocumentFragment, соответствующий его содержимому. А это значит, тег можно использовать для клонирования и вставки в качестве аргумента appendChild.


Вынесем разметку компонента в шаблон, и будем использовать его для заполнения Shadow DOM: демо.


Обратите внимание, что название «шаблон» для этого тега многих сбивает с толку. Эти шаблоны никак не связаны с привычными многим шаблонизаторами: тут нет ни циклов, ни даже заполнения содержимым. Скорее, это шаблон DOM-дерева, эталонный кусок разметки. Вся работа по заполнению данных ложится на плечи разработчика.


HTML-импорты (HTML Imports)


Последним этапом мы отделили разметку и стили от логики. Может, это и улучшило производительность, но для разработчика собирать исходники по нескольким местам, как правило, не очень удобно. Вместо этого спецификация HTML-импортов предлагает другое решение: на один компонент создаётся один HTML-документ, который содержит все ресурсы компонента (разметку, стили, скрипты) и подключается на страницу волшебной строкой <link rel="import" href="x-spoiler.html">.


Как правило, содержимое такого html-файла включает в себя требуемые теги <template> и тег <script> с объявлением класса и регистрацией пользовательского элемента. При подключении такого типа ресурсов, указанный файл скачивается, после чего теги <script> выполняются в глобальной области видимости. Мы можем перенести наш компонент в такой html-файл и подключить на страницу, однако, нам придётся слегка поправить код: разметка, в отличие от скриптов, ни в каком виде не попадает в DOM основного документа, а значит, мы не найдём шаблон через document.getElementById. Вместо этого доступ к DOM-дереву импортированного html-файла осуществляется через свойство import соответствующего тега link. Мы могли бы добавить к тегу <link> id, чтобы по нему находить тег, брать свойство import и искать там template, но это нерационально. Вместо этого можно в начале тега <script> добавить строчку const ownerDocument = document.currentScript.ownerDocument, чтобы запомнить документ, в рамках которого исполняется скрипт, а затем искать <template> в ownerDocument, а не в document. Полный пример. Компонент готов для того чтобы им делиться!


Прелесть импортов в том, что они могут быть вложенными. Например, если ваш компонент зависит от другого (использует или расширяет его), он может содержать <link rel="import"> внутри себя. Разработчику не обязательно подключать все зависимости вручную. При этом браузер, конечно, не будет скачивать файлы повторно, если встретит несколько одинаковых импортов.


Практика


Что использовать


Среди четырёх стандартов я решил выбрать только три. От HTML-импортов я решил отказаться по ряду причин:


  1. Медленный «холодный старт». Вложенные импорты — это прекрасно. Подключить на страницу только app.html, это же так удобно, не правда ли? До тех пор, пока у нас не образуется 20 вложенностей. Браузер будет узнавать о том, что ему нужно скачать следующий файл только после того, как скачает текущий, и так далее, и так далее. Чем больше вложенностей, тем дольше будет прогружаться цепочка компонентов. Эту проблему можно решить предзагрузкой ресурсов, но тогда мы по сути можем отказаться от цепочки импортов и объявлять загрузку всех компонентов сразу, что нивелирует всю пользу от вложенных импортов.
  2. Как показывает практика, браузер в условиях низкого качества связи может не прогрузить тот или иной файл. Если у нас 100 компонентов, повышается вероятность того что один из них не прогрузится, и сайт потеряет n-ную часть функционала. Контролировать, загрузился ли один файл, гораздо проще, чем множество импортов.
  3. Загрузка большого числа компонентов создаст очередь, если на сервере нет поддержки HTTP/2. Так как в моём случае HTTP/2 действительно нет, для меня это неприемлемо.
  4. Для поиска шаблона компонента использовался код document.currentScript. К сожалению, это свойство не поддерживается в IE11 и его нельзя заменить полифилом. Как следствие, нам придётся вставлять все компоненты разом, прописывать им id и находить по id, чтобы можно было получить разметку из <template>, что, опять таки, нивелирует пользую импортов.
  5. Firefox не поддерживает и не будет поддерживать по умолчанию HTML-импорты в том виде, в каком они есть сейчас. Возможно, нас когда-нибудь ждёт обновление спецификации до того вида, который будет поддерживать Firefox, но когда это случится и случится ли, никто не знает.

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


Как использовать


Шаблоны поддерживаются во всех браузерах, за исключением IE, Edge 12 и Safari 7-. Для остальных нам придётся использовать полифил.


Гораздо хуже поддержка пользовательских элементов v1 (Chrome 54+, Safari 10.1+) и теневого DOM v1 (Chrome 53+, Safari 10+). Здесь нам также помогут полифилы.


Наиболее широкий спектр полифилов предоставляет репозиторий WebComponents/webcomponentsjs. В нём есть множество вариантов сборок, но нас интересует только один, webcomponents-sd-ce.js, так как он включает пользовательские элементы и теневой DOM, и при этом не содержит полифил HTML-импортов.


В этом же репозитории, при желании, можно найти и все полифилы отдельно: пользовательские элементы, инкапсуляция разметки — ShadyDOM и стилей — ShadyCSS для теневого DOM. Нам от них может пригодиться разве что документация. Но ещё один репозиторий нам действительно будет полезен: полифил для <template>.


Данные полифилы тоже требуют для своей работы поддержки ряда других возможностей браузера — Promise, конструктор CustomEvent, Object.assign(), Array.from() и некоторые другие мелочи. Если у вас по какой-то причине ещё не организованна поддержка этих возможностей, вы можете либо включить в проект webcomponents-platform из того же репозитория и полифил es6-promise, либо подключить полифилы с сервиса polyfill.io.


Вдобавок ко всему этому необходимо пропатчить Element.prototype.insertAdjacentHTML с помощью ещё одного полифила, если браузер нативно не поддерживает пользовательские элементы. Дело в том, что по какой-то причине полифил пользовательских элементов этого пока что не делает, есть открытый issue. UPD: исправлено.


Следующим этапом было написание тестов, для того чтобы определить, какие возможности мы действительно сможем использовать с учётом использования всего множества полифилов. Тесты можно посмотреть (и попробовать) по ссылке.


Можно упомянуть альтернативный полифил пользовательских элементов от WebReflection, но он требует некоторое шаманство при использовании конструктора и в целом в тестах показал себя хуже (смотреть в Firefox). Возможно, результаты можно улучшить, если подразобраться, но в целом я слабо верю в хорошую совместимость двух полифилов от разных авторов.


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


Итого, нам понадобятся:



Ограничения, с которыми придётся смириться


Ограничения поддержки стандарта


Тестовый стенд примерно соответствует поддерживаемым на проекте браузерам. На этих браузерах проверялось прохождение тестов с настроенным окружением. Список:


  • Internet Explorer 11+
  • Edge 12+
  • Firefox 35+
  • Chrome 26+
  • Safari 6.1+
  • iOS-браузеры 8+
  • Android browser 4.4+

От чего в итоге мы вынуждены отказаться:



Кроме того, так как сами стандарты довольно сложные, полифилы сами по себе имеют ряд ограничений. Здесь я перечислю основные особенности и ограничения, с которыми придётся смириться.


Ограничения полифила пользовательских элементов


Первая сложность — пользовательские элементы должны быть объявлены через классы. При использовании Babel классы с их extends будут преобразованы в обычное прототипное наследование, и Chrome с поддержкой пользовательских элементов, например, начнёт выдавать ошибку при попытке обработать ES5-код. Чтобы это починить, предлагается использовать custom-elements-es5-adapter.js из того же репозитория, но подключать его необходимо только в актуальных браузерах, ведь его код нельзя транспилировать. В общем, сложно. Если мы используем ES5-код, нам нужно либо подключать полифилы веб-компонентов (для старых браузеров), либо подключать адаптер (для новых браузеров).


Один из вариантов решения основан на удалении из DOM адаптера, если пользовательские элементы поддерживаются нативно:


<div id="custom-elements-adapter-test">
    <script>
        (function() {
            if (isNative(window.customElements.define) === false) {
                // предполагается, что Element.prototype.remove() у нас заполифилен,
                // либо сохраняем в переменную и удаляем по старинке,
                // через .parentNode.removeChild()
                document.getElementById("custom-elements-adapter-test").remove();
            }

            function isNative(fn) {
                return (/\{\s*\[native code\]\s*\}/).test('' + fn);
            }
        })();
    </script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.22/custom-elements-es5-adapter.js"></script>
</div>

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


Альтернативой является использование babel-plugin-transform-custom-element-classes. При подключении данного плагина вам больше не нужно использовать es5-adapter, но при этом мы получаем нерабочий код в IE11 и многих других браузерах, так как решение плагина построено на использовании Reflect.construct. Возможно, вы тоже, как и я, до этого о таком не слышали. То есть, решая эту проблему, мы породили другую — теперь нам для старых браузеров нужен ещё один полифил. Причём с ним не всё так просто: готовый, работающий в данной ситуации полифил я нашёл только в babel-polyfill. А это просто огромный файл. Спасибо, но пока что нет.


Ещё одна альтернатива — babel-plugin-transform-builtin-classes от упомянутого выше WebReflection. С ним всё гораздо лучше и легче, за исключением того, что код для IE11 опять-таки ломается, но теперь по иной причине. Дело в том, что этот плагин не совместим с полифилом пользовательских элементов от WebComponents. Точка зрения автора здесь — надо было ставить линукс надо использовать полифил пользовательских элементов от WebReflection, а не от WebComponents. Поэтому этот вариант нам также не подходит.


И ещё одна альтернатива, которая выглядит многообещающе, но опять таки имеет ограничения — использовать Babel 7, который сейчас находится в статусе бета. Совсем недавно там появилась встроенная возможность расширять нативные элементы. Несмотря на то что выглядит это очень привлекательно, на практике я получил сломанный код и в Chrome (есть проблемы с наследованием и полифилами), и в IE11, так как в основе опять лежит Reflect.construct.


Итого: в текущий момент решение с удалением из DOM ES5-адаптера, если он не нужен, будет для нас самым приемлемым. Со временем мы его улучшим, а пока — будем использовать в таком виде.


Есть ограничения по свойству constructor: в IE и Safari конструктор может быть равен HTMLUnknownElementConstructor, а не настоящему конструктору пользовательского элемента. Поэтому небезопасно полагаться на это свойство.


Есть и ещё немного ограничений.


Ограничения полифила теневого DOM


Чтобы симулировать инкапсуляцию стилей, необходимо немного дополнительного кода. Во-первых, единожды для каждого шаблона необходимо вызвать метод ShadyCSS.prepareTemplate(), передав туда первым аргументом сам шаблон, вторым — название пользовательского элемента, к которому этот шаблон относится. В случае с шаблоном <template id="x-spoiler"> и пользовательским элементом <x-spoiler>, необходимо вызвать строчку ShadyCSS.prepareTemplate(document.getElementById("x-spoiler"), "x-spoiler").


Перед использованием же шаблона необходимо также вызвать дополнительный код: ShadyCSS.styleElement(this);


Есть ограничения на уровень вложенности селекторов со скобками при использовании вместе с :host(): :host(.zot) и :host(.zot:not(.bar)) будут работать, а :host(.zot:not(.bar:nth-child(2))) — уже нет.


Есть ограничения на использование селектора ::slotted: слева от селектора обязательно должен быть родитель. Так, селектор ::slotted(span) в тестах не будет работать с полифилом, поэтому там указан селектор .header ::slotted(span).


Также ещё немного ограничений.


Ограничения полифила шаблонов


Следующая проблема — это инициализация полифила тега <template>. Дело в том, что он отрабатывает только после события DOMContentLoaded, а это значит, что customElements.define(), запущенный до завершения построения DOM-дерева, столкнётся с невозможностью использования свойства content у тегов template. Решения два: либо запускать customElements.define() также по событию DOMContentLoaded, либо принудительно инициализировать полифил до первого customElements.define(). Второй вариант я вижу менее костыльным. Выглядеть это будет так:


try {
    HTMLTemplateElement.bootstrap(document);
} catch (e) { }

Чтобы полифил <template> и теневого DOM корректно отработал, на нас накладывается ещё одно ограничение: все теги <template> уже должны быть в DOM-дереве на момент инициализации пользовательских элементов. Помогать нам с этим будет система сборки.


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


Сборка с помощью gulp


Итак, мы определились, как будут выглядеть компоненты в исходном виде: используем однофайловые компоненты с расширением html. Внутри обязательно есть один тег <script>, содержащий логику компонента. Также может быть любое число (или не быть вообще) тегов <template>, содержащих разметку для теневого DOM и стили или содержимого в виде ванильного HTML. Каждый <template> обязательно содержит уникальный id, по которому шаблон будет находиться. Если шаблон предназначен для теневого DOM, он может содержать тег <style> со стилями, написанными на ванильном CSS. Препроцессоры в данном случае могут быть избыточны, к тому же наш HTML ни о каких препроцессорах знать не знает и, как следствие, не должен содержать в теге <style> ничего, кроме обычного css.


Сборщик проекта нам нужен для того, чтобы привести это всё к виду, который будет доступен всем полифилам и всем браузерам. Для этого нам нужно:


  • выделить всю разметку в отдельный файл templates.html
  • прогнать при этом стили через PostCSS
  • выделить все скрипты в отдельный файл app.js
  • прогнать при этом их через Babel

Так как по историческим причинам для сборки моего проекта использовался gulp, и до сих пор для многих задач его достаточно, я написал gulpfile, который покрывает все требования. Полный код примеров с возможностью собрать и посмотреть приведён на github. Рассмотрим что и как собирается:


Исходники лежат в папке src. Там есть html-файл приложения (предположим, у нас одностраничник), файл scaffolding.js с вспомогательным js-кодом и папка components с двумя независимыми веб-компонентами.


index.html содержит вставку @@templates, которая будет заменяться сборкой на шаблоны веб-компонентов, использование самих веб-компонентов, подключение полифилов, подключение es5-адаптера по условию, подключение бандла app.js и демо-скрипт, который слушает событие одного из компонентов и выводит соответствующее уведомление.


scaffolding.js используется для принудительной активации полифила шаблонов и вспомогательного кода для инициализации шаблонов, нужного полифилу теневого DOM (к этому коду мы вернёмся позднее).


Теперь поподробнее разберём сборку. У нас две основные задачи: сборка скриптов и шаблонов.


Задача scripts преобразует теги <script> компонентов в app.js:


gulp.task("scripts", () =>
    // выберем все компоненты
    gulp.src("src/components/*.html")
        // сразу склеим все файлы в один
        .pipe(concat("app.js"))
        // преобразуем файл. чтобы не прибегать к регуляркам, 
        // используем jsdom, позволяющий пользоваться браузерными api
        // выберем все теги <script> и склеим их содержимое
        .pipe(insert.transform(content => {
            const document = (new JSDOM(content)).window.document;
            const scriptsTags = document.querySelectorAll("script");
            const scriptsContents = Array.prototype.map.call(scriptsTags, tag => tag.textContent);
            return scriptsContents.join("");
        }))
        // добавим файл scaffolding.js
        .pipe(gap.prependFile("src/scaffolding.js"))
        // пропустим результат через Babel
        .pipe(babel({
            presets: ["env"]
        }))
        // сохраним результат
        .pipe(gulp.dest("dist"))
)

Задача templates выполняет нечто схожее, объединяя теги <template> в один файл и приводя при этом в порядок стили:


gulp.task("templates", () =>
    // ещё раз выберем все компоненты
    gulp.src("src/components/*.html")
        // сразу преобразуем их с использованием jsdom, оставив только теги <template>
        // при этом добавим тегам data-атрибут, указывающий, к какому компоненту
        // относится шаблон. Это пригодится в дальнейшем
        .pipe(insert.transform((content, file) => {
            const componentName = path.basename(file.path, ".html");
            const document = (new JSDOM(content)).window.document;
            const templatesTags = document.querySelectorAll("template");
            templatesTags.forEach(template => template.setAttribute("data-component", componentName));
            const templatesHTML = Array.prototype.map.call(templatesTags, tag => tag.outerHTML);
            return templatesHTML.join("");
        }))
        // после этого склеим шаблоны в один файл
        .pipe(concat("templates.html"))
        // прогоним через gulp-html-postcss, который умеет работать с html и <style>
        .pipe(postcss([
            autoprefixer()
        ]))
        // при необходимости — минифицируем html и css
        .pipe(htmlmin({
            collapseWhitespace: true,
            conservativeCollapse: true,
            minifyCSS: true,
        }))
        // сохраним результат
        .pipe(gulp.dest("dist"))
);

Ещё одна задача отвечает за вставку в html-файл на место вставки @@templates содержимого файла templates.html, собранного в задаче templates. В данном случае это производится с помощью gulp, но может быть реализовано как угодно. Например, в случае с бэкэндом на php подойдёт вставка содержимого файла через include. При этом не понадобится лишняя задача в gulp и некоторые пакеты.


Один момент требует особого пояснения, зачем во время сборки дополнительно модифицировать шаблоны, проставляя data-атрибуты? Gulp позволяет автоматизировать рутинные операции. В данном случае информация о том, к какому компоненту какой шаблон относится, позволяет автоматизировать вызов ShadyCSS.prepareTemplate(). Собственно, это и было сделано в scaffolding.js:


document.querySelectorAll("template[data-component]").forEach(template => {
    ShadyCSS.prepareTemplate(template, template.dataset["component"]);
});

Вот и всё! Теперь мы можем использовать веб-компоненты, а gulp поможет нам преобразовать это в удобный для продакшна вид. Дальше мы займёмся улучшением нашей сборки и самих компонентов.


Базовый компонент


Как можно заметить, даже в двух компонентах есть повторяющийся код. Логично было бы вывести его в некий базовый класс, и расширять уже его, а не напрямую HTMLElement. Для этого можно описать компонент x-component, куда добавить то, без чего разработка компонентов вам не мила. Я могу предложить следующие идеи (это только идеи, а не рекомендации к действию):


Методы $ и $$. Это синонимы для выборки querySelector и querySelectorAll по Light DOM. Такой синтаксис используется в Chrome DevTools, хоть и напоминает он в первую очередь jQuery.


Метод fireEvent, который позволяет упростить отправку событий на пользовательском элементе. Вместо предварительного создания объекта CustomEvent через довольно неудобный конструктор, в метод передаются данные, на основе которых создаётся событие и вызывается dispatchEvent. Бонусом идёт кэширование событий с одинаковыми названиями и данными, чтобы при повторной отправке одного и того же события c теми же параметрами не создавать новые объекты.


// было
this.dispatchEvent(new CustomEvent(`x-timer.ended`, { bubbles: true }));
// стало
this.fireEvent(`x-timer.ended`);

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


Метод getTemplateCopy возвращает копию содержимого какого-либо шаблона по его id.


Метод makeShadowRoot автоматизирует поиск подходящего шаблона и вставку его в теневой DOM. Базируется он на знании о том, что id основного шаблона компонента соответствует названию самого пользовательского элемента (оно же this.is) и использует в том числе метод getTemplateCopy. Туда же упакован код для полифила теневого DOM (ShadyCSS.styleElement(this);), который очень не хочется писать каждый раз вручную. Ещё один бонусом добавляются методы $ и $$ и для shadowRoot.


// было
ShadyCSS.styleElement(this);
this.attachShadow({ mode: "open" });
const template = document.getElementById("x-spoiler");
const templateClone = template.content.cloneNode(true);
templateClone.querySelector("button").textContent = this.text["when-close"];
this.shadowRoot.appendChild(templateClone);
// стало
this.makeShadowRoot();
this.shadowRoot.$("button").textContent = this.text["when-close"];

Функционал базового компонента ограничен только вашими потребностями. Например, в компоненты можно добавить свойство properties, где описывать, какие свойства будут доступны для записи/чтения из атрибутов как свойства объекта, их типы (так как в атрибутах у нас хранятся только строки), а в базовом компоненте реализовывать нужный функционал по установке сеттеров и гететров. Я не реализовал это, но выглядеть код будет примерно так:


// теоретически, было
get opened() {
    return this.getAttribute("opened") !== null;
}

set opened(value) {
    if (!!value) {
        this.setAttribute("opened", "");
    } else {
        this.removeAttribute("opened");
    }
}
// теоретически, стало
properties = {
    opened: {
        type: Boolean,
    },
}

И да, кто-то это уже сделал в своём базовом компоненте.


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


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


Вторая сборка, без полифилов


Итак, теперь, когда мы обмазались полифилами со всех сторон, мы можем писать и использовать веб-компоненты даже в IE11, но как же светлое будущее, без лишних огромных файлов по сети? Сейчас даже Chrome, который всё поддерживает, вынужден скачивать es5-адаптер для работы es5-кодом и веб-компонентами. Не порядок.


Идея в том, чтобы собрать JS под актуальные браузеры, поддерживающе веб-компоненты в достаточной мере (они же — поддерживающие современные стандарты ES), и под остальные. Доля первых будет со временем увеличиваться, а доля вторых — уменьшаться. Стоит ли вообще заморачиваться ради части пользователей? Определённо, стоит. На рабочем проекте доля пользователей, у которых весь код будет работать нативно, без всяких полифилов и преобразований, на момент начала 2018 года приближается к 75%. И с каждым месяцем эта цифра будет расти.


Во-первых, отредактируем сборку, сделав два отдельных таска на сборку ES5 и ES6 кода. Так, например, Babel и подключение scaffolding.js нам нужны только при сборке под старые браузеры. Задачи будут выглядеть примерно так:


gulp.task("scripts-es5", buildJS.bind(null, "es5"));

gulp.task("scripts-es6", buildJS.bind(null, "es6"));

function buildJS(mode) {
    return gulp.src("src/components/*.html")
        .pipe(concat(`app-${mode}.js`))
        .pipe(insert.transform(content => {
            const document = (new JSDOM(content)).window.document;
            const scriptsTags = document.querySelectorAll("script");
            const scriptsContents = Array.prototype.map.call(scriptsTags, tag => tag.textContent);
            return scriptsContents.join("");
        }))
        .pipe(gulpif(mode === 'es5', gap.prependFile("src/scaffolding.js")))
        .pipe(gulpif(mode === 'es5', babel({presets: ["env"]})))
        .pipe(uglify())
        .pipe(gulp.dest("dist"))
}

На выходе получим два файла, app-es5.js и app-es6.js. Говорить про разницу в размере будет не совсем корректно, так как процентное соотношение будет варьироваться в зависимости от количества компонентов. С двумя маленькими компонентами файлы отличаются аж в два раза, с бо́льшим количеством компонентов разрыв будет сокращаться, но тем не менее будет очень заметен пользователям ваших продуктов.


Но это только часть оптимизаций. Не забывайте о том, что новым браузерам также не придётся грузить и обрабатывать около 150 килобайт полифилов! Осталось только это реализовать. Как вариант, сделать это можно тем же образом, что и в случае с подключением адаптера. Но при этом современные браузеры хоть и не будут исполнять не нужный код, скачать файлы скриптов браузеры успеют. Поэтому можно воспользоваться простым добавлением тех или иных скриптов в документ. Для этого используем следующий код в html:


(function () {
    var wcReady = ("attachShadow" in document.documentElement) && ('customElements' in window);

    var scripts;

    if (wcReady) {
        scripts = [
            "./app-es6.js"
        ];
    } else {
        scripts = [
            "https://cdn.polyfill.io/v2/polyfill.js?features=default",
            "https://cdnjs.cloudflare.com/ajax/libs/webcomponentsjs/1.0.22/webcomponents-sd-ce.js",
            "https://cdn.jsdelivr.net/npm/template-mb@2.0.6/template.js",
            "./app-es5.js"
        ];
    }

    scripts.forEach(function (script) {
        insertScript(script);
    });

    function insertScript(src) {
        var script = document.createElement('script');
        script.src = src;
        script.async = false;
        document.head.appendChild(script);
    }
})();

Теперь браузер действительно будет грузить только одну комбинацию скриптов. Можно дополнительно оптимизировать код: скачать базовые полифилы, установить полифилы веб-компонентов через bower и включить всех их в es-5 вариант сборки. Однако, с учётом того что со временем это станет не актуальным, рациональность этих дополнительных оптимизаций под вопросом и зависит от свободного времени разработчика.


Результат доступен по ссылке.


Polymer


Говоря про веб-компоненты, нельзя не упомянуть Polymer. Это библиотека, которая упрощает создание и использование веб-компонентов за счёт вспомогательных веб-компонентов и расширения компонента Polymer.Element. Она требует те же самые полифилы, и сама из себя представляет веб-компонент, который первым подключается на страницу.


В настоящее время, говоря веб-компоненты, люди чаще всего подразумевают Polymer. Шикарное хранилище готовых веб-компонентов webcomponents.org представляет по сути набор компонентов для Polymer, так как большинство используют его как зависимость. Вы не можете просто взять компонент и использовать его, если у вас не используется Polymer.


Несмотря на то что Polymer позиционирует себя как библиотека, компоненты, использующие Polymer, должны быть оформлены строго определённым образом, не типичным для простых веб-компонентов. Polymer предоставляет некоторый уровень абстракции над веб-компонентами, как, например, jQuery предоставляет уровень абстракции над работой с DOM в JS.


Работает Polymer с использованием HTML-импортов.


Ну и так как Polymer, в отличие от веб-компонентов, это не часть браузера, это ещё и лишние подключаемые файлы в проекте.


Ещё одной важной особенностью Polymer является довольно динамическое развитие и смена API и подходов к разработке. Так, например, следующая, третья версия библиотеки не будет использовать ни HTML-импорты (вместо них ES6 модули), ни HTML-шаблоны (вместо них шаблонные строки). То есть компоненты, написанные для второй версии, не будут совместимы с компонентами для третьей версии. Такие изменения видятся мне как ещё более динамичные, чем изменения в самом стандарте.


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


После внедрения


Уже после написания статьи, через некоторое время использования было принято решение отказаться от однофайловых компонентов и вернуться к разделению кода на файлы — стили, скрипты, шаблоны. Основная проблема проявилась в использовании стилей. Слишком сильна была привычка использовать препроцессоры. И если от миксинов я был готов отказаться, то без переменных и вложенностей (для псевдоклассов, псевдоэлементов и особоенно для медиазапросов) оказалось очень сложно. Помогали, конечно, решения для PostCSS, но плагины для переменных очень костыльны. Плюс — моя IDE не смогла корректно форматировать и подсвечивать PostCSS-синтаксис внутри тега <style>. Да и работать с отдельными файлами оказалось проще.


В результате сборка проекта немного упростилась, но появилась сборка стилей с помощью node-sass. Стили после компиляции вставлялись в шаблоны, на место пустого тега <style></style>, если такой находился.


Выводы


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


Конечно, стандарт ещё может меняться (например, есть предложение на введение псевдоэлементов ::part и ::theme для упрощения стилизации теневого DOM от Таба Аткинса). Но веб-компоненты — это стандарты, и с ним как минимум надо быть знакомым. Я же верю в светлое будущее веб-компонентов и в их самостоятельность в отрыве от Polymer и советую — пробуйте, используйте веб-компоненты не только как часть Polymer, но и как самостоятельную технологию.


Ресурсы


Tags:
Hubs:
Total votes 31: ↑31 and ↓0 +31
Views 45K
Comments 18
Comments Comments 18

Posts