Інтро до Веб-компонентів

50 хв. читання

Фронтенд-розробка розвивається з шаленою швидкістю. Варто лише поглянути на численні статті, туторіали та треди у Twitter зі скаргами на технології, колись такі прості та зрозумілі. У цій статті ми з'ясуємо, чому для якісного користувацького досвіду чудово підходять Веб-компоненти — інструмент, що не має складних фреймворків, этапів збірки чи ризику втратити актуальність.

Для розуміння матеріалу необхідні базові знання HTML, CSS та JavaScript. Якщо ви відчуваєте певну прогалину у знаннях, не засмучуйтесь: створення користувацьких елементів насправді спрощує розуміння фронтенд-розробки.

Що таке Веб-компоненти?

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

  1. Користувацькі елементи. Якщо стисло, це повністю допустимі HTML-елементи з кастомними шаблонами, поведінкою і назвою тегів (наприклад, <one-dialog>), які містять JavaScript API. Користувацькі елементи визначені в HTML-специфікації.
  2. Shadow DOM. Здатний ізолювати CSS і JavaScript, майже як <iframe>. Визначений у DOM-специфікації.
  3. HTML-шаблони. Користувацькі шаблони в HTML, які не відображаються до безпосереднього їх виклику. Тег <template> визначено в HTML-специфікації.

Ми описали складові специфікації веб-компонентів.

Зверніть увагу: HTML-модулі, ймовірно, будуть четвертою технологією в стеку, але вони ще не реалізовані в жодному з найвідоміших браузерів. Команда Chrome оголосила про намір додати їх до майбутнього релізу.

Веб-компоненти доступні у всіх основних браузерах, за винятком Microsoft Edge й Internet Explorer 11, але для заповнення таких прогалин існують поліфіли.

Поняття Веб-компонентів досить широке. Тобто кожна з описаних технологій може використовуватись самостійно або у поєднанні з іншими. Тобто вони не є взаємовиключними.

Розглянемо кожен з пунктів детальніше.

Користувацькі елементи

Як видно з назви, користувацькі елементи — це HTML-елементи, на зразок <div>, <section> або <article>, та ми можемо самостійно визначити їх назву, відповідно до API браузера. Користувацькі елементи схожі на стандартні HTML-елементи, але їхня назва завжди містить дефіс (наприклад, <news-slider> або <bacon-cheeseburger>). Вбудовані елементи браузера не мають дефісів у назві— для запобігання конфліктів з користувацькими елементами.

Користувацькі елементи мають власну семантику, поведінку, їх можна поширювати між різними фреймворками та браузерами.

