Собственная веб-платформа догнала фреймворки внешнего интерфейса и постепенно сделает их устаревшими.

Помните, когда document.querySelector впервые получил широкую поддержку браузерами и начал прекращать повсеместное распространение jQuery? Наконец, это дало нам возможность делать изначально то, что jQuery предоставляло годами: простой выбор элементов DOM. Я считаю, что то же самое скоро произойдет с интерфейсными фреймворками, такими как Angular и React.

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

Это скоро изменится.

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

Первоначально представленные в 2011 году веб-компоненты представляют собой набор функций, которые позволяют создавать повторно используемые компоненты, используя только HTML, CSS и JavaScript. Это означает, что вы можете создавать свои компоненты без использования таких фреймворков, как React или Angular. Более того, все эти компоненты можно легко интегрировать в эти фреймворки.

Впервые в истории мы можем создавать повторно используемые компоненты, которые могут работать в любом современном браузере, используя только HTML, CSS и JavaScript. Веб-компоненты теперь изначально поддерживаются в последних версиях Chrome, Safari, Firefox и Opera для ПК, Safari на iOS и Chrome на Android.

Edge будет предлагать поддержку в следующей версии 19. Для старых браузеров есть полифил, который позволяет использовать веб-компоненты вплоть до IE11.

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

Создайте свои собственные настраиваемые теги HTML, которые наследуют все свойства элементов HTML, которые они расширяют, и которые вы можете использовать в любом поддерживающем браузере, просто импортировав сценарий. Весь HTML, CSS и JavaScript, определенные внутри компонента, полностью привязаны к самому компоненту.

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

Давайте посмотрим на основные функции веб-компонентов.

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

Пользовательские элементы - это просто определенные пользователем элементы HTML. Они определены с помощью CustomElementRegistry. Чтобы зарегистрировать новый элемент, получите экземпляр реестра через window.customElements и вызовите его метод define:

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

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

<my-element></my-element>

Тире (-) в имени является обязательным, чтобы избежать конфликтов имени с любыми собственными элементами HTML.

Конструктор MyElement должен быть классом ES6, что прискорбно, поскольку классы JavaScript (пока) не похожи на традиционные классы ООП и могут сбивать с толку. Кроме того, если будет разрешено Object, то также можно будет использовать Proxy, что обеспечит простую привязку данных для настраиваемых элементов. Однако это ограничение необходимо для включения расширения собственных HTML-элементов и гарантии того, что ваш элемент наследует весь DOM API.

Напишем класс для нашего настраиваемого элемента:

class MyElement extends HTMLElement {
  constructor() {
    super();
  }
  connectedCallback() {
    // here the element has been inserted into the DOM
  }
}

Класс для нашего настраиваемого элемента - это обычный класс JavaScript, расширяющий собственный HTMLElement. В дополнение к конструктору у него есть метод connectedCallback, который вызывается, когда элемент вставлен в дерево DOM. Вы можете сравнить это с componentDidMount методом React.

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

Разница между constructor и connectedCallback элемента заключается в том, что конструктор вызывается при создании элемента (например, при вызове document.createElement), а connectedCallback вызывается, когда элемент фактически вставлен в DOM, например, когда документ, в котором объявлено, что он был проанализирован или был добавлен с помощью document.body.appendChild.

Вы также можете создать элемент, получив ссылку на его конструктор с помощью вызова customElements.get('my-element'), при условии, что он уже был зарегистрирован в customElements.define(). Затем вы можете создать экземпляр своего элемента с new element() вместо document.createElement():

customElements.define('my-element', class extends HTMLElement {...});
...
const el = customElements.get('my-element');
const myElement = new el();  // same as document.createElement('my-element');
document.body.appendChild(myElement);

Аналог connectedCallback - disconnectedCallback, который вызывается, когда элемент удаляется из DOM. Этот метод позволит вам выполнить любую необходимую работу по очистке, но имейте в виду, что этот метод не будет вызываться, например, когда пользователь закрывает браузер или вкладку браузера.

Также существует adoptedCallback, который вызывается, когда элемент внедряется в документ, вызывая document.adoptNode(element). До сих пор я никогда не встречал варианта использования этого обратного вызова.

