Shadow DOM v1 — автономные веб-компоненты

Shadow DOM позволяет веб-разработчикам создавать разделенные DOM и CSS для веб-компонентов.

Эрик Бидельман

Краткое содержание

Shadow DOM устраняет хрупкость создания веб-приложений. Хрупкость связана с глобальной природой HTML, CSS и JS. За прошедшие годы мы изобрели непомерное количество инструментов , позволяющих обойти эти проблемы. Например, когда вы используете новый идентификатор/класс HTML, невозможно сказать, будет ли он конфликтовать с существующим именем, используемым на странице. Появляются мелкие ошибки , специфичность CSS становится огромной проблемой ( !important все!), селекторы стилей выходят из-под контроля, а производительность может пострадать . Список можно продолжить.

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

Введение

Shadow DOM — один из трёх стандартов веб-компонентов: HTML Templates , Shadow DOM и Custom elements . Импорт HTML раньше был частью списка, но теперь считается устаревшим .

Вам не нужно создавать веб-компоненты, использующие теневой DOM. Но когда вы это сделаете, вы воспользуетесь его преимуществами (охват CSS, инкапсуляция DOM, композиция) и создадите повторно используемые пользовательские элементы , которые являются отказоустойчивыми, легко настраиваемыми и чрезвычайно пригодными для повторного использования. Если пользовательские элементы — это способ создания нового HTML (с помощью JS API), то теневой DOM — это способ предоставления HTML и CSS. Два API объединяются в компонент с автономными HTML, CSS и JavaScript.

Shadow DOM разработан как инструмент для создания приложений на основе компонентов. Таким образом, он предлагает решения распространенных проблем в веб-разработке:

  • Изолированный DOM : DOM компонента является автономным (например, document.querySelector() не будет возвращать узлы в теневом DOM компонента).
  • CSS с областью действия : CSS, определенный внутри теневого DOM, ограничен его областью действия. Правила стиля не просачиваются наружу, а стили страниц не просачиваются наружу.
  • Состав : Разработайте декларативный API на основе разметки для вашего компонента.
  • Упрощает CSS . Область действия DOM означает, что вы можете использовать простые селекторы CSS, более общие имена идентификаторов/классов и не беспокоиться о конфликтах имен.
  • Производительность . Думайте о приложениях как о фрагментах DOM, а не об одной большой (глобальной) странице.

демо-версия fancy-tabs

В этой статье я буду ссылаться на демонстрационный компонент ( <fancy-tabs> ) и фрагменты кода из него. Если ваш браузер поддерживает API, вы должны увидеть его живую демонстрацию чуть ниже. В противном случае проверьте полный исходный код на Github .

Посмотреть исходный код на Github

Что такое теневой DOM?

Общие сведения о DOM

HTML расширяет возможности Интернета, потому что с ним легко работать. Объявив несколько тегов, вы можете за считанные секунды создать страницу, имеющую как представление, так и структуру. Однако сам по себе HTML не так уж и полезен. Людям легко понять текстовый язык, но машинам нужно нечто большее. Введите объектную модель документа или DOM.

Когда браузер загружает веб-страницу, он делает много интересных вещей. Одна из задач, которую он делает, — это преобразование HTML автора в живой документ. По сути, чтобы понять структуру страницы, браузер анализирует HTML (статические строки текста) в модель данных (объекты/узлы). Браузер сохраняет иерархию HTML, создавая дерево этих узлов: DOM. Самое замечательное в DOM — это то, что это живое представление вашей страницы. В отличие от статического HTML, который мы создаем, узлы, создаваемые браузером, содержат свойства, методы и, что самое главное… ими можно манипулировать с помощью программ! Вот почему мы можем создавать элементы DOM напрямую с помощью JavaScript:

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

создает следующую HTML-разметку:

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

Все это хорошо и хорошо. Тогда что же такое теневой DOM ?

ДОМ… в тени

Теневой DOM — это обычный DOM с двумя отличиями: 1) как он создается/используется и 2) как он ведет себя по отношению к остальной части страницы. Обычно вы создаете узлы DOM и добавляете их как дочерние элементы другого элемента. С помощью теневого DOM вы создаете дерево DOM с ограниченной областью действия, прикрепленное к элементу, но отдельно от его фактических дочерних элементов. Это ограниченное поддерево называется теневым деревом . Элемент, к которому он прикреплен, является его теневым хостом . Все, что вы добавляете в тени, становится локальным для хост-элемента, включая <style> . Вот как теневой DOM обеспечивает область видимости стиля CSS.