class MyComponent extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<h1>Hello world</h1>`;
  }
}
    
customElements.define('my-component', MyComponent);

У наведеному прикладі ми визначили <my-component> — власний HTML-елемент. Очевидно, що він не має корисного функціоналу, однак ви побачили основний процес створення. Усі користувацькі елементи повинні певним чином розширяти HTMLElement для того, щоб браузер міг їх зареєструвати.

Такі елементи існують без сторонніх фреймворків. Браузери організовують зворотну сумісність специфікацій, щоб гарантувати, що компоненти не постраждають від змін в API. Ба більше, кастомні компоненти можуть використовуватися «з коробки» з мінімальними зусиллями у популярних фреймворках, на зразок Angular, React, Vue тощо.

Shadow DOM

Shadow DOM — інкапсульована версія DOM. Вона дозволяє ефективно ізолювати фрагменти DOM один від одного, зокрема будь-який вид CSS-селекторів та пов'язані з ними стилі. Як правило, будь-який контент всередині документа називається light DOM, а все, що знаходиться всередині shadow root, називається shadow DOM.

При використанні light DOM, елемент можна вибрати за допомогою document.querySelector('selector'), або element.querySelector('selector'). Подібним чином можна звернутися до дочірніх елементів shadow root, викликавши shadowRoot.querySelector, де shadowRoot — посилання на фрагменти документу. Різниця у тому, що дочірні елементи shadowRoot не можна викликати з light DOM. Наприклад, якщо у нас є shadow root з <button> і ми викличемо shadowRoot.querySelector('button'), то буде повернено нашу кнопку, але повторити те ж саме для document не вдасться, тому що наш елемент належить до іншого екземпляру - DocumentOrShadowRoot. Селектори стилю працюють так само.

З такого погляду shadow DOM працює як <iframe>, де вміст відокремлений від решти документа. Коли ми створюємо shadow root, у нас досі є повний контроль над частиною сторінки, але область видимості обмежується контекстом. Це називається інкапсуляція.

Якщо ви колись створювали компонент, що використовує деякий id, інструменти CSS-in-JS або методології CSS (на зразок BEM), shadow DOM може поліпшити ваш досвід розробки.

	<div>
  <div id="example">
    <!-- Код для позначення shadow root -->
    <#shadow-root>
      <style>
      button {
        background: tomato;
        color: white;
      }
      </style>
      <button id="button">This will use the CSS background tomato</button>
    </#shadow-root>
  </div>
  <button id="button">Not tomato</button>
</div>

Окрім псевдокоду у <#shadow-root> (який потрібен для відмежовування shadow root, що не містить HTML-елементів), HTML повністю коректний. Щоб приєднати shadow root до вузла вище, потрібен подібний код:

const shadowRoot = document.getElementById('example').attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `<style>
button {
 color: tomato;
}
</style>
<button id="button">This will use the CSS color tomato <slot></slot></button>`;

Також shadow root може вміщувати контент документа за допомогою <slot>. Так контент зовнішнього документу буде переміщено у призначене місце.

HTML-шаблони

HTML-елемент <template> дозволяє нам створювати повторно використовувані шаблони коду всередині нормального HTML-потоку. Вони не будуть відображатись одразу ж, однак можуть бути повторно використані пізніше.

<template id="book-template">
 <li><span class="title"></span> — <span class="author"></span></li>
</template>

<ul id="books"></ul>

Наведений код не буде показувати контент, поки скрипт не вкаже браузеру, що з ним робити .

const fragment = document.getElementById('book-template');
const books = [
  { title: 'The Great Gatsby', author: 'F. Scott Fitzgerald' },
  { title: 'A Farewell to Arms', author: 'Ernest Hemingway' },
  { title: 'Catch 22', author: 'Joseph Heller' }
];

books.forEach(book => {
  // Створення екземпляра вмісту
	Createtemplate
  const instance = document.importNode(fragment.content, true);
  // Додаємо відповідний вміст
  instance.querySelector('.title').innerHTML = book.title;
  instance.querySelector('.author').innerHTML = book.author;
  // Приєднуємо екземпляр до DOM
  document.getElementById('books').appendChild(instance);
});

Помітьте, що у цьому прикладі ми створюємо шаблон (<template id="book-template">) без використання Веб-компонентів. Так ми ще раз підтверджуємо, що згадані на початку статті складові можуть використовуватись незалежно чи спільно.

У такий спосіб всі, хто використовують template API, можуть створити шаблон будь-якої форми чи структури і використати його пізніше. Інша веб-сторінка може послуговуватися тим самим сервісом, але організувати шаблон так:

<template id="book-template">
  <li><span class="author"></span>'s classic novel <span class="title"></span></li>
</template>

<ul id="books"></ul>

Створюємо повторно використовувані HTML-шаблони

Щоб продемонструвати взаємодію користувацьких елементів, shadow DOM та HTML-шаблонів у дії, ми з нуля створимо модальний діалог.

HTML-шаблони

Однією з найменш відомих, але найбільш потужних фіч специфікації веб-компонентів є елемент <template>. Раніше ми вже визначили елемент template як «користувацький шаблон HTML, що не рендериться, поки не було безпосереднього виклику». Іншими словами, template — це HTML-розмірка, що ігнорується браузером, поки явно не вказано протилежне.

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

Визначаємо наш шаблон

Як би це просто не звучало, <template> — звичайний HTML-елемент, тому базова розмітка з контентом виглядатиме так:

<template>
  <h1>Hello world</h1>
</template>

Якщо ви спробуєте відкрити таку розмітку у браузері, ви побачите пустий екран, тому що браузер не здатний відображати вміст <template>. Така особливість може бути надзвичайно потужною, тому що дозволяє нам визначати вміст (або структуру) шаблону та зберігати його на потім, замість того щоб додавати HTML у JavaScript.

Щоб використовувати <template>, нам знадобиться JavaScript.

const template = document.querySelector('template');
const node = document.importNode(template.content, true);
document.body.appendChild(node);

Справжня магія відбувається у методі document.importNode. Там ми створюємо копію content для template та готуємо її до розміщення в іншому документі (або його фрагменті). Перший аргумент функції відповідає за контент шаблону, а другий вказує браузеру створити копію піддерева DOM для елементу (тобто всіх його дочірніх елементів).

Ми могли б використати template.content безпосередньо, але тоді ми видалили б вміст з елемента і додали до тіла документа пізніше. Будь-який вузол DOM можна приєднати в єдиному місці. Тому при подальшому використанні вмісту шаблону отримаємо порожній документ (по суті, нульове значення), оскільки вміст раніше вже було переміщено. Використання document.importNode дозволяє повторно використовувати екземпляри того самого вмісту шаблону у декількох місцях.

Такий вузол потім приєднується до document.body та рендериться. Тепер ми можемо робити цікаві речі. Перш за все, ми зможемо давати нашим користувачам шаблони, які вони наповнюватимуть по-своєму:

Тут ми організували два шаблони для показу однакового вмісту: авторів та їх книг. Для кожної форми відображення даних, ми обираємо відповідний шаблон для рендерингу. В такий спосіб ми зрештою створимо кастомний елемент, який використовуватиме шаблон, визначений пізніше.

Універсальність шаблону

Цікава фіча шаблонів — вони можуть вміщувати будь-який HTML: з скриптом та елементами стилю. Простим прикладом буде шаблон кнопки, після натискання на яку ми бачимо діалогове вікно.

<button id="click-me">Log click event</button>

Додамо їй трохи стилів:

button {
  all: unset;
  background: tomato;
  border: 0;
  border-radius: 4px;
  color: white;
  font-family: Helvetica;
  font-size: 1.5rem;
  padding: .5rem 1rem;
}

Та слухача подій:

const button = document.getElementById('click-me');
button.addEventListener('click', event => alert(event));

Звичайно, ми можемо розмістити усе це в одному місці, використавши теги <style> та <script> прямо у шаблоні.

<template id="template">
  <script>
    const button = document.getElementById('click-me');
    button.addEventListener('click', event => alert(event));
  </script>
  <style>
    #click-me {
      all: unset;
      background: tomato;
      border: 0;
      border-radius: 4px;
      color: white;
      font-family: Helvetica;
      font-size: 1.5rem;
      padding: .5rem 1rem;
    }
  </style>
  <button id="click-me">Log click event</button>
</template>

Одразу, як елемент буде приєднано до DOM, ми отримаємо нову кнопку з id #click-me, глобальним CSS-селектором, що відповідає ідентифікатору кнопки та слухачем подій, що буде виводити сповіщення.

У нашому скрипті ми просто приєднуємо вміст за допомогою document.importNode і отримуємо наповнений HTML шаблон, який можна переміщувати зі сторінки на сторінку.

Створюємо шаблон для нашого діалогового вікна

Повернемось до створення елементу діалогового вікна. Нам треба визначити вміст шаблону та відповідні стилі.

<template id="one-dialog">
  <script>
    document.getElementById('launch-dialog').addEventListener('click', () => {
      const wrapper = document.querySelector('.wrapper');
      const closeButton = document.querySelector('button.close');
      const wasFocused = document.activeElement;
      wrapper.classList.add('open');
      closeButton.focus();
      closeButton.addEventListener('click', () => {
        wrapper.classList.remove('open');
        wasFocused.focus();
      });
    });
  </script>
  <style>
    .wrapper {
      opacity: 0;
      transition: visibility 0s, opacity 0.25s ease-in;
    }
    .wrapper:not(.open) {
      visibility: hidden;
    }
    .wrapper.open {
      align-items: center;
      display: flex;
      justify-content: center;
      height: 100vh;
      position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
      opacity: 1;
      visibility: visible;
    }
    .overlay {
      background: rgba(0, 0, 0, 0.8);
      height: 100%;
      position: fixed;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
      width: 100%;
    }
    .dialog {
      background: #ffffff;
      max-width: 600px;
      padding: 1rem;
      position: fixed;
    }
    button {
      all: unset;
      cursor: pointer;
      font-size: 1.25rem;
      position: absolute;
        top: 1rem;
        right: 1rem;
    }
    button:focus {
      border: 2px solid blue;
    }
  </style>
  <div class="wrapper">
  <div class="overlay"></div>
    <div class="dialog" role="dialog" aria-labelledby="title" aria-describedby="content">
      <button class="close" aria-label="Close">✖️</button>
      <h1 id="title">Hello world</h1>
      <div id="content" class="content">
        <p>This is content in the body of our modal</p>
      </div>
    </div>
  </div>
</template>

Наведений фрагмент коду буде основою для нашого діалогового вікна. Якщо коротко, тут є кнопка для закриття, заголовок та деякий вміст. Ми також додали трохи поведінки, щоб візуально перемикати наш діалог (хоча він поки що не доступний). На жаль, стилі та вміст скрипта не обмежені нашим шаблоном і застосовуються до всього документа. Це може бути проблемою, якщо в DOM є більше ніж однин екземпляр шаблону. Згодом ми застосуємо користувацькі елементи та створимо власний елемент, що використовуватиме цей шаблон та інкапсулюватиме логіку.

Створення користувацького елементу

Основу Веб-компонентів складають користувацькі елементи. API customElements дає нам спосіб визначити користувацькі HTML-теги, які можуть бути використані у будь-якому документі, що містить клас.

Думайте про них, як про компоненти React або Angular (наприклад, <MyCard />), але без відповідних залежностей. Нативні користувацькі елементи виглядають таким чином: <my-card></my-card>. Важливо те, що їх можна використовувати у вашому застосунку на React, Angular, Vue, [вставте фреймворки, який цікавить вас зараз] без особливих труднощів.

По суті, користувацький елемент складається з двох частин: імені тега і класу, який розширює вбудований клас HTMLElement. Найпростіша версія нашого користувацького елемента буде виглядати так:

class OneDialog extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<h1>Hello, World!</h1>`;
  }
}