Еще один полезный метод жизненного цикла - attributeChangedCallback. Это будет вызываться всякий раз, когда изменяется атрибут, добавленный в массив observedAttributes. Метод вызывается с именем атрибута, его старым и новым значением:

class MyElement extends HTMLElement {
  static get observedAttributes() {
    return ['foo', 'bar'];
  }
  attributeChangedCallback(attr, oldVal, newVal) {
    switch(attr) {
      case 'foo':
        // do something with 'foo' attribute
      case 'bar':
        // do something with 'bar' attribute
    }
  }
}

Этот обратный вызов будет вызываться только для атрибутов, которые присутствуют в массиве observedAttributes, в данном случае foo и bar. Для любого другого изменяющегося атрибута обратный вызов не будет вызываться.

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

Порядок методов жизненного цикла

Порядок выполнения методов жизненного цикла:

constructor -> attributeChangedCallback -> connectedCallback

Почему attributeChangedCallback будет выполняться раньше connectedCallback?

Напомним, что основное назначение атрибутов веб-компонентов - это начальная настройка. Это означает, что эта конфигурация должна быть доступна, когда компонент вставлен в DOM, поэтому attributeChangedCallback необходимо вызывать перед connectedCallback.

Это означает, что если вам нужно настроить какие-либо узлы внутри теневой DOM на основе значений определенных атрибутов, вам нужно ссылаться на них уже в constructor, а не в connectedCallback.

Например, если у вас есть элемент с id="container" внутри вашего компонента, и вам нужно придать этому элементу серый фон всякий раз, когда наблюдаемый атрибут отключен, укажите ссылку на этот элемент уже в constructor, чтобы он был доступен в attributeChangedCallback:

constructor() {
  this.container = this.shadowRoot.querySelector('#container');
}
attributeChangedCallback(attr, oldVal, newVal) {
  if(attr === 'disabled') {
    if(this.hasAttribute('disabled') {
      this.container.style.background = '#808080';
    }
    else {
      this.container.style.background = '#ffffff';
    }
  }
}

Если бы вы подождали connectedCallback, чтобы создать this.container, тогда он еще не будет доступен при первом вызове attributeChangedCallback. Поэтому, хотя вам следует отложить настройку вашего компонента до connectedCallback, насколько это возможно, в данном случае это невозможно.

Также важно понимать, что вы можете использовать веб-компонент до, который был зарегистрирован в customElements.define(). Когда элемент присутствует в модели DOM или вставлен в нее, но (еще) не зарегистрирован, он будет экземпляром HTMLUnknownElement. Браузер будет рассматривать любой незнакомый ему элемент HTML как таковой, и вы можете взаимодействовать с ним так же, как с любым другим элементом, кроме того, что у него не будет никаких методов или стиля по умолчанию.

Когда он затем регистрируется через customElements.define(), он дополняется определением класса. Этот процесс называется обновлением. Вы можете вызвать обратный вызов, когда элемент обновлен с помощью customElements.whenDefined, который возвращает обещание, которое разрешается при обновлении элемента:

customElements.whenDefined('my-element')
.then(() => {
  // my-element is now defined
})

Публичный API веб-компонента

Помимо этих методов жизненного цикла, вы можете определять методы для своего элемента, которые можно вызывать извне, что в настоящее время невозможно с элементами, определенными с использованием таких фреймворков, как React или Angular. Например, вы можете определить метод с именем doSomething:

class MyElement extends HTMLElement {
  ...
  doSomething() {
    // do something in this method
  }
}

И назовите его извне компонента следующим образом:

const element = document.querySelector('my-element');
element.doSomething();

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

Помимо объявления начального состояния веб-компонента, атрибуты также используются для отражения значения соответствующего свойства, чтобы состояние JavaScript элемента отражалось в его представлении DOM. Примером этого является атрибут disabled элемента input:

<input name="name">
const input = document.querySelector('input');
input.disabled = true;

После установки для свойства input свойства disabled значение true это изменение будет отражено в соответствующем атрибуте disabled:

<input name="name" disabled>

Реализовать отражение свойства в атрибуте можно легко с помощью установщика:

class MyElement extends HTMLElement {
  ...
  set disabled(isDisabled) {
    if(isDisabled) {
      this.setAttribute('disabled', '');
    }
    else {
      this.removeAttribute('disabled');
    }
  }
  get disabled() {
    return this.hasAttribute('disabled');
  }
}

Когда вам нужно выполнить какое-либо действие при изменении атрибута, добавьте его в массив observedAttributes. В целях оптимизации производительности для изменений будут учитываться только перечисленные здесь атрибуты. Каждый раз, когда значение атрибута изменяется, attributeChangedCallback будет вызываться с именем атрибута, его текущим значением и его новым значением:

class MyElement extends HTMLElement {  
  static get observedAttributes() {    
    return ['disabled'];  
  }
  constructor() {    
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `      
      <style>        
        .disabled {          
          opacity: 0.4;        
        }      
      </style>      
      <div id="container"></div>    
    `;
    this.container = this.shadowRoot('#container');  
  }
  attributeChangedCallback(attr, oldVal, newVal) {    
    if(attr === 'disabled') {      
      if(this.disabled) {        
        this.container.classList.add('disabled');      
      }      
      else {        
        this.container.classList.remove('disabled')      
      }    
    }
  }
}

Теперь всякий раз, когда атрибут disabled изменяется, класс disabled переключается на this.container, который является элементом div внутри Shadow DOM элемента.

Давайте посмотрим на это поближе.

Теневой DOM

Используя Shadow DOM, HTML и CSS пользовательского элемента полностью инкапсулируются внутри компонента. Это означает, что элемент будет отображаться как один тег HTML в дереве DOM документа, а его внутренняя структура HTML будет помещена внутри #shadow-root.

Фактически, Shadow DOM также используется несколькими собственными элементами HTML. Например, когда у вас есть элемент <video> на веб-странице, он будет отображаться как один тег, но он также будет отображать элементы управления для воспроизведения и приостановки видео, которые не видны, когда вы проверяете элемент <video> в инструментах разработчика браузера.

Эти элементы управления фактически являются частью Shadow DOM элемента <video> и поэтому по умолчанию скрыты. Чтобы открыть Shadow DOM в Chrome, перейдите в «Настройки» в настройках инструментов разработчика и установите флажок «Показать теневой DOM пользовательского агента». Когда вы снова проверите видеоэлемент в инструментах разработчика, вы увидите и сможете проверить Shadow DOM элемента.

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

Чтобы определить теневой корень:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `<p>Hello world</p>`;

Это определяет теневой корень с mode: 'open', что означает, что его можно проверять в инструментах разработки и взаимодействовать с ним, запрашивая его, настраивая любые открытые свойства CSS или прослушивая события, которые он генерирует. Также можно определить корень Shadow с помощью mode: 'closed', что не рекомендуется, поскольку это не позволит потребителю компонента каким-либо образом взаимодействовать с ним; вы даже не сможете прослушивать генерируемые им события.

Чтобы добавить HTM к корню Shadow, вы можете назначить строку HTML его свойству innerHTML или использовать элемент <template>. Шаблон HTML - это, по сути, инертный фрагмент HTML, который вы можете определить для дальнейшего использования. Он не будет виден или проанализирован до тех пор, пока он не будет фактически вставлен в дерево DOM, что означает, что любые внешние ресурсы, определенные внутри него, не будут извлечены, а любые CSS и JavaScript не будут проанализированы, пока вы не вставите его в DOM. Когда HTML вашего компонента изменяется в зависимости от его состояния, вы можете, например, определить несколько <template> элементов, которые вы можете вставить в зависимости от состояния компонента. Это позволяет вам легко изменять большие части HTML-кода компонента без необходимости возиться с отдельными узлами DOM.

Когда теневой корень создан, вы можете использовать для него все методы DOM, которые вы обычно использовали бы для объекта document, например this.shadowRoot.querySelector, чтобы найти элемент. Весь CSS для компонента определяется внутри тега <style>, но вы также можете получить внешнюю таблицу стилей, если хотите использовать обычный тег <link rel="stylesheet">. В дополнение к обычному CSS вы можете использовать селектор :host для стилизации самого компонента. Например, пользовательские элементы по умолчанию используют display: inline, поэтому для отображения компонента как блочного элемента вы можете использовать:

:host {
  display: block;
}

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

:host([disabled]) {
  opacity: 0.5;
}

По умолчанию пользовательские элементы наследуют некоторые свойства из окружающего CSS, например color и font. Однако, если вы хотите начать с чистого листа и сбросить все свойства CSS до значений по умолчанию внутри вашего компонента, используйте:

:host {
  all: initial;
}

Важно отметить, что стили, определенные в самом компоненте извне, имеют приоритет над стилями, определенными в Shadow DOM с помощью :host. Итак, если бы вы определили:

my-element {
  display: inline-block;
}

это переопределит:

:host {
  display: block;
}

Невозможно стилизовать какие-либо узлы внутри настраиваемого элемента снаружи. Однако, если вы хотите, чтобы потребители могли стилизовать (части) вашего компонента, вы можете предоставить для этого переменные CSS. Например, если вы хотите, чтобы потребители могли выбирать цвет фона вашего компонента, вы можете предоставить переменную CSS с именем --background-color.

Допустим, корневой узел теневой модели DOM внутри вашего компонента - <div id="container">:

#container {
  background-color: var(--background-color);
}