Создание теневого DOM

Теневой корень — это фрагмент документа, который прикрепляется к элементу «хост». Акт присоединения теневого корня — это то, как элемент получает свой теневой DOM. Чтобы создать теневой DOM для элемента, вызовите element.attachShadow() :

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

Я использую .innerHTML для заполнения теневого корня, но вы также можете использовать другие API DOM. Это сеть. У нас есть выбор.

Спецификация определяет список элементов , которые не могут содержать теневое дерево. Элемент может оказаться в списке по нескольким причинам:

  • В браузере уже имеется собственный внутренний теневой DOM для элемента ( <textarea> , <input> ).
  • Нет смысла размещать в элементе теневой DOM ( <img> ).

Например, это не работает:

    document.createElement('input').attachShadow({mode: 'open'});
    // Error. `<input>` cannot host shadow dom.

Создание теневого DOM для пользовательского элемента

Shadow DOM особенно полезен при создании пользовательских элементов . Используйте теневой DOM для разделения HTML, CSS и JS элемента, создавая таким образом «веб-компонент».

Пример — пользовательский элемент присоединяет к себе теневой DOM , инкапсулируя его DOM/CSS:

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
        <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
        <div id="tabs">...</div>
        <div id="panels">...</div>
    `;
    }
    ...
});

Здесь происходит пара интересных вещей. Во-первых, пользовательский элемент создает свой собственный теневой DOM при создании экземпляра <fancy-tabs> . Это делается в constructor() . Во-вторых, поскольку мы создаем теневой корень, правила CSS внутри <style> будут ограничены <fancy-tabs> .

Состав и слоты

Композиция — одна из наименее понятных особенностей теневого DOM, но, возможно, самая важная.

В нашем мире веб-разработки композиция — это способ создания приложений декларативно из HTML. Различные строительные блоки ( <div> , <header> , <form> , <input> ) собираются вместе, чтобы сформировать приложения. Некоторые из этих тегов даже работают друг с другом. Именно благодаря композиции нативные элементы, такие как <select> , <details> , <form> и <video> , настолько гибкие. Каждый из этих тегов принимает определенный HTML-код в качестве дочернего и делает с ним что-то особенное. Например, <select> знает, как отображать <option> и <optgroup> в виджетах с раскрывающимся списком и множественным выбором. Элемент <details> отображает <summary> как расширяемую стрелку. Даже <video> знает, как обращаться с некоторыми дочерними элементами: элементы <source> не визуализируются, но влияют на поведение видео. Какое волшебство!

Терминология: светлый DOM против теневого DOM

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

Светлый ДОМ

Разметка, которую пишет пользователь вашего компонента. Этот DOM находится вне теневого DOM компонента. Это фактические дочерние элементы элемента.

<better-button>
    <!-- the image and span are better-button's light DOM -->
    <img src="gear.svg" slot="icon">
    <span>Settings</span>
</better-button>

Тень ДОМ

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

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

Сплющенное дерево DOM

Результат того, что браузер распределяет световой DOM пользователя в ваш теневой DOM, визуализируя конечный продукт. Сплющенное дерево — это то, что вы в конечном итоге видите в DevTools и что отображается на странице.

<better-button>
    #shadow-root
    <style>...</style>
    <slot name="icon">
        <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
        <slot>
        <span>Settings</span>
        </slot>
    </span>
</better-button>

Элемент <slot>

Shadow DOM объединяет различные деревья DOM вместе с помощью элемента <slot> . Слоты — это заполнители внутри вашего компонента, которые пользователи могут заполнить собственной разметкой . Определив один или несколько слотов, вы приглашаете внешнюю разметку для рендеринга в теневой DOM вашего компонента. По сути, вы говорите : «Отобразите здесь пользовательскую разметку» .

Элементам разрешено «пересекать» границу теневого DOM, когда <slot> приглашает их войти. Эти элементы называются распределенными узлами . Концептуально распределенные узлы могут показаться немного странными. Слоты физически не перемещают DOM; они визуализируют его в другом месте внутри теневого DOM.

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

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
    <h2>Title</h2>
    <summary>Description text</summary>
</slot>

Вы также можете создавать именованные слоты . Именованные слоты — это особые дыры в вашей теневой модели DOM, на которые пользователи ссылаются по имени.

Пример — слоты в теневом DOM <fancy-tabs> :

#shadow-root
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>

Пользователи компонента объявляют <fancy-tabs> следующим образом:

<fancy-tabs>
    <button slot="title">Title</button>
    <button slot="title" selected>Title 2</button>
    <button slot="title">Title 3</button>
    <section>content panel 1</section>
    <section>content panel 2</section>
    <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
    <h2 slot="title">Title</h2>
    <section>content panel 1</section>
    <h2 slot="title" selected>Title 2</h2>
    <section>content panel 2</section>
    <h2 slot="title">Title 3</h2>
    <section>content panel 3</section>
</fancy-tabs>

И если вам интересно, сплющенное дерево выглядит примерно так:

<fancy-tabs>
    #shadow-root
    <div id="tabs">
        <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
        </slot>
    </div>
    <div id="panels">
        <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
        </slot>
    </div>
</fancy-tabs>

Обратите внимание, что наш компонент может обрабатывать разные конфигурации, но плоское дерево DOM остается прежним. Мы также можем переключиться с <button> на <h2> . Этот компонент был создан для обработки различных типов потомков… точно так же, как это делает <select> !

Стиль

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

Стили, определенные компонентом

Несомненно, самая полезная функция теневого DOM — это CSS :

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

Селекторы CSS, используемые внутри теневого DOM, применяются локально к вашему компоненту . На практике это означает, что мы можем снова использовать общие имена идентификаторов/классов, не беспокоясь о конфликтах в других местах страницы. Более простые селекторы CSS — лучшая практика внутри Shadow DOM. Они также хороши для производительности.

Пример : стили, определенные в теневом корне, являются локальными.

#shadow-root
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        ...
    }
    #tabs {
        display: inline-flex;
        ...
    }
    </style>
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

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

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

Вы когда-нибудь задумывались, как элемент <select> отображает виджет с множественным выбором (вместо раскрывающегося списка), когда вы добавляете атрибут multiple :

<select multiple>
  <option>Do</option>
  <option selected>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

<select> может стилизовать себя по-разному в зависимости от объявленных вами атрибутов. Веб-компоненты также могут стилизовать себя, используя селектор :host .

Пример — сам стиль компонента

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

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

Функциональная форма :host(<selector>) позволяет указать хост, если он соответствует <selector> . Это отличный способ для вашего компонента инкапсулировать поведение, которое реагирует на взаимодействие с пользователем или состояние или стиль внутренних узлов на основе хоста.

<style>
:host {
    opacity: 0.4;
    will-change: opacity;
    transition: opacity 300ms ease-in-out;
}
:host(:hover) {
    opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
}
:host(.blue) {
    color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

Стилизация в зависимости от контекста

:host-context(<selector>) соответствует компоненту, если он или любой из его предков соответствует <selector> . Обычное использование этого метода — создание тем на основе окружения компонента. Например, многие люди создают темы, применяя класс к <html> или <body> :

<body class="darktheme">
    <fancy-tabs>
    ...
    </fancy-tabs>
</body>

:host-context(.darktheme) будет стилизовать <fancy-tabs> если он является потомком .darktheme :

:host-context(.darktheme) {
    color: white;
    background: black;
}

:host-context() может быть полезен для оформления тем, но еще лучший подход — создать перехватчики стилей с использованием пользовательских свойств CSS .

Стилизация распределенных узлов

::slotted(<compound-selector>) соответствует узлам, которые распределены в <slot> .

Допустим, мы создали компонент бейджа с именем:

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

Теневой DOM компонента может стилизовать пользовательские <h2> и .title :

<style>
::slotted(h2) {
    margin: 0;
    font-weight: 300;
    color: red;
}
::slotted(.title) {
    color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
    text-transform: uppercase;
}
*/
</style>
<slot></slot>

Если вы помните ранее, <slot> не перемещает пользовательский легкий DOM. Когда узлы распределяются по <slot> , <slot> отображает их DOM, но узлы физически остаются на месте. Стили, применявшиеся до распространения, продолжают применяться и после распространения . Однако когда световой DOM распространяется, он может принимать дополнительные стили (определенные теневым DOM).

Еще один, более подробный пример из <fancy-tabs> :

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        border-radius: 3px;
        padding: 16px;
        height: 250px;
        overflow: auto;
    }
    #tabs {
        display: inline-flex;
        -webkit-user-select: none;
        user-select: none;
    }
    #tabsSlot::slotted(*) {
        font: 400 16px/22px 'Roboto';
        padding: 16px 8px;
        ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
        font-weight: 600;
        background: white;
        box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
        display: none;
    }
    </style>
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>
`;