customElements.define('one-dialog', OneDialog);

Зверніть увагу: this посилається на екземпляр елемента.

У прикладі вище ми визначили новий HTML-елемент <one-dialog></one-dialog>, сумісний зі стандартами. Він не робить нічого суттєвого...поки що. Якщо ми використаємо тег <one-dialog> у будь-якому HTML-документі, то створимо новий елемент з <h1> та «Hello, World!».

Нам обов'язково потрібно щось більш надійне. Раніше ми вже розглянули процес створення шаблону для діалогового вікна. Оскільки ми маємо доступ до цього шаблону, використаємо його у користувацькому елементі. Між тегами script ми додали трохи магії. Зараз приберемо його, оскільки ми розмістимо нашу логіку у класі користувацького елементу.

class OneDialog extends HTMLElement {
  connectedCallback() {
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
  }
}

Тепер наш користувацький елемент <one-dialog> визначено і браузеру доручено відобразити вміст HTML -шаблону там, де буде викликано елемент.

Наступним кроком буде переміщення логіки у клас компоненту.

Методи життєвого циклу користувацького елементу

Як в React чи Angular, користувацькі елементи мають методи життєвого циклу. Ви вже трохи познайомились з connectedCallback, який викликається, коли наш елемент додається у DOM.

connectedCallback відокремлений від конструктора елемента. Як відомо, конструктор використовується для налаштування каркасу елемента. А connectedCallback зазвичай використовується для додавання вмісту, налаштування слухачів подій або іншої ініціалізації компонента.

Взагалі, конструктор не можна використовувати для модифікації чи дій з атрибутами елемента за своєю структурою. Якщо нам треба було б створити новий екземпляр нашого діалогу, використовуючи document.createElement, то викликався б конструктор. Користувачі елементу очікуватимуть саме на простий вузол: без атрибутів чи вмісту.

Функція createElement не має параметрів для налаштування елемента, який повертає. Звичайно ж, конструктор не повинен мати можливість змінювати елемент, який він створює, як ми зазначили вище. Модифікувати елемент ми можемо за допомогою connectedCallback.

У стандартних вбудованих елементів стан елемента, зазвичай, залежить від його атрибутів. У нашому прикладі, ми розглянемо один атрибут — [open]. Для цього нам необхідно спостерігати за його змінами, тому знадобиться attributeChangedCallback.

Попри всю складність, код досить простий:

class OneDialog extends HTMLElement {
  static get observedAttributes() {
    return ['open'];
  }
  
  attributeChangedCallback(attrName, oldValue, newValue) {
    if (newValue !== oldValue) {
      this[attrName] = this.hasAttribute(attrName);
    }
  }
  
  connectedCallback() {
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
  }
}

У наведеному прикладі нас турбує лише встановлено атрибут чи ні, а саме його значення нам не потрібне (схоже на атрибут required для полів вводу в HTML5). Коли цей атрибут оновлюється, ми також оновлюємо властивість open. Властивість належить JavaScript-об'єкту, натомість атрибут належить HTMLElement. Методи життєвого циклу допомагають усе синхронізувати.

Ми огортаємо оновлення у метод attributeChangedCallback і в умовному виразі перевіряємо рівність старого та нового значень. Усе для того, щоб запобігти нескінченному циклу всередині нашого застосунку. Пізніше ми створимо гетер та сетер, які зберігатимуть властивість та атрибути синхронізованими, встановлюючи атрибут, коли властивість елемента оновлено. attributeChangedCallback робить протилежне: оновлює властивість коли атрибут змінюється.

Тепер можна використати наш компонент, а наявність атрибуту open вказуватиме, чи буде діалог відкриватися за замовчуванням. Щоб зробити все більш динамічним, ми можемо додати користувацькі гетери та сетери до властивості open:

class OneDialog extends HTMLElement {
  static get boundAttributes() {
    return ['open'];
  }
  
  attributeChangedCallback(attrName, oldValue, newValue) {
    this[attrName] = this.hasAttribute(attrName);
  }
  
  connectedCallback() {
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    this.appendChild(node);
  }
  
  get open() {
    return this.hasAttribute('open');
  }
  
  set open(isOpen) {
    if (isOpen) {
      this.setAttribute('open', true);
    } else {
      this.removeAttribute('open');
    }
  }
}

