Веб-компоненты. Часть 2: теневой DOM

в 8:05, , рубрики: html, javascript, web-разработка, webcomponents, Разработка веб-сайтов
Вступление

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

В этой статье речь пойдет о спецификации теневого DOM (shadow DOM) версии от 01.03.2018 г.. Последний черновик спецификации датирован 08.03.2018г.

АПИ теневого DOM позволяет нам инкапсулировать содержимое страницы, посредством помещения разметки в древовидную структуру, называемую shadow tree, которая, хотя и будет внедрена в DOM, не будет ее полноправной частью в привычном нам контексте: ее нельзя получить для взаимодействия стандартными методами js для работы с обычными потомками в DOM. Именно это АПИ в разрезе всех АПИ для создания веб-компонентов, дает нам возможность не только скрывать внутреннюю реализацию компонентов, но и инкапсулировать стили с минимальными усилиями.

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

<input type=”rage”/>

при рассмотрении в консоли оказывается не единичным элементом, а именно древовидной структурой обычных HTML элементов в теневом DOM.

Основные понятия

Ключевым понятием в концепции теневого DOM является shadow tree, то самое «поддерево», которое рендерится в документ, но не находится в DOM дереве. Мне проще всего было рассматривать shadow tree как нечто среднее между частью документа и фрагметом (document fragment).

Корневым элементом shadow tree является shadow root. Это — узел, на котором был вызван метод .attachShadow(obj), где obj — это объект с настройками в котором содержится свойство mode — режим доступа к теневому DOM, которое может быть задано в значении «open» (доступ к shadow DOM возможен из основного документа посредством свойства .shadowRoot) или «closed» (доступ через .shadowRoot вернет null), впрочем вот в этой статье доступно объяснено почему серьезно расчитывать на режим closed не имеет смысла. В версии спецификации от 02.03.2018 также предусматривалась настройка свойством delegatedFocus, в булиевом значении, устанавливающая будет ли делегироваться фокус от shadow host к shadow root, однако спустя 6 дней эта концепция из спецификации была удалена.

Метод .attachShadow() прикрепляет shadow tree к узлу и возвращает объект ShadowRoot.

Дерево, которому принадлежит shadow root называют light tree, и, кстати light tree вполне может быть другим shadow tree.

Slot

Shadow tree может содержать элементы слоты (slot).Этот элемент — аналог documentFragment — при его рендеринге в DOM, он заменяется на его содержимое.

Частные случаи работы элемента slot различаются в зависимости от того, задано ли значение в его атрибут name.

Если значение атрибута name задано

Когда атрибутe name элемента slot присвоено значение, то элемент slot при рендеринге документа будет заменен на те элементы документа (light tree), у которых есть атрибут slot, установленный в значение равное значению атрибута name у элемента slot.

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


class TabNavigationItem extends HTMLElement {
	constructor() {
		super();
   		this._target = null;
   		this.attachShadow({ mode: 'open' });
 }
//...
}

В конструкторе создан и прикреплен теневой DOM. Теперь доступ к shadow root будет возможен через обращение this.shadowRoot.

Следующим шагом, я внесла изменения в метод .render(), изменив разметку пользовательского элемента:


	render() {
 		if(!this.ownerDocument.defaultView) return;
 		this.shadowRoot.innerHTML = `
    		<a href="#${this._target}"><slot name="nav"></slot></a>
   		`;
	}

Важно обратить внимание на то, что теперь разметка будет передаваться в значение свойства .innerHTML объекта this.shadowRoot.

Теперь в разметке при использовании пользовательского элемента я смогу задавать внутреннее содержимое элемента навигации с единственным условием, вложенные элементы должны иметь атрибут slot равный значению атрибута name у элемента slot в теневом DOM (в нашем примере — «nav»), иначе они не будут отображаться.


<tab-nav-item class="active" target="First tab"> 
	<h1 slot="nav">First</h1>
</tab-nav-item>

Аналогичные изменения я внесу в класс TabContentItem:


class TabContentItem extends HTMLElement {
	constructor() {
		super();
		this._target = null;
		this.attachShadow({ mode: 'open' });
	}