Теперь потребители вашего компонента могут установить цвет фона снаружи:

my-element {
  --background-color: #ff0000;
}

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

:host {
  --background-color: #ffffff;
}
#container {
  background-color: var(--background-color);
}

Конечно, вы можете выбрать любое имя для своих переменных CSS. Единственное требование к переменным CSS - они должны начинаться с "- -".

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

Помимо возможности стилизовать внутреннее устройство веб-компонентов с помощью переменных CSS, также можно внедрить HTML в веб-компоненты.

Композиция через слоты

Композиция - это процесс составления дерева Shadow DOM вместе с разметкой, предоставляемой пользователем. Это делается с помощью элемента <slot>, который в основном является заполнителем в Shadow DOM, где отображается разметка, предоставленная пользователем. Разметка, предоставляемая пользователем, называется облегченной моделью DOM. Композиция объединяет светлый DOM и Shadow DOM в новое дерево DOM.

Например, вы можете создать <image-gallery> компонент и предоставить стандартные <img> теги в качестве содержимого для визуализации компонента:

<image-gallery>
  <img src="foo.jpg" slot="image">
  <img src="b.arjpg" slot="image">
</image-gallery>

Теперь компонент возьмет два предоставленных изображения и отрендерит их внутри теневой модели DOM, используя слоты. Обратите внимание на атрибут slot="image" на изображениях. Это сообщает компоненту, где они должны отображаться внутри его Shadow DOM, который, например, может выглядеть так:

<div id="container">
  <div class="images">
    <slot name="image"></slot>
  </div>
</div>

И когда узлы внутри легкой DOM будут распределены в Shadow DOM элемента, результирующее дерево DOM будет выглядеть так:

<div id="container">
  <div class="images">
    <slot name="image">
      <img src="foo.jpg" slot="image">
      <img src="bar.jpg" slot="image">
    </slot>
  </div>
</div>

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

Скромный элемент <select> работает точно так же, как вы можете увидеть, когда изучите его в инструментах разработчика Chrome (когда вы выбрали Показать теневой DOM пользовательского агента, см. Выше):

Он берет <option> элементы, которые предоставляет пользователь, и отображает их в раскрывающемся меню.

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

Скажем, Shadow DOM <image-gallery> выглядит так:

<div id="container">
  <div class="images">
    <slot></slot>
    <slot></slot>
    <slot>
      <strong>No image here!</strong> <-- fallback content -->
    </slot>
  </div>
</div>

Когда ему снова будут предоставлены те же два изображения, результирующее дерево DOM будет выглядеть так:

<div id="container">
  <div class="images">
    <slot>
      <img src="foo.jpg">
    </slot>
    <slot>
      <img src="bar.jpg">
    </slot>
    <slot>
     <strong>No image here!</strong>
    </slot>
  </div>
</div>

Элементы, которые отображаются внутри Shadow DOM через слоты, называются распределенными узлами. Все стили, которые были применены к этим узлам до того, как они были отрисованы внутри теневой модели DOM компонента (распределенной), также будут применяться после распределения. Внутри Shadow DOM распределенные узлы могут получить дополнительный стиль с помощью селектора ::slotted():