Наші гетер та сетер забезпечать синхронізацію атрибуту open (для HTML-елемента) та властивості (для об'єкта DOM). Додавши атрибут open, ми встановлюємо element.open як true, і навпаки: встановлення true для element.open зумовить додавання атрибуту open. Такі дії необхідні, щоб переконатися, що стан нашого елемента відповідає його властивостям. Технічно це не обов'язково, але розглядається як хороша практика для створення користувацьких елементів.

Створення абстрактного класу, який підтримує синхронізацію — досить тривіальне завдання, якщо циклічно обходити список спостережуваних атрибутів та використовувати Object.defineProperty. Але шаблонний код тут неминучий.

class AbstractClass extends HTMLElement {
  constructor() {
    super();
    // Перевіряємо чи визначено observedAttributes та чи має довжину
    if (this.constructor.observedAttributes && this.constructor.observedAttributes.length) {
      // Циклічно обходимо атрибути 
      this.constructor.observedAttributes.forEach(attribute => {
        // Динамічно визначаємо гетери/сетери для властивості
        Object.defineProperty(this, attribute, {
          get() { return this.getAttribute(attribute); },
          set(attrValue) {
            if (attrValue) {
              this.setAttribute(attribute, attrValue);
            } else {
              this.removeAttribute(attribute);
            }
          }
        }
      });
    }
  }
}

// Замість прямого наслідування HTMLElement directly, тепер ми можемо розширити наш AbstractClass
class SomeElement extends AbstractClass { /** Деякий код */ }

customElements.define('some-element', SomeElement);

Наведений код не ідеальний. Тут ми не враховуємо особливість open, який не має присвоєного значення, а залежить лише від присутності атрибута. Покращена версія виходить за рамки статті.

Тепер, коли ми знаємо чи відкрите наше діалогове вікно, додамо деяку логіку, щоб насправді відкривати/закривати його:

class OneDialog extends HTMLElement { 
 /** Деякий код */
 constructor() {
   super();
   this.close = this.close.bind(this);
 }
 
 set open(isOpen) {
   this.querySelector('.wrapper').classList.toggle('open', isOpen);
   this.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
   if (isOpen) {
     this._wasFocused = document.activeElement;
     this.setAttribute('open', '');
     document.addEventListener('keydown', this._watchEscape);
     this.focus();
     this.querySelector('button').focus();
   } else {
     this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
     this.removeAttribute('open');
     document.removeEventListener('keydown', this._watchEscape);
     this.close();
   }
 }
 
 close() {
   if (this.open !== false) {
     this.open = false;
   }
   const closeEvent = new CustomEvent('dialog-closed');
   this.dispatchEvent(closeEvent);
 }
 
 _watchEscape(event) {
   if (event.key === 'Escape') {
       this.close();   
   }
 }
}

Тут багато чого відбувається, тому оглянемо код детальніше. Спершу ми звертаємось до нашого wrapper, викликаємо toggle для класу .open, що залежить від значення isOpen. Не забуваємо про доступність, тому повторюємо те ж саме і для атрибута aria-hidden.

Якщо діалогове вікно відкрито, ми хочемо зберегти посилання на раніше обраний елемент. Усе повинно враховувати стандарти доступності. Ми також додаємо слухача watchEscape для document, який ми зв'язали із this елемента у конструкторі (подібно до того, як React обробляє виклики методів у компонентах класу).

Такі дії необхідні не тільки, щоб переконатися у правильності зв'язування this.close, а й тому що Function.prototype.bind повертає екземпляр функції, яка в момент виклику має певне присвоєне значення this, а також задану послідовність аргументів, що передують будь-яким аргументам, переданим під час виклику нової функції. Після того, як ми зберегли посилання на зв'язаний метод у конструкторі, можемо видалити event, коли діалогове вікно буде закрито. Наприкінці ми створюємо фокус на нашому елементі, а також на потрібному елементі у shadow root.

Ми також використовуємо корисний метод для закриття діалогового вікна, який викликає dispatchEvent для нашого користувацького event, та виводить відповідне повідомлення.

Якщо елемент закрито (тобто !open), треба переконатися, що властивість this._wasFocused визначена та містить метод focus, який ми викликаємо, щоб повернути фокус користувача назад до звичайного DOM. Потім ми видаляємо слухача подій, щоб уникнути витоків пам'яті.

Для очищення вам знадобиться ще один метод життєвого циклу — disconnectedCallback. Цей метод — повна протилежність connectedCallback: він викликається після видалення елемента з DOM та дозволяє нам очистити будь-яких слухачів подій або MutationObservers, приєднаних до нашого елемента.

У нас є ще декілька слухачів подій, які необхідно зв'язати:

class OneDialog extends HTMLElement {
  /** Деякий код */
  
  connectedCallback() {
    this.querySelector('button').addEventListener('click', this.close);
    this.querySelector('.overlay').addEventListener('click', this.close);
  }
  
  disconnectedCallback() {
    this.querySelector('button').removeEventListener('click', this.close);
    this.querySelector('.overlay').removeEventListener('click', this.close);
  }  
}

Отримали функціональний, елемент діалогового вікна, що відповідає вимогам доступності. Але ми могли б ще трохи покращити наш код, додавши захоплення фокусу на елементі.

Існує ще один метод життєвого циклу, який не застосовується до нашого елементу — adoptedCallback. Він спрацьовує, коли елемент додається до іншої частини DOM.

У наступному прикладі можна побачити як наш шаблонний елемент використовується стандартним елементом <one-dialog>.

Інша справа: непрезентаційні компоненти

<one-template>, який ми створювали, — типовий користувацький елемент з розміткою та поведінкою, що вставляється у документ. Однак, не усі елементи рендеряться. В екосистемі React компоненти часто використовуються для управління станом застосунку або деяким іншим важливим функціоналом, на зразок <Provider /> у react-redux.

Уявімо, що наш компонент — частина серії діалогових вікон. Якщо одне з вікон закрите, інше повинно бути відкритим. Ми можемо створити компонент-обгортку, яка відстежуватиме подію dialog-closed.

class DialogWorkflow extends HTMLElement {
  connectedCallback() {
    this._onDialogClosed = this._onDialogClosed.bind(this);
    this.addEventListener('dialog-closed', this._onDialogClosed);
  }

  get dialogs() {
    return Array.from(this.querySelectorAll('one-dialog'));
  }

  _onDialogClosed(event) {
    const dialogClosed = event.target;
    const nextIndex = this.dialogs.indexOf(dialogClosed);
    if (nextIndex !== -1) {
      this.dialogs[nextIndex].open = true;
    }
  }
}

Такий елемент немає ніякої візуальної логіки, але служить контролером для стану застосунку. Додавши трохи зусиль, ми змогли б відтворити управління станом на зразок Redux, використавши звичайний користувацький елемент, який міг би управляти станом усього застосунку так само.

Тепер у нас є досить непогане розуміння користувацьких елементів. Але досі є деякі проблеми.

Зверніть увагу, що нам довелося додати CSS для зміни стилю діалогової кнопки, тому що стилі нашого елемента заважають решті сторінки. Хоча ми могли б використовувати методології, на зразок BEM, щоб наші стилі не створювали конфліктів з іншими компонентами, є більш зручний спосіб їх ізоляції, про який ми поговоримо далі.

До того ж, нам необхідно визначити новий шаблон для кожного компонента аби знайти спосіб перемикати шаблони для нашого вікна. На кожній сторінці може бути лише один тип діалогового вікна. Тож нам потрібен спосіб динамічно змінювати контент або шаблони.

Інкапсулюємо стилі та логіку за допомогою Shadow DOM

Якщо ми ще раз поглянемо на наше діалогове вікно, то побачимо, що воно має певну форму, структуру та поведінку, однак повністю залежить від зовнішнього DOM. Тож необхідно, щоб його користувачі розуміли загальну форму та структуру елементу, до того ж могли стилізувати його (так, щоб глобальні стилі документу були перевизначені). Оскільки наш елемент залежить від вмісту шаблонного елементу з id one-dialog, кожен документ матиме лише один екземпляр вікна.

Насправді наші обмеження не такі вже й критичні. Користувачі, що добре розбираються у внутрішній організації елементу, з легкістю можуть використовувати його, створивши власний <template> та визначивши бажаний вміст та стилі. Однак, нам може знадобитись особливий дизайн та певні структурні обмеження, щоб відповідати кращим практикам. Саме тут на допомогу приходить shadow DOM.

Що таке shadow DOM?

Раніше ми вже сказали, що shadow DOM здатний ізолювати CSS і JavaScript, майже як <iframe>. Подібно до <iframe>, селектори та стилі всередині вузла shadow DOM інкапсульовані від зовнішньої логіки. Є декілька винятків, які наслідуються від батьківського документа (сімейство шрифтів, їх розмір (наприклад, rem)), які можна перевизначити.

На відміну від <iframe>, усі shadow roots досі існують у документі, тому код можна розмістити всередині контексту, і при цьому не турбуватися про конфлікти з іншими стилями чи селекторами.

Додаємо shadow DOM до нашого діалогового вікна

Щоб додати shadow root (базовий фрагмент вузла/документа shadow tree), необхідно викликати метод attachShadow:

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
}

Передавши у attachShadow mode: 'open', ми вказуємо нашому елементу зберігати посилання на shadow root у властивості element.shadowRoot. attachShadow завжди повертає посилання на shadow root, але для нашого прикладу це не потрібно.

Якщо б ми викликали метод з параметром mode: 'closed', посилання на елемент не було б збережено, і нам довелося б вигадати власний спосіб зберігати та отримувати властивість, використовуючи WeakMap або Object (встановивши сам вузол як ключ, а shadow root як значення).

const shadowRoots = new WeakMap();

class ClosedRoot extends HTMLElement {
  constructor() {
    super();
    const shadowRoot = this.attachShadow({ mode: 'closed' });
    shadowRoots.set(this, shadowRoot);
  }

  connectedCallback() {
    const shadowRoot = shadowRoots.get(this);
    shadowRoot.innerHTML = `<h1>Hello from a closed shadow root!</h1>`;
  }
}

Ми могли б також зберегти посилання на shadow root в елементі, використовуючи Symbol або інший ключ, щоб зробити shadow root приватним.

Взагалі закритий режим для shadow roots існує і для нативних елементів (на зразок, <audio> або <video>). Крім того, у нас може не бути доступу до об'єкта shadowRoots під час юніт-тестування елементів. Тобто ми не зможемо відстежувати зміни всередині елементу зі зміною архітектури бібліотеки.

Можливо, існують «законні» варіанти використання закритих shadow roots, але їх небагато, тому ми обрали відкритий shadow root для нашого прикладу.

Після реалізації відкритого shadow root, ви можете помітити, що наш елемент не працює.

Так відбувається тому, що ми працювали з нашою логікою у стандартному DOM (називатимемо його light DOM). Тепер, коли ми приєднали shadow DOM, звичайний light DOM не може рендеритись. Виправимо це, перемістивши все до shadow DOM:

class OneDialog extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.close = this.close.bind(this);
  }
  
  connectedCallback() {
    const { shadowRoot } = this;
    const template = document.getElementById('one-dialog');
    const node = document.importNode(template.content, true);
    shadowRoot.appendChild(node);
    
    shadowRoot.querySelector('button').addEventListener('click', this.close);
    shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
    this.open = this.open;
  }

  disconnectedCallback() {
    this.shadowRoot.querySelector('button').removeEventListener('click', this.close);
    this.shadowRoot.querySelector('.overlay').removeEventListener('click', this.close);
  }
  
  set open(isOpen) {
    const { shadowRoot } = this;
    shadowRoot.querySelector('.wrapper').classList.toggle('open', isOpen);
    shadowRoot.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
    if (isOpen) {
      this._wasFocused = document.activeElement;
      this.setAttribute('open', '');
      document.addEventListener('keydown', this._watchEscape);
      this.focus();
      shadowRoot.querySelector('button').focus();
    } else {
      this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
      this.removeAttribute('open');
      document.removeEventListener('keydown', this._watchEscape);
    }
  }
  
  close() {
    this.open = false;
  }
  
  _watchEscape(event) {
    if (event.key === 'Escape') {
        this.close();  
    }
  }
}