	render() {
		if(!this.ownerDocument.defaultView) return;
		this.shadowRoot.innerHTML = `
      	<div>
         	<slot name="tab"></slot>
      	</div>
     	`;
 	}
}

В связи с подключением теневого DOM необходимость в использовании атрибута content для отображения содержимого таб отпала.

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


	<tab-element>
   <nav>
       <tab-nav-item class="active" target="First tab"> <h1 slot="nav">First</h1></tab-nav-item>
       <tab-nav-item target="Second tab"><h2 slot="nav">Second</h2></tab-nav-item>
       <tab-nav-item target="Third tab"><h3 slot="nav">Third</h3></tab-nav-item>
   </nav>

   <tab-content-item class='active' target="First tab">
       <div slot="tab">
           <h3>Hi, I'm the first</h3>
           <h1>TAB</h1>
       </div>
   </tab-content-item>
   <tab-content-item target="Second tab">
       <p slot="tab">second one!</p>
   </tab-content-item>
   <tab-content-item target="Third tab">
       <style slot="tab">
           .test {
               border: 1px solid yellow;
               padding: 20px
           }
       </style>

       <div slot="tab">
           <div class="test">I’m the third</div>
       </div>
   </tab-content-item>
</tab-element>

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

Если значение атрибута name не задано

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

Потому я уберу из разметки, задаваемой в методах render, атрибут name и его значение, а из разметки в основном документе атрибуты slot и их значения. Таким образом, вся разметка, указываемая между двумя тегами пользовательского элемента будет внедрена в дефолтный слот. Код изоляции разметки можно посмотреть вот тут.

Стили

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

Пользовательские веб-компоненты могут стилизовать сами себя из контекста теневого DOM, используя селектор :host. При этом, правила такого селектора могут быть переписаны внешними стилями компоненты, что позволит менять стили при использовании компоненты (адаптировать при необходимости в момент использования). А :host(selector) позволяет компоненте стилизовать хост при его совпадении с селектором, что как раз используется для визуализации действий пользователя и состояния приложения. Также доступна стилизация по контексту :host-context(selector) который совпадает с компонентой только когда какой-то из предков компоненты совпадает с селектором (используется для стилизации обусловленной окружением).

Я решила убрать стили, отвечающие за определяющее поведение таб и дефолтные внешние стили и сокрыть их в теневом DOM, для чего внесла теги style внутрь разметки метода .render() класса TabNavigationItem:


render() {
	if (!this.ownerDocument.defaultView) return;
	this.shadowRoot.innerHTML = `
    <style>
      :host{
         padding: 10px;
         background-color: gray;
         border: 1px solid gray;
      }
      :host-context(.active) {
       background-color: #ccc;
      }
      a{
          text-decoration: none;
       color: black;
      }
    </style>
    <a href="#${this._target}"><slot></slot></a>
   `;
}

Аналогично с методом .render() класса TabContentItem:


render() {
	if (!this.ownerDocument.defaultView) return;
	this.shadowRoot.innerHTML = `
  <style>
   :host {
        display: none;
        padding: 20px;
        width: 100%;
        height: 50px;
   }
   :host-context(.active){
     display: block;
   }
  </style>
    <div><slot></slot></div>
   `;
}

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

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

О событиях

Мой пример с табами не лучшим образом подходит для демонстрации этого, но теневой DOM имеет также особенность повдения событий, так например вот список событий, которые, согласно документации, должны будут всегда останавливаться на самом внутреннем shadow root: abort, error, select, change, load, reset, resize, scrol, selectstart. А click, dbclick и почти все остальные мышиные события, wheel, blur, focus, focusin, focusout, keydown, keyup, все события перетаскивания (drag events) и некоторые другие свободно пересекают границу shadow DOM. Мне не доводилось это использовать, но я считаю что эта информаци может пригодится.

О поддержке

В настоящее время поддержка первой версии спецификации реализована в Chrome в Opera, частично в Safari и на данный момент имплементируется в Firefox.

Спасибо за внимание, прошу не судить строго. С уважением Tania_N

Автор: Tatiana Nedavnia

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js