В этом примере есть два слота: именованный слот для заголовков вкладок и слот для содержимого панели вкладок. Когда пользователь выбирает вкладку, мы выделяем его выделение жирным шрифтом и открываем его панель. Это делается путем выбора распределенных узлов, имеющих selected атрибут. JS пользовательского элемента (здесь не показан) добавляет этот атрибут в нужное время.

Стилизация компонента снаружи

Есть несколько способов стилизовать компонент снаружи. Самый простой способ — использовать имя тега в качестве селектора:

fancy-tabs {
    width: 500px;
    color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
    box-shadow: 0 3px 3px #ccc;
}

Внешние стили всегда преобладают над стилями, определенными в теневом DOM . Например, если пользователь пишет селектор fancy-tabs { width: 500px; } , это превзойдет правило компонента: :host { width: 650px;} .

Стилизация самого компонента не поможет. Но что произойдет, если вы захотите стилизовать внутренние части компонента? Для этого нам нужны пользовательские свойства CSS.

Создание хуков стиля с использованием пользовательских свойств CSS

Пользователи могут настраивать внутренние стили, если автор компонента предоставляет возможности стилизации с использованием пользовательских свойств CSS . Концептуально идея аналогична <slot> . Вы создаете «заполнители стиля», которые пользователи могут переопределить.