customElements.define('one-dialog', OneDialog);

Зміни мінімальні, але вплив мають суттєвий. Усі наші селектори (стилю також) інкапсульовані. Наприклад, шаблон нашого діалогового вікна має одну кнопку, тому наш CSS впливає лише на button { ... }, і ці зміни не чіпатимуть light DOM.

Але ми, як і раніше, покладаємось на зовнішній шаблон. Змінимо це, видаливши розмітку з шаблону та вставивши її у shadow root за допомогою innerHTML.

Додаємо контент з light DOM

Специфікація shadow DOM передбачає способи рендерингу контенту, який знаходиться поза shadow root. В AngularJS схожий механізм у ng-transclude, а в React — props.children. Веб-компоненти для цього використовують елемент <slot>.

Розглянемо простий приклад:

  <span>world <!-- це буде розміщено у slot --></span>
  <#shadow-root><!-- деякий код -->
    <p>Hello <slot></slot></p>
  </#shadow-root>
</div>

Наш shadow root може мати будь-яку кількість слотів, які можна відрізнити за допомогою атрибута name. Перший слот без атрибута всередині shadow root — слот за замовчуванням, тому увесь вміст буде відображатися у цьому вузлі, якщо не зазначено інше. Для нашого діалогового вікна потрібні два слоти: заголовок і вміст (який ми зробимо за замовчуванням).