::slotted(img) {
  float: left;
}

::slotted() может принимать любой допустимый селектор CSS, но может выбирать только узлы верхнего уровня. Например, ::slotted(section img) не будет работать с этим контентом:

<image-gallery>
  <section slot="image">
    <img src="foo.jpg">
  </section>
</image-gallery>

Работа со слотами в JavaScript

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

Чтобы узнать, какие элементы были назначены на слот, вызовите slot.assignedNodes(). Если вы также хотите получить резервный контент, позвоните slot.assignedNodes({flatten: true}).

Чтобы узнать, какой слот определенному элементу был присвоен элемент, проверьте element.assignedSlot.

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

slot.addEventListener('slotchange', e => {
  const changedSlot = e.target;
  console.log(changedSlot.assignedNodes());
});

Chrome запускает событие slotchange при первой инициализации элемента, тогда как Safari и Firefox этого не делают.

События в Shadow DOM

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

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

class MyElement extends HTMLElement {
  ...
  connectedCallback() {
    this.dispatchEvent(new CustomEvent('custom', {
      detail: {message: 'a custom event'}
    }));
  }
}
// on the outside
document.querySelector('my-element').addEventListener('custom', e => console.log('message from event:', e.detail.message));

Однако, когда событие генерируется из узла внутри Shadow DOM вместо самого настраиваемого элемента, оно не выскакивает из Shadow DOM, если оно не создано с помощью composed: true:

class MyElement extends HTMLElement {
  ...
  connectedCallback() {
    this.container = this.shadowRoot.querySelector('#container');
    // dispatchEvent is now called on this.container instead of this
    this.container.dispatchEvent(new CustomEvent('custom', {
      detail: {message: 'a custom event'},
      composed: true  // without composed: true this event will not bubble out of Shadow DOM
    }));
  }
}

Элемент шаблона

Помимо использования this.shadowRoot.innerHTML для добавления HTML в теневой корень элемента, для этого можно использовать элемент <template>. Шаблон содержит HTML для дальнейшего использования. Он не отображается и сначала анализируется только для проверки правильности его содержимого. JavaScript внутри шаблона не выполняется, и никакие внешние ресурсы не извлекаются. По умолчанию он скрыт.

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

class MyElement extends HTMLElement {
  ...
  constructor() {
    const shadowRoot = this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `
      <template id="view1">
        <p>This is view 1</p>
      </template>
      <template id="view1">
        <p>This is view 1</p>
      </template>
      <div id="container">
        <p>This is the container</p>
      </div>
    `;
  }
  connectedCallback() {
    const content = this.shadowRoot.querySelector('#view1').content.cloneNode(true);
    this.container = this.shadowRoot.querySelector('#container');
    
    this.container.appendChild(content);
  }
}

Здесь оба шаблона помещаются в теневой корень элемента с использованием innerHTML. Изначально оба шаблона скрыты, и отображается только контейнер. Внутри connectedCallback мы получаем содержимое #view1 с this.shadowRoot.querySelector('#view1').content.cloneNode(true). Свойство content шаблона возвращает содержимое шаблона как DocumentFragment, которое может быть добавлено к другому элементу с помощью appendChild. Поскольку appendChild переместит элемент, когда он уже присутствует в DOM, нам нужно сначала клонировать его, используя cloneNode(true). В противном случае содержимое шаблона будет перемещено, а не добавлено, что означает, что мы сможем использовать его только один раз.

Шаблоны очень полезны для быстрого изменения больших частей HTML или для повторного использования разметки. Они не ограничиваются веб-компонентами и могут использоваться в любом месте DOM.

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

До сих пор мы расширяли HTMLElement, чтобы создать совершенно новый элемент HTML. Пользовательские элементы также позволяют расширять собственные встроенные элементы, позволяя улучшать уже существующие элементы HTML, такие как, например, изображения и кнопки. На момент написания эта функция поддерживается только в Chrome и Firefox.

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

В качестве примера предположим, что мы хотим улучшить элемент HTML <button>:

class MyButton extends HTMLButtonElement {
  ...
  constructor() {
    super();  // always call super() to run the parent's constructor as well
  }
  connectedCallback() {
    ...
  }
  someMethod() {
    ...
  }
}
customElements.define('my-button', MyButton, {extends: 'button'});