Пример . <fancy-tabs> позволяет пользователям переопределять цвет фона:

<!-- main page -->
<style>
    fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
    }
</style>
<fancy-tabs background>...</fancy-tabs>

Внутри своего теневого DOM:

:host([background]) {
    background: var(--fancy-tabs-bg, #9E9E9E);
    border-radius: 10px;
    padding: 10px;
}

В этом случае компонент будет использовать black в качестве значения фона, поскольку его предоставил пользователь. В противном случае по умолчанию будет #9E9E9E .

Расширенные темы

Создание закрытых теневых корней (следует избегать)

Есть еще одна разновидность теневого DOM, называемая «закрытым» режимом. Когда вы создаете закрытое теневое дерево, внешний JavaScript не сможет получить доступ к внутреннему DOM вашего компонента. Это похоже на то, как работают нативные элементы, такие как <video> . JavaScript не может получить доступ к теневому DOM <video> , поскольку браузер реализует его, используя теневой корень закрытого режима.

Пример — создание закрытого теневого дерева:

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

Закрытый режим также влияет на другие API:

  • Element.assignedSlot / TextNode.assignedSlot возвращает null
  • Event.composedPath() для событий, связанных с элементами внутри теневого DOM, возвращает []

Вот мое краткое изложение того, почему никогда не следует создавать веб-компоненты с помощью {mode: 'closed'} :

  1. Искусственное чувство безопасности. Ничто не мешает злоумышленнику перехватить Element.prototype.attachShadow .

  2. Закрытый режим не позволяет коду пользовательского элемента получить доступ к собственному теневому DOM . Это полный провал. Вместо этого вам придется сохранить ссылку на будущее, если вы захотите использовать такие вещи, как querySelector() . Это полностью противоречит первоначальной цели закрытого режима!

        customElements.define('x-element', class extends HTMLElement {
        constructor() {
        super(); // always call super() first in the constructor.
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
        }
        connectedCallback() {
        // When creating closed shadow trees, you'll need to stash the shadow root
        // for later if you want to use it again. Kinda pointless.
        const wrapper = this._shadowRoot.querySelector('.wrapper');
        }
        ...
    });
    
  3. Закрытый режим делает ваш компонент менее гибким для конечных пользователей . Когда вы создаете веб-компоненты, наступает момент, когда вы забываете добавить функцию. Вариант конфигурации. Вариант использования, который хочет пользователь. Типичный пример — забывание включить адекватные стили для внутренних узлов. В закрытом режиме пользователи не могут переопределять настройки по умолчанию и настраивать стили. Возможность доступа к внутренним компонентам компонента очень полезна. В конечном итоге пользователи разветвят ваш компонент, найдут другой или создадут свой собственный, если он не делает то, что они хотят :(

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

API теневого DOM предоставляет утилиты для работы со слотами и распределенными узлами. Они пригодятся при создании пользовательского элемента.

событие смены слота

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

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
    console.log('light dom children changed!');
});

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