Тепер ми змінюємо частину HTML, яка відповідає за вікно, і бачимо результат. Будь-який вміст light DOM розміщується у слоті та привласнюється йому. Такий вміст залишається у light DOM, хоч він і рендериться так, ніби знаходиться у shadow DOM. Так користувач може стилізувати та контролювати елементи.

Автор shadow root може стилізувати вміст light DOM за допомогою псевдоселектора ::slotted(), але дуже обмежено. Однак всередині слотів працюватимуть лише прості селектори. Тобто ми не зможемо стилізувати елемент <strong> всередині <p> у DOM з попереднього прикладу.

Шукаємо компроміс

Тепер наше діалогове вікно в непоганому стані: має інкапсульовану, семантичну розмітку, стилі та поведінку. Однак деяким користувачам може знадобитися власна розмітка. На щастя, ми можемо знайти компроміс, об'єднавши описані способи, і визначити зовнішній шаблон.

Для цього ми дозволимо кожному екземплярові нашого компонента посилатися на необов'язковий ідентифікатор шаблону. Спочатку необхідно визначити гетер та сетер для властивості template компонента.

get template() {
  return this.getAttribute('template');
}

set template(template) {
  if (template) {
    this.setAttribute('template', template);
  } else {
    this.removeAttribute('template');
  }
  this.render();
}

Тут відбувається майже те ж саме, що і з властивістю open, коли ми прив'язували її до відповідного атрибуту. Але в кінці ми визначаємо новий метод для нашого компонента: render. Ми збираємось використати його для вставки вмісту нашого shadow DOM та для видалення відповідної поведінки з connectedCallback. Викликатимемо render, коли наш елемент буде приєднано.

connectedCallback() {
  this.render();
}

render() {
  const { shadowRoot, template } = this;
  const templateNode = document.getElementById(template);
  shadowRoot.innerHTML = '';
  if (templateNode) {
    const content = document.importNode(templateNode.content, true);
    shadowRoot.appendChild(content);
  } else {
    shadowRoot.innerHTML = `<!-- template text -->`;
  }
  shadowRoot.querySelector('button').addEventListener('click', this.close);
  shadowRoot.querySelector('.overlay').addEventListener('click', this.close);
  this.open = this.open;
}

Наше вікно має базові стилі, проте користувачі тепер можуть визначати новий шаблон для кожного екземпляру. Ми можемо використовувати attributeChangedCallback, щоб компонент оновлювався відповідно до встановленого шаблону.

static get observedAttributes() { return ['open', 'template']; }

attributeChangedCallback(attrName, oldValue, newValue) {
  if (newValue !== oldValue) {
    switch (attrName) {
      /** Булеві атрибути */
      case 'open':
        this[attrName] = this.hasAttribute(attrName);
        break;
      /** Атрибути значення */
      case 'template':
        this[attrName] = newValue
        break;
    }
  }
}

На демо можна побачити, як зі зміною атрибуту template для елемента <one-dialog> змінюється дизайн.

Способи стилізації shadow DOM

Зараз єдиний надійний спосіб додати стилі до shadow DOM — визначити їх у <style> за допомогою inner HTML. Такий спосіб працює майже завжди, тому що браузери за можливістю будуть дублювати стилі у всіх цих елементах. Маємо зайві витрати пам'яті, але тут вони не суттєві.

Всередині тегу стилю, ми можемо використовувати користувацькі властивості CSS, щоб передбачити API для стилізації компонентів. Користувацькі компоненти можуть впливати на вміст shadow node.

Вас може цікавити, чи можемо ми використовувати тег <link> всередині shadow root. Насправді можемо. Але якщо ми хочемо повторно використати елемент в інших застосунках, може виникнути проблема, тому що CSS-файл зберігатиметься в різних місцях. Однак, якщо ви точно визначились з місцем для ваших стилів, вам підійде такий спосіб. Те ж саме і для @import.

Варто також помітити, що не всім компонентам потрібна така стилізація. З CSS-селекторами :host та :host-context ми можемо легко визначити більш примітивні компоненти як блокові та дозволити користувачам застосовувати стилі на зразок кольору фону, налаштувань шрифтів тощо.

Користувацькі властивості CSS

Одна з переваг користувацьких властивостей CSS (або CSS-змінних) — їх здатність виходити за межі shadow DOM. Так задумано, щоб автори компонентів мали поверхню для створення тем та стилів іззовні. Важливо помітити, що через каскадність CSS, зміни користувацьких властивостей всередині shadow root не мають такого ж впливу.

Розглянемо, як змінні впливатимуть на відображений контент. Після цього можна буде подивитися на стилі у innerHTML shadow DOM і побачити, як shadow DOM може визначити власні властивості, що не впливають на light DOM.

Constructible stylesheets

На момент написання статті для більш модульних стилів у shadow DOM та light DOM рекомендується використовувати constructible stylesheets, які вже підтримуються Chrome 73 та позитивно сприйняті Mozilla.

Ця фіча дозволяє визначати стилі у JavaScript- файлі подібно до звичайного CSS та поширювати ці стилі між різними вузлами. Отже, для декількох shadow roots та документу може використовуватись одні стилі.

const everythingTomato = new CSSStyleSheet();
everythingTomato.replace('* { color: tomato; }');

document.adoptedStyleSheets = [everythingTomato];

class SomeCompoent extends HTMLElement {
  constructor() {
    super();
    this.adoptedStyleSheets = [everythingTomato];
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `<h1>CSS colors are fun</h1>`;
  }
}

У прикладі вище стилі everythingTomato будуть одночасно застосовуватися до shadow root і до тіла документа. Така фіча може бути дуже корисною для команд, які створюють системи дизайну та компоненти, які призначені для спільного використання в різних застосунках і фреймворках.

У цьому демо можна побачити базовий приклад використання constructble stylesheets та потужні можливості такої фічі.

'use strict';

/** Demo code */
const bgPurple = new CSSStyleSheet();
const everythingTomato = new CSSStyleSheet();

bgPurple.replace(`h1 { background: purple; }`);
everythingTomato.replace(`* { color: tomato; }`);


document.adoptedStyleSheets = [everythingTomato, bgPurple];

document.querySelector('form').addEventListener('submit', event => {
  event.preventDefault();
  const color = document.querySelector('[name="selectTextColor"]').value || '#000000';
  const fontFamily = document.querySelector('[name="font"]').value || 'Times New Roman';
  
  everythingTomato.replace(`
    * {
      color: ${color};
      font-family: "${fontFamily}";
    }
  `).then(console.log);
});