Вместо того, чтобы расширять более общий HTMLElement, наш веб-компонент теперь расширяет HTMLButtonElement. Вызов customElements.define теперь также принимает дополнительный аргумент {extends: 'button'}, чтобы указать, что наш класс расширяет элемент <button>. Это может показаться излишним, поскольку мы уже указали, что хотим расширить HTMLButtonElement, но это необходимо, поскольку есть элементы, которые используют один и тот же интерфейс DOM. Например, и <q>, и <blockquote> имеют общий интерфейс HTMLQuoteElement.

Улучшенную кнопку теперь можно использовать с атрибутом is:

<button is="my-button">

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

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

Еще одно преимущество расширения встроенных элементов состоит в том, что их также можно использовать в местах, где действуют ограничения на дочерние элементы. Например, элементу <thead> разрешено иметь только элементы <tr> в качестве его дочерних элементов, поэтому элемент <awesome-tr>, например, будет отображать недопустимую разметку. В этом случае мы могли бы расширить встроенный элемент <tr> и использовать его следующим образом:

<table>
  <thead>
    <tr is="awesome-tr"></tr>
  </thead>
</table>

Этот способ создания веб-компонентов приносит большие прогрессивные улучшения, но, как уже упоминалось, на данный момент это реализовано только в Chrome и Firefox. Edge также будет реализовывать это, но на момент написания Safari, к сожалению, не реализовал это.

Тестирование веб-компонентов

Тестировать веб-компоненты легко, просто и, откровенно говоря, проще простого по сравнению с написанием тестов для таких фреймворков, как Angular и React. Нет необходимости в транспиляции или сложной настройке. Просто создайте элемент, добавьте его в DOM и запустите тесты.

Вот пример теста с использованием Mocha:

import 'path/to/my-element.js';
describe('my-element', () => {
  let element;
  beforeEach(() => {
    element = document.createElement('my-element');
    document.body.appendChild(element);
  });
  afterEach(() => {
    document.body.removeChild(element);
  });
  it('should test my-element', () => {
    // run your test here
  });
});

Здесь первая строка импортирует файл my-element.js, который представляет наш веб-компонент как модуль ES6. Это означает, что сам тестовый файл также необходимо загрузить в браузере как модуль ES6. Для этого требуется index.html, чтобы иметь возможность запускать тесты в браузере. Помимо Mocha, эта настройка также загружает полифилл WebcomponentsJS, Chai для тестовых утверждений и Sinon для шпионов и имитаторов:

<!doctype html>
<html>
    <head>
        <meta charset="utf-8">
        <link rel="stylesheet" href="../node_modules/mocha/mocha.css">
        <script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
        <script src="../node_modules/sinon/pkg/sinon.js"></script>
        <script src="../node_modules/chai/chai.js"></script>
        <script src="../node_modules/mocha/mocha.js"></script>
        <script>
            window.assert = chai.assert;
            mocha.setup('bdd');
        </script>
        <script type="module" src="path/to/my-element.test.js"></script>
        <script type="module">
            mocha.run();
        </script>
    </head>
    <body>
        <div id="mocha"></div>
    </body>
</html>

После загрузки необходимых сценариев мы выставляем chai.assert как глобальную переменную, чтобы мы могли просто использовать assert() в наших тестах и ​​настроить Mocha для использования интерфейса BDD. Затем загружаются тестовые файлы (в данном случае только один), и мы запускаем тесты с вызовом mocha.run().

Обратите внимание, что при использовании модулей ES6 также необходимо поместить mocha.run() в сценарий с type="module". Это связано с тем, что модули ES6 по умолчанию откладываются, и если mocha.run() помещается в обычный тег скрипта, он будет выполнен до загрузки my-element.test.js.

Полифиллинг старых браузеров

Пользовательские элементы теперь поддерживаются в последних версиях Chrome, Firefox, Safari и Opera для настольных ПК и будут поддерживаться в предстоящем Edge 19. На iOS и Android они поддерживаются в Safari, Chrome и Firefox.

Для старых браузеров существует полифилл WebcomponentsJS, который можно установить с помощью:

npm install --save @webcomponents/webcomponentsjs