Какие элементы отображаются в слоте?

Иногда полезно знать, какие элементы связаны со слотом. Вызовите slot.assignedNodes() , чтобы узнать, какие элементы отображает слот. Параметр {flatten: true} также вернет резервное содержимое слота (если узлы не распределяются).

В качестве примера предположим, что ваш теневой DOM выглядит так:

<slot><b>fallback content</b></slot>
Применение Вызов Результат
<мой-компонент>текст компонента</мой-компонент> slot.assignedNodes(); [component text]
<мой-компонент></мой-компонент> slot.assignedNodes(); []
<мой-компонент></мой-компонент> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

Какому слоту назначен элемент?

Возможен и ответ на обратный вопрос. element.assignedSlot сообщает вам, какому из слотов компонентов назначен ваш элемент.

Модель событий Shadow DOM

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

События, которые пересекают теневую границу:

  • События фокусировки: blur , focus , focusin , focusout
  • События мыши: click , dblclick , mousedown , mouseenter , mousemove и т. д.
  • События колеса: wheel
  • Входные события: beforeinput , input
  • События клавиатуры: keydown , keyup
  • События композиции: compositionstart , compositionupdate , compositionend
  • DragEvent: dragstart , drag , dragend , drop и т. д.

Советы

Если теневое дерево открыто, вызов event.composedPath() вернет массив узлов, через которые прошло событие.

Использование пользовательских событий

Пользовательские события DOM, которые запускаются на внутренних узлах теневого дерева, не выходят за пределы теневой границы, если только событие не создано с использованием флага composed: true :

// Inside <fancy-tab> custom element class definition:
selectTab() {
    const tabs = this.shadowRoot.querySelector('#tabs');
    tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

Если composed: false (по умолчанию), потребители не смогут прослушивать событие за пределами вашего теневого корня.

<fancy-tabs></fancy-tabs>
<script>
    const tabs = document.querySelector('fancy-tabs');
    tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
    });
</script>

Обработка фокуса

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

<x-focus>
    #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

Событие focus будет выглядеть так, как будто оно пришло из <x-focus> , а не из <input> . Аналогично, document.activeElement будет <x-focus> . Если теневой корень был создан с mode:'open' (см. закрытый режим ), вы также сможете получить доступ к внутреннему узлу, который получил фокус:

document.activeElement.shadowRoot.activeElement // only works with open mode.

Если задействовано несколько уровней теневого DOM (скажем, пользовательский элемент внутри другого пользовательского элемента), вам необходимо рекурсивно углубиться в теневые корни, чтобы найти activeElement :

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

Другой вариант фокуса — это параметр delegatesFocus: true , который расширяет поведение фокуса элементов внутри теневого дерева:

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

Пример : как delegatesFocus: true меняет поведение фокуса

<style>
    :focus {
    outline: 2px solid red;
    }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
        <style>
        :host {
            display: flex;
            border: 1px dotted black;
            padding: 16px;
        }
        :focus {
            outline: 2px solid blue;
        }
        </style>
        <div>Clickable Shadow DOM text</div>
        <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
        console.log('Active element (inside shadow dom):',
                    this.shadowRoot.activeElement);
    });
    }
});
</script>

Результат

делегатыФокус: истинное поведение.

Выше показан результат, когда <x-focus> находится в фокусе (щелчок пользователя, вкладка, focus() и т. д.), щелчок по «кликабельному теневому тексту DOM» или внутренний <input> в фокусе (включая autofocus ).

Если бы вы установили delegatesFocus: false , вместо этого вы бы увидели вот что:

делегатыФокус: false, и внутренний ввод сфокусирован.
delegatesFocus: false , и внутренний <input> находится в фокусе.
DelegatesFocus: false и x-focus получает фокус (например, tabindex='0').
delegatesFocus: false и <x-focus> получает фокус (например, имеет tabindex="0" ).
делегатыФокус: false и щелкается «Кликируемый теневой текст DOM» (или щелкается другая пустая область в теневой DOM элемента).
delegatesFocus: false и щелкается «Кликируемый текст теневого DOM» (или щелкается другая пустая область в теневой DOM элемента).

Советы и хитрости

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

Используйте сдерживание CSS