class TestEl extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.adoptedStyleSheets = [bgPurple];
  }
  
  connectedCallback() {
    this.shadowRoot.innerHTML = `<h1>Shadow DOM, y'all</h1>`;
    
    setTimeout(() => {
      this.shadowRoot.adoptedStyleSheets = [];
    }, 3000)
  }
}

customElements.define('test-el', TestEl);

const c = new TestEl
document.body.appendChild(c);

Тут ми створили дві таблиці стилів та приєднали їх до документа та користувацького елемента. Через три секунди ми видалили одну таблицю для shadow root. Однак, протягом цих трьох секунд, документ і shadow DOM поділяють ту ж саму таблицю стилів. У демо ми використовуємо поліфіл, тому насправді тут два елементи стилю.

У демо також є форма для демонстрації асинхронної зміни правил стилю. Така фіча може стати потужним помічником для тих, хто створює системи дизайну, які охоплюють декілька фреймворків, або для тих, хто створює теми для свого веб-сайту.

Як варіант, із adoptedStyleSheets можна використовувати CSS Modules. Якщо реалізувати це у нашій формі, ми зможемо імпортувати CSS як модуль, подібний до модулів ECMAScript:

import styles './styles.css';

class SomeCompoent extends HTMLElement {
  constructor() {
    super();
    this.adoptedStyleSheets = [styles];
  }
}

::part() та ::theme()

Розглянемо ще деякі фічі Веб-компонентів. ::part() дозволяє визначати частини користувацького елементу, що мають поверхню для стилізації.