Вы можете включить файл webcomponents-loader.js, который будет определять особенности, чтобы загружать только необходимые полифиллы. Используя этот полифилл, вы можете использовать пользовательские элементы, не добавляя ничего в исходный код. Однако он не предоставляет CSS с действительно ограниченной областью видимости, а это означает, что если у вас есть одинаковые имена классов и идентификаторы в разных веб-компонентах и ​​вы загружаете их в один и тот же документ, они будут конфликтовать. Кроме того, селекторы Shadow DOM CSS :host() и :slotted() могут работать не так, как ожидалось.

Чтобы это работало правильно, вам нужно будет использовать полифил Shady CSS, что также означает, что вам придется (немного) изменить исходный код, чтобы использовать его. Я лично счел это нежелательным, поэтому я создал загрузчик веб-пакетов, который справится с этим за вас. Это означает, что вам придется транспилировать, но вы можете оставить свой код нетронутым.

Загрузчик веб-пакетов выполняет три функции: он префикс всех правил CSS внутри Shadow DOM вашего веб-компонента, которые не начинаются с ::host или ::slotted, с тегом элемента, чтобы обеспечить правильную область видимости. После этого он анализирует все ::host и ::slotted правила, чтобы убедиться, что они работают правильно.

Рабочий пример # 1: lazy-img

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

Основная версия компонента оборачивает собственный тег <img> внутри <lazy-img> настраиваемого элемента:

<lazy-img
  src="path/to/image.jpg"
  width="480"
  height="320"
  delay="500"
  margin="0px"></lazy-img>

Репо также содержит ветвь extend-native, которая содержит lazy-img, который расширяет собственный тег <img> с помощью атрибута is:

<img
  is="lazy-img"
  src="path/to/img.jpg"
  width="480"
  height="320"
  delay="500"
  margin="0px">

Это хороший пример мощи собственных веб-компонентов: просто импортируйте файл JavaScript, добавьте тег HTML или расширьте собственный с помощью атрибута is, и вы в деле!

Рабочий пример # 2: материал-вебкомпоненты

Я реализовал материальный дизайн Google с помощью пользовательских элементов, также доступных на Github.

Эта библиотека также демонстрирует мощь настраиваемых свойств CSS, которые она активно использует.

Итак, я должен отказаться от своей структуры?

Ну, как всегда, зависит от обстоятельств.

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

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

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

Хотя этот подход имеет смысл, он может сделать обновление только небольших частей DOM громоздким и многословным по сравнению с декларативным способом, которым React и Angular позволяют это делать. Эти фреймворки позволяют определять представление, содержащее выражения, которые обновляются при изменении.

Нативные веб-компоненты не предоставляют такую ​​функциональность (пока), хотя есть предложение расширить элемент <template>, чтобы он мог быть создан и обновлен данными:

<template id="example">
  <h1>{{title}}</h1>
  <p>{{text}}</p>
</template>
const template = document.querySelector('#example');
const instance = template.createInstance({title: 'The title', text: 'Hello world'});
shadowRoot.appendChild(instance.content);
//update
instance.update({title: 'A new title', text: 'Hi there'});

В настоящее время доступной библиотекой, обеспечивающей эффективное обновление модели DOM, является lit-html.

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

Я работал над множеством проектов с использованием Angular, React и Polymer, и, хотя они определенно знакомы, эти кодовые базы значительно различались, несмотря на использование одной и той же структуры. Четко определенный способ работы и руководство по стилю сделают больше для единообразия вашей кодовой базы, чем простое использование фреймворка. Фреймворки также добавляют сложности, спросите себя, действительно ли это того стоит.

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

Преимущества нативных веб-компонентов очевидны:

  • родной, фреймворк не нужен
  • простая интеграция, транспиляция не требуется
  • действительно ограниченный CSS
  • стандартный, только HTML, CSS и JavaScript

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

Спасибо за прочтение!

Спасибо, что нашли время прочитать мою статью, и я надеюсь, что она вам понравилась!

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

Если вы создали что-то интересное с помощью веб-компонентов, я буду рад услышать от вас!

Первоначально опубликовано на www.dannymoerkerke.com

📝 Прочтите этот рассказ позже в Журнале.

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