Обычно макет/стиль/отрисовка веб-компонента достаточно автономны. Используйте CSS-включение в :host для достижения максимальной производительности:

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

Сброс наследуемых стилей

Наследуемые стили ( background , color , font , line-height и т. д.) продолжают наследоваться в теневом DOM. То есть по умолчанию они пересекают границу теневого DOM. Если вы хотите начать с чистого листа, используйте all: initial; для сброса наследуемых стилей к их исходному значению, когда они пересекают границу тени.

<style>
    div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
    }
</style>

<div>
    <p>I'm outside the element (big/white)</p>
    <my-element>Light DOM content is also affected.</my-element>
    <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
    <style>
    :host {
        all: initial; /* 1st rule so subsequent properties are reset. */
        display: block;
        background: white;
    }
    </style>
    <p>my-element: all CSS properties are reset to their
        initial value using <code>all: initial</code>.</p>
    <slot></slot>
`;
</script>

Поиск всех пользовательских элементов, используемых на странице

Иногда полезно найти пользовательские элементы, используемые на странице. Для этого вам необходимо рекурсивно пройти по теневой модели DOM всех элементов, используемых на странице.

const allCustomElements = [];

function isCustomElement(el) {
    const isAttr = el.getAttribute('is');
    // Check for <super-button> and <button is="super-button">.
    return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
    for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
        allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
        findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
    }
}

findAllCustomElements(document.querySelectorAll('*'));

Создание элементов из <template>

Вместо заполнения теневого корня с помощью .innerHTML мы можем использовать декларативный <template> . Шаблоны являются идеальным заполнителем для объявления структуры веб-компонента.

См. пример в разделе «Пользовательские элементы: создание повторно используемых веб-компонентов» .

История и поддержка браузера

Если вы следили за веб-компонентами в течение последних нескольких лет, вы знаете, что Chrome 35+/Opera уже некоторое время поставляет более старую версию теневого DOM. Blink продолжит поддерживать обе версии параллельно в течение некоторого времени. Спецификация v0 предоставила другой метод для создания теневого корня ( element.createShadowRoot вместо element.attachShadow v1 ). Вызов старого метода продолжает создавать теневой корень с семантикой версии 0, поэтому существующий код версии 0 не нарушится.

Если вас заинтересовала старая спецификация v0, ознакомьтесь со статьями html5rocks: 1 , 2 , 3 . Также есть отличное сравнение различий между теневым DOM v0 и v1 .

Поддержка браузера

Shadow DOM v1 поставляется в Chrome 53 ( статус ), Opera 40, Safari 10 и Firefox 63. Edge начал разработку .

Чтобы обнаружить теневой DOM, проверьте наличие attachShadow :

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Полифилл

Пока поддержка браузеров не станет широко доступной, полифилы shadydom и shadycss предоставляют вам функцию v1. Shady DOM имитирует область действия DOM Shadow DOM и пользовательских свойств CSS-полифилов shadycss, а также область действия стиля, предоставляемую собственным API.

Установите полифилы:

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

Используйте полифилы:

function loadScript(src) {
    return new Promise(function(resolve, reject) {
    const script = document.createElement('script');
    script.async = true;
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
    loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
        // Polyfills loaded.
    });
} else {
    // Native shadow dom v1 support. Go to go!
}

См. https://github.com/webcomComponents/shadycss#usage для получения инструкций о том, как регулировать/ограничивать ваши стили.

Заключение

Впервые у нас есть примитив API, который обеспечивает правильную область видимости CSS, область видимости DOM и имеет настоящую композицию. В сочетании с другими API-интерфейсами веб-компонентов, такими как пользовательские элементы, теневой DOM предоставляет возможность создавать по-настоящему инкапсулированные компоненты без хаков или использования старого багажа, такого как <iframe> .

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

дальнейшее чтение

Часто задаваемые вопросы

Могу ли я использовать Shadow DOM v1 сегодня?

Да, с полифилом. См. Поддержка браузера .

Какие функции безопасности предоставляет теневой DOM?

Shadow DOM не является функцией безопасности. Это легкий инструмент для определения границ CSS и сокрытия деревьев DOM в компоненте. Если вам нужна настоящая граница безопасности, используйте <iframe> .

Должен ли веб-компонент использовать теневой DOM?

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

В чем разница между открытыми и закрытыми теневыми корнями?

См. Закрытые теневые корни .