class SomeOtherComponent extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>h1 { color: rebeccapurple; }</style>
      <h1>Web components are <span part="description">AWESOME</span></h1>
    `;
  }
}
    
customElements.define('other-component', SomeOtherComponent);

У нашому глобальному CSS, ми можемо вказати на будь-який елемент з description, застосувавши CSS-селектор ::part().

other-component::part(description) {
  color: tomato;
}

У прикладі вище основне повідомлення тега <h1> буде мати інший колір, ніж частина description. Таким чином автори користувацьких елементів зможуть передбачити API для стилів їх компонентів та контролювати потрібні частини.

Різниця між ::part() та ::theme() у тому, що ::part() спеціально обраний, тоді як ::theme () може бути вкладений на будь-якому рівні. Вказаний нижче код має схожий ефект, але діятиме також і для будь-якого іншого елемента з part="description" у дереві документу.

:root::theme(description) {
  color: tomato;
}

Як і constructible stylesheets, ::part() доступний у Chrome 73.

Тепер наш компонент більш-менш завершено: він має власну розмітку, стилі (без зовнішніх залежностей) та поведінку. Його вже можна використовувати у проектах з будь-яким фреймворком, тому що ми створювали все відповідно до специфікації браузера, без сторонніх API.

Нам залишилось поговорити про високорівневі інструменти та способи інтеграції з популярними фреймворками.

Як щодо взаємодії з фреймворками?

Наш компонент чудово працює самостійно, а також майже з будь-яким фреймворком (звичайно ж, якщо не вимкнено JavaScript). Angular та Vue чудово взаємодіють з Веб-компонентами: ці фреймворки були розроблені згідно з веб-стандартами. З React усе трохи складніше, але також можливо.

Angular

Спершу, розглянемо, як Angular обробляє користувацькі елементи. За замовчуванням Angular викидає помилку шаблону щоразу, коли натрапляє на елемент, який він не розпізнає (тобто якщо це не елементи браузера за замовчуванням або будь-які компоненти, визначені Angular). Таку поведінку можна змінити використанням CUSTOM_ELEMENTS_SCHEMA.

Як вказано у документації Angular, CUSTOMELEMENTSSCHEMA дозволяє NgModule містити:

  • не Angular елементи з - у назві;
  • властивості елементів з - у назві. Дефіс у назві користувацьких елементів зумовлено угодою про іменування.

Використовувати таку схему так само просто, як і додати її до модуля:

import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';

@NgModule({
 /** деякий код */
  schemas: [ CUSTOM_ELEMENTS_SCHEMA ]
})
export class MyModuleAllowsCustomElements {}

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

<one-dialog [open]="isDialogOpen" (dialog-closed)="dialogClosed($event)">
  <span slot="heading">Heading text</span>
  <div>
    <p>Body copy</p>
  </div>
</one-dialog>

Vue

Vue навіть краще сумісний з Веб-компонентами, ніж Angular. Йому не потрібна спеціальна конфігурація. Після реєстрації елемента його можна використовувати з синтаксисом шаблонів Vue за замовчуванням:

<one-dialog v-bind:open="isDialogOpen" v-on:dialog-closed="dialogClosed">
  <span slot="heading">Heading text</span>
  <div>
    <p>Body copy</p>
  </div>
</one-dialog>

Тут є одне застереження. Якщо ми хочемо використовувати щось на зразок реактивних форм або [(ng-model)] в Angular чи v-model у Vue для користувацького елемента з формою, нам необхідні додаткові налаштування, які не охоплюються у нашій статті.

React

У React все трохи складніше. React virtual DOM приймає дерево JSX і відображає його як великий об'єкт. Тому замість того, щоб прямо змінювати HTML-атрибути (як це робить Angular чи Vue) React використовує синтаксис об'єктів, аби відстежувати зміни у DOM і оновлювати їх партіями. У більшості випадків усе працює добре. Атрибут open нашого діалогу прив'язаний до його властивості і буде чудово реагувати на зміну props.

Пастка криється у CustomEvent, коли наше діалогове вікно закривається. React реалізує набір слухачів подій з їх синтетичною системою подій. На жаль, це означає, що такі методи onDialogClosed не будуть додавати слухачів подій до нашого компонента, тому нам потрібен інший спосіб.

Щоб обійти такий момент у React, ми можемо використати DOM refs. У наведеному коді ми можемо прямо посилатися на HTML-вузол. Коду забагато, але працює добре:

import React, { Component, createRef } from 'react';

export default class MyComponent extends Component {
  constructor(props) {
    super(props);
    // Створення ref
    this.dialog = createRef();
    // Прив'язуємо наш метод до екземпляру
    this.onDialogClosed = this.onDialogClosed.bind(this);

    this.state = {
      open: false
    };
  }

  componentDidMount() {
    // Додаємо слухача подій
    this.dialog.current.addEventListener('dialog-closed', this.onDialogClosed);
  }

  componentWillUnmount() {
    // Видаляємо слухача подій
    this.dialog.current.removeEventListener('dialog-closed', this.onDialogClosed);
  }

  onDialogClosed(event) { /** Деякий код **/ }

  render() {
    return <div>
      <one-dialog open={this.state.open} ref={this.dialog}>
        <span slot="heading">Heading text</span>
        <div>
          <p>Body copy</p>
        </div>
      </one-dialog>
    </div>
  }
}

Або можна використовувати stateless функціональні компоненти та хуки:

import React, { useState, useEffect, useRef } from 'react';

export default function MyComponent(props) {
  const [ dialogOpen, setDialogOpen ] = useState(false);
  const oneDialog = useRef(null);
  const onDialogClosed = event => console.log(event);

  useEffect(() => {
    oneDialog.current.addEventListener('dialog-closed', onDialogClosed);
    return () => oneDialog.current.removeEventListener('dialog-closed', onDialogClosed)
  });

  return <div>
      <button onClick={() => setDialogOpen(true)}>Open dialog</button>
      <one-dialog ref={oneDialog} open={dialogOpen}>
        <span slot="heading">Heading text</span>
        <div>
          <p>Body copy</p>
        </div>
      </one-dialog>
    </div>
}

Такий підхід непоганий, але повторне використання цього компоненту буде громіздким. На щастя, ми можемо експортувати компонент React за замовчуванням, який огортає наш користувацький елемент, у ті ж способи.

import React, { Component, createRef } from 'react';
import PropTypes from 'prop-types';

export default class OneDialog extends Component {
  constructor(props) {
    super(props);
    // Створення ref
    this.dialog = createRef();
    // Bind our method to the instance
    this.onDialogClosed = this.onDialogClosed.bind(this);
  }

  componentDidMount() {
    // Додаємо слухача подій
    this.dialog.current.addEventListener('dialog-closed', this.onDialogClosed);
  }

  componentWillUnmount() {
    // Видаляємо слухача подій
    this.dialog.current.removeEventListener('dialog-closed', this.onDialogClosed);
  }

  onDialogClosed(event) {
    // Перевіряємо наявність prop перед викликом
    if (this.props.onDialogClosed) {
      this.props.onDialogClosed(event);
    }
  }

  render() {
    const { children, onDialogClosed, ...props } = this.props;
    return <one-dialog {...props} ref={this.dialog}>
      {children}
    </one-dialog>
  }
}

OneDialog.propTypes = {
  children: children: PropTypes.oneOfType([
      PropTypes.arrayOf(PropTypes.node),
      PropTypes.node
  ]).isRequired,
  onDialogClosed: PropTypes.func
};

...або знову ж, використовуючи stateless функціональний компонент:

import React, { useRef, useEffect } from 'react';
import PropTypes from 'prop-types';

export default function OneDialog(props) {
  const { children, onDialogClosed, ...restProps } = props;
  const oneDialog = useRef(null);
  
  useEffect(() => {
    onDialogClosed ? oneDialog.current.addEventListener('dialog-closed', onDialogClosed) : null;
    return () => {
      onDialogClosed ? oneDialog.current.removeEventListener('dialog-closed', onDialogClosed) : null; 
    };
  });

  return <one-dialog ref={oneDialog} {...restProps}>{children}</one-dialog>
}

Тепер ми нативно можемо використовувати наше діалогове вікно у React, при цьому застосовувати однакове API для всіх наших застосунків.

import React, { useState } from 'react';
import OneDialog from './OneDialog';

export default function MyComponent(props) {
  const [open, setOpen] = useState(false);
  return <div>
    <button onClick={() => setOpen(true)}>Open dialog</button>
    <OneDialog open={open} onDialogClosed={() => setOpen(false)}>
      <span slot="heading">Heading text</span>
      <div>
        <p>Body copy</p>
      </div>
    </OneDialog>
  </div>
}

Просунуті інструменти

Якщо пошукаєте в npm, ви знайдете безліч інструментів для створення реактивних елементів. Сьогодні найбільш популярні: lit-html від Polymer та більш направлений на Веб-компоненти LitElement.

LitElement — базовий клас користувацьких елементів, який передбачає API для створення того, що ми розглядали у статті. Його можна без збірки запустити у браузері. Якщо ж вам подобаються інструменти на зразок декораторів, ви також знайдете там відповідні утиліти.

Перед тим, як розглянемо lit або LitElement у дії, приділимо трохи часу знайомству з tagged template literals. Це спеціальний вид функції, яка викликається для рядків літералу шаблону у JavaScript. Передаємо туди масив рядків та колекцію інтерпольованих значень і отримуємо будь-який потрібний тип.

function tag(strings, ...values) {
  console.log({ strings, values });
  return true;
}
const who = 'world';

tag`hello ${who}`; 
/** виведе { strings: ['hello ', ''], values: ['world'] } і поверне true **/

LitElement дозволяє нам динамічно оновлювати вміст переданого масиву значень, тому при оновленні властивості, елемент викликає render, і остаточний DOM повторно відображається.

import { LitElement, html } from 'lit-element';

class SomeComponent {
  static get properties() {
    return { 
      now: { type: String }
    };
  }

  connectedCallback() {
    // Не забудьте викликати super
    super.connectedCallback();
    this.interval = window.setInterval(() => {
      this.now = Date.now();
    });
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    window.clearInterval(this.interval);
  }

  render() {
    return html`<h1>It is ${this.now}</h1>`;
  }
}

customElements.define('some-component', SomeComponent);

Ви можете помітити: для того, щоб LitElement відстежував властивість, її треба визначити за допомогою гетера static properties. Такий API вказує базовому класу викликати render, якщо були зміни у властивостях компонента. У той час як render оновить лише ті вузли, які цього потребують.

Використання LitElement для нашого елемента матиме такий вигляд:

Існує декілька реалізацій lit-html. Наприклад, Haunted — бібліотека хуків React для Веб-компонентів дозволяє використовувати віртуальні компоненти, в основі яких lit-html.

Зрештою, більшість сучасних інструментів для Веб-компонентів є лише варіаціями LitElement: базовий клас, що абстрагує спільну логіку компонентів. До таких інструментів належать Stencil, SkateJS, Angular Elements та Polymer.

Висновок

Стандарти веб-компонентів продовжують розвиватися, а нові фічі починають підтримуватись браузерами на постійній основі. Скоро розробники Веб-компонентів матимуть API для взаємодії з веб-формами на високому рівні: імпорт модулів нативного HTML та CSS, нативні екземпляри шаблонів, оновлення елементів управління та багато іншого (можна відстежити за посиланням).

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

Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 6.2K
Приєднався: 7 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація