Привет, Хабр!

За последнее время я заметил целый ряд статей с разного рода критикой веб-компонентов. Порой, эта критика весьма жесткая и даже попахивает хейтерством. На мой взгляд, основной проблемой тут является отсутствие наработанной практики работы с этой группой стандартов у сообщества разработчиков. Многие привычные модели не всегда органично вписываются в проекты с участием Custom Elements и Shadow DOM, на многие вещи приходится смотреть под новым углом и не всем это нравится. Я вполне успешно работаю с веб-компонентами уже несколько лет и даже разрабатываю собственную библиотеку на их основе, поэтому считаю такую ситуацию не очень справедливой. Постараюсь, хотя бы частично, это исправить, по мере моих скромных сил. Я решил сделать серию компактных публикаций, в каждой из которых я планирую затронуть один из частых аспектов критики, а так-же продемонстрировать ряд технических приемов, которые могут оказаться интересными тем, кто еще не определился с тем, на какой стороне баррикад ему быть. Сегодня я хотел бы рассказать о том, как создавать компоненты без Shadow DOM.

Зачем?


Основная мысль, которую я хочу донести на этот раз, это то, что веб-компоненты и Shadow DOM — это не одно и то-же. При использовании Shadow DOM, вы получаете два основных преимущества:

  • Изолированный участок документа, в котором ваши стили чувствуют себя в безопасности от внешних влияний и «протечек»
  • Композиционный механизм, позволяющий разделить документ на то, что является структурой самого компонента и на его содержимое (потомки DOM-элемента в дереве)

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

const MY_CSS = {
  title: 'color: #00f; font-size: 2em',
  item: 'color: #f00; font-size: 1.2em',
};

const DATA = [
  {text: 'Text 1'},
  {text: 'Text 2'},
  {text: 'Text 3'},
];

let template = document.createElement('template');
template.innerHTML = /*html*/ `
<div style="${MY_CSS_.title}">List items:</div>
<div class="my-list">
  ${DATA.map(item => /*html*/ `<div style="${MY_CSS.item}">${item.text}</div>`).join('')}
</div>
`;

class ShadowlessComponent extends HTMLElement {
  constructor() {
    super();
    this._contents = new DocumentFragment();
    this._contents.appendChild(template.content.cloneNode(true));
  }
  connectedCallback() {
    this.appendChild(this._contents);
  }
}

window.customElements.define('shadowless-component', ShadowlessComponent);

Если вы уже знакомы со стандартом Custom Elements, вы сразу заметите в чем дело: вместо вызова метода attachShadow в конструкторе компонента, мы создали DocumentFragment в который клонировали заранее подготовленный шаблон. На данном этапе, компонент не рендерится браузером и его можно относительно безопасно модифицировать, например, привязать/вставить данные.

Следующий важный этап связан с жизненным циклом Custom Elements. В общий документ компоненты добавляются только после того, как полностью сработает конструктор и до этого момента, та часть DOM API, которая отвечает за работу с родителями или потомками элемента, а также, с атрибутами, будет недоступна. Поэтому, для непосредственного добавления контента в наш компонент, мы используем connectedCallback.

При создании шаблона, мы, для простоты, использовали метод innerHTML. Данная операция выполняется только однажды, при создании элемента «template», она не повторяется каждый раз при создании экземпляра нашего компонента. Однако, этот момент также можно дополнительно оптимизировать создавая шаблоны императивно.

Итого, используя в нашей разметке кастомный тег shadowless-component, мы получаем следующий результат в браузере:

<shadowless-component>
  <div id="caption" style="color: #00f; font-size: 2em">List items:</div>
    <div class="my-list">
      <div style="color: #f00; font-size: 1.2em">Text 1</div>
      <div style="color: #f00; font-size: 1.2em">Text 2</div>
      <div style="color: #f00; font-size: 1.2em">Text 3</div>
    </div>
</shadowless-component>

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

display: contents


Веб-компоненты являются полноправными узлами вашего DOM. Это означает, помимо того, что вам доступны все стандартные методы DOM-элементов, и то, что ваш компонент — это всегда некий контейнер. То есть, если вы захотите, с помощью веб-компонента, добавить в DOM произвольную структуру элементов, все они окажутся потомками вашего компонента, что не всегда удобно. В таких случаях, можно использовать новое CSS-правило — display: contents. Поддержка браузерами: caniuse.com/#feat=css-display-contents

По умолчанию же, все компоненты имеют свойство display: inline.

Немного похулиганим


А что если мы вообще не хотим никаких лишних контейнеров и кастомных тегов? Даешь чистый HTML!

Ок:

  constructor() {
    super();
    this._contents = new DocumentFragment();
    this._contents.appendChild(template.content.cloneNode(true));
    this._titleEl = this._contents.querySelector('#caption');
    window.setInterval(() => {
      this._titleEl.textContent = Date.now();
    }, 1000);
  }
  connectedCallback() {
    this.parentNode.prepend(this._contents, this);
    this.remove();
  }

В результате получаем это:

<div id="caption" style="color: #00f; font-size: 2em">1581075598392</div>
<div class="my-list">
  <div style="color: #f00; font-size: 1.2em">Text 1</div>
  <div style="color: #f00; font-size: 1.2em">Text 2</div>
  <div style="color: #f00; font-size: 1.2em">Text 3</div>
</div>

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

CSS Компоненты


Согласно стандарту, пользовательские теги должны именоваться с обязательным добавлением символа "-". Если вы используете свой тег в разметке, но, при этом, вообще не создаете никакого компонента в JS и не добавляете его конструктор в реестр компонентов — браузер считает ваш тег «неизвестным элементом» (HTMLUnknownElement). По умолчанию, такие элементы аналогичны по поведению тегу «span». Этим можно воспользоваться, если вам нужно создать простой dumb-компонент с незатейливой структурой, для которой достаточно CSS правил ::before, ::after и выражения attr(). Пример:

  my-container {
    display: block;
    padding: 10px;
    border: 1px solid currentColor;
  }
  my-container::before {
    content: attr(caption);
    margin-bottom: .6em;
  }

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

<my-container caption="Заголовок">Содержимое</my-container>