Shadow DOM v1 - 自包含 Web 组件

Shadow DOM 允许 web 开发者为 web 组件创建隔离的 DOM 和 CSS

摘要

Shadow DOM 消除了构建 web 应用程序的脆弱性。这种脆弱性来自于 HTML、CSS 和 JS 的全局性质。多年来,我们发明了大量的工具方法技术来规避这些问题。例如,当您使用新的 HTML id/class 时,无法确定它是否会与页面使用的现有名称冲突。细微的错误悄然出现,CSS 特异性成为一个巨大的问题(到处都是 !important!),样式选择器变得失控,并且性能可能会受到影响。这样的例子不胜枚举。

Shadow DOM 修复了 CSS 和 DOM。它为 web 平台引入了作用域样式。无需工具或命名约定,您就可以将 CSS 与标记捆绑在一起,隐藏实现细节,并使用原生 JavaScript 编写自包含组件

简介

Shadow DOM 是三个 Web Component 标准之一:HTML 模板Shadow DOM自定义元素HTML Imports 曾经是列表的一部分,但现在被认为是已弃用

您不必编写使用 shadow DOM 的 web 组件。但是,当您这样做时,您将利用其优势(CSS 作用域、DOM 封装、组合),并构建可重用的 自定义元素,这些元素具有弹性、高度可配置且非常可重用。如果自定义元素是创建新的 HTML(带有 JS API)的方式,那么 shadow DOM 就是您提供其 HTML 和 CSS 的方式。这两个 API 结合在一起,构成了一个具有自包含 HTML、CSS 和 JavaScript 的组件。

Shadow DOM 被设计为构建基于组件的应用程序的工具。因此,它为 web 开发中的常见问题带来了解决方案

  • 隔离的 DOM:组件的 DOM 是自包含的(例如,document.querySelector() 不会返回组件 shadow DOM 中的节点)。
  • 作用域 CSS:在 shadow DOM 内部定义的 CSS 作用域限定于它。样式规则不会泄漏出去,页面样式也不会渗入。
  • 组合:为您的组件设计声明式的、基于标记的 API。
  • 简化 CSS - 作用域 DOM 意味着您可以使用简单的 CSS 选择器、更通用的 id/class 名称,而无需担心命名冲突。
  • 生产力 - 将应用程序视为 DOM 块,而不是一个大型(全局)页面。

fancy-tabs 演示

在本文中,我将参考一个演示组件(<fancy-tabs>)并引用其中的代码片段。如果您的浏览器支持这些 API,您应该在下面看到它的实时演示。否则,请查看 Github 上的完整源代码

在 Github 上查看源代码

什么是 shadow DOM?

DOM 背景知识

HTML 为 web 提供动力,因为它易于使用。通过声明几个标签,您可以在几秒钟内编写一个页面,该页面同时具有呈现和结构。但是,HTML 本身并没有那么有用。人类很容易理解基于文本的语言,但机器需要更多。输入文档对象模型,或 DOM。

当浏览器加载网页时,它会执行许多有趣的操作。其中一项操作是将作者的 HTML 转换为实时文档。基本上,为了理解页面的结构,浏览器将 HTML(静态文本字符串)解析为数据模型(对象/节点)。浏览器通过创建这些节点的树来保留 HTML 的层次结构:DOM。DOM 的酷之处在于它是页面的实时表示。与我们编写的静态 HTML 不同,浏览器生成的节点包含属性、方法,最重要的是……可以被程序操作!这就是为什么我们能够直接使用 JavaScript 创建 DOM 元素

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>

这一切都很好。那么 shadow DOM 到底是什么

阴影中的 DOM

Shadow DOM 只是普通的 DOM,但有两个不同之处:1) 它的创建/使用方式和 2) 它与页面其余部分的关系。通常,您创建 DOM 节点并将它们作为另一个元素的子元素追加。使用 shadow DOM,您可以创建一个作用域 DOM 树,该树附加到元素,但与其实际子元素分开。这个作用域子树称为 shadow 树。它附加到的元素是它的 shadow 主机。您在 shadow 中添加的任何内容都将成为主宿元素的本地内容,包括 <style>。这就是 shadow DOM 实现 CSS 样式作用域的方式。

创建 shadow DOM

shadow 根是一个文档片段,它被附加到“主机”元素。附加 shadow 根的行为是元素获得其 shadow DOM 的方式。要为元素创建 shadow 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 来填充 shadow 根,但您也可以使用其他 DOM API。这就是 web。我们有选择。

规范 定义了不能托管 shadow 树的元素列表。元素可能在该列表上的原因有几个

  • 浏览器已经为元素托管了自己的内部 shadow DOM(<textarea>, <input>)。
  • 元素托管 shadow DOM 没有意义(<img>)。

例如,这不起作用

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

为自定义元素创建 shadow DOM

当创建 自定义元素时,Shadow DOM 特别有用。使用 shadow DOM 来分隔元素的 HTML、CSS 和 JS,从而生成“web 组件”。

示例 - 自定义元素将 shadow 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>
    `;
    }
    ...
});

这里发生了一些有趣的事情。首先,自定义元素在创建 <fancy-tabs> 实例时创建了自己的 shadow DOM。这在 constructor() 中完成。其次,因为我们正在创建一个 shadow 根,所以 <style> 内的 CSS 规则将作用域限定为 <fancy-tabs>

组合和插槽

组合是 shadow DOM 最不被理解的功能之一,但它可以说是最重要的功能。

在我们的 web 开发世界中,组合是我们如何使用 HTML 以声明方式构建应用程序的方式。不同的构建块(<div>s、<header>s、<form>s、<input>s)组合在一起形成应用程序。其中一些标签甚至可以相互协作。组合是为什么像 <select><details><form><video> 这样的原生元素如此灵活的原因。这些标签中的每一个都接受某些 HTML 作为子元素,并对它们执行一些特殊操作。例如,<select> 知道如何将 <option><optgroup> 呈现为下拉列表和多选小部件。<details> 元素将 <summary> 呈现为可展开的箭头。即使是 <video> 也知道如何处理某些子元素:<source> 元素不会被呈现,但它们确实会影响视频的行为。多么神奇!

术语:light DOM 与 shadow DOM

Shadow DOM 组合在 web 开发中引入了许多新的基本概念。在深入细节之前,让我们标准化一些术语,以便我们使用相同的术语。

Light DOM

您的组件的用户编写的标记。此 DOM 位于组件的 shadow DOM 之外。它是元素的实际子元素。

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

Shadow DOM

组件作者编写的 DOM。Shadow DOM 是组件本地的,并定义其内部结构、作用域 CSS,并封装您的实现细节。它还可以定义如何呈现由您的组件的使用者编写的标记。

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

扁平化 DOM 树

浏览器将用户的 light DOM 分发到您的 shadow 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 使用 <slot> 元素将不同的 DOM 树组合在一起。插槽是组件内部的占位符,用户可以用自己的标记填充它们。通过定义一个或多个插槽,您可以邀请外部标记在组件的 shadow DOM 中呈现。本质上,您是在说“在这里呈现用户的标记”

<slot> 邀请元素进入时,允许元素“跨越” shadow DOM 边界。这些元素称为 分布式节点。从概念上讲,分布式节点可能看起来有点奇怪。插槽不会物理移动 DOM;它们在 shadow DOM 内的另一个位置呈现它。

组件可以在其 shadow DOM 中定义零个或多个插槽。插槽可以是空的或提供回退内容。如果用户不提供 light 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>

您还可以创建 命名插槽。命名插槽是 shadow DOM 中的特定孔洞,用户按名称引用它们。

示例 - <fancy-tabs> 的 shadow DOM 中的插槽

#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> 所做的那样!

样式

有很多用于设置 web 组件样式的选项。使用 shadow DOM 的组件可以由主页面设置样式,定义自己的样式,或提供钩子(以 CSS 自定义属性 的形式)供用户覆盖默认值。

组件定义的样式

Shadow DOM 最有用的功能无疑是 作用域 CSS

  • 来自外部页面的 CSS 选择器不适用于组件内部。
  • 内部定义的样式不会泄漏出去。它们的作用域限定为主机元素。

在 shadow DOM 内部使用的 CSS 选择器在本地应用于您的组件。在实践中,这意味着我们可以再次使用通用的 id/class 名称,而无需担心页面上其他地方的冲突。更简单的 CSS 选择器是 Shadow DOM 内部的最佳实践。它们也有利于性能。

示例 - 在 shadow 根中定义的样式是本地的

#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 树

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

有没有想过当您添加 multiple 属性时,<select> 元素如何呈现多选小部件(而不是下拉列表)

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

<select> 能够根据您在其上声明的属性以不同方式设置自身的样式。Web 组件也可以使用 :host 选择器来设置自身样式。

示例 - 组件自身设置样式

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

:host 的一个陷阱是父页面中的规则比元素中定义的 :host 规则具有更高的特异性。也就是说,外部样式获胜。这允许用户从外部覆盖您的顶级样式。此外,:host 仅在 shadow 根的上下文中工作,因此您不能在 shadow 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>

基于上下文设置样式

如果组件或其任何祖先与 <selector> 匹配,则 :host-context(<selector>) 与组件匹配。它的常见用途是基于组件周围环境进行主题化。例如,许多人通过将类应用于 <html><body> 来进行主题化

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

<fancy-tabs>.darktheme 的后代时,:host-context(.darktheme) 将设置 <fancy-tabs> 的样式

: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>

组件的 shadow 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> 不会移动用户的 light DOM。当节点分发到 <slot> 中时,<slot> 会呈现它们的 DOM,但节点在物理上保持不动。在分发之前应用的样式在分发之后继续应用。但是,当 light DOM 分发时,它可以采用额外的样式(由 shadow 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;
}

外部样式始终优先于 shadow 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>

在其 shadow DOM 内部

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

在这种情况下,组件将使用 black 作为背景值,因为用户提供了它。否则,它将默认为 #9E9E9E

高级主题

创建封闭 shadow 根(应避免)

还有另一种 shadow DOM 风格,称为“封闭”模式。当您创建封闭 shadow 树时,外部 JavaScript 将无法访问组件的内部 DOM。这类似于 <video> 等原生元素的工作方式。JavaScript 无法访问 <video> 的 shadow DOM,因为浏览器使用封闭模式 shadow 根来实现它。

示例 - 创建封闭 shadow 树

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
  • 与 shadow DOM 内部元素关联的事件的 Event.composedPath() 返回 []

这是我总结的为什么您永远不应该使用 {mode: 'closed'} 创建 web 组件的原因

  1. 人为的安全感。没有什么可以阻止攻击者劫持 Element.prototype.attachShadow

  2. 封闭模式阻止您的自定义元素代码访问其自身的 shadow 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. 封闭模式使您的组件对最终用户来说不那么灵活。当您构建 web 组件时,总会有一天您会忘记添加一个功能。一个配置选项。用户想要的一个用例。一个常见的例子是忘记为内部节点包含足够的样式钩子。使用封闭模式,用户无法覆盖默认值和调整样式。能够访问组件的内部结构非常有帮助。最终,如果您的组件没有满足他们的需求,用户将分叉您的组件、寻找另一个组件或创建自己的组件 :(

在 JS 中使用插槽

shadow DOM API 提供了用于处理插槽和分布式节点的实用程序。这些在编写自定义元素时会派上用场。

slotchange 事件

当插槽的分布式节点发生更改时,会触发 slotchange 事件。例如,如果用户从 light DOM 添加/删除子元素。

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

要监视 light DOM 的其他类型的更改,您可以在元素的构造函数中设置 MutationObserver

哪些元素正在插槽中呈现?

有时了解哪些元素与插槽关联很有用。调用 slot.assignedNodes() 以查找插槽正在呈现的元素。{flatten: true} 选项还将返回插槽的回退内容(如果没有节点正在分发)。

例如,假设您的 shadow DOM 看起来像这样

<slot><b>fallback content</b></slot>
用法调用结果
<my-component>组件文本</my-component> slot.assignedNodes(); [组件文本]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>回退内容</b>]

元素被分配给哪个插槽?

回答相反的问题也是可能的。element.assignedSlot 告诉您您的元素被分配到哪个组件插槽。

Shadow DOM 事件模型

当事件从 shadow DOM 向上冒泡时,其目标会被调整,以维护 shadow DOM 提供的封装。也就是说,事件被重新定向,使其看起来像是来自组件,而不是来自 shadow DOM 内部的元素。有些事件甚至不会从 shadow DOM 中传播出来。

确实 跨越 shadow 边界的事件是

  • 焦点事件:blurfocusfocusinfocusout
  • 鼠标事件:clickdblclickmousedownmouseentermousemove 等。
  • 滚轮事件:wheel
  • 输入事件:beforeinputinput
  • 键盘事件:keydownkeyup
  • 组合事件:compositionstartcompositionupdatecompositionend
  • DragEvent:dragstartdragdragenddrop 等。

提示

如果 shadow 树是开放的,则调用 event.composedPath() 将返回事件经过的节点数组。

使用自定义事件

除非使用 composed: true 标志创建事件,否则在 shadow 树内部节点上触发的自定义 DOM 事件不会冒泡到 shadow 边界之外

// 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(默认),使用者将无法在 shadow 根之外侦听事件。

<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>

处理焦点

如果您还记得 shadow DOM 的事件模型,则在 shadow DOM 内部触发的事件会被调整,使其看起来像是来自宿主元素。例如,假设您单击 shadow 根内部的 <input>

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

focus 事件看起来像是来自 <x-focus>,而不是 <input>document.activeElement 也将是 <x-focus>。如果 shadow 根是用 mode:'open' 创建的(请参阅 封闭模式),您也可以访问获得焦点的内部节点

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

如果存在多个级别的 shadow DOM(例如,一个自定义元素在另一个自定义元素内),您需要递归地深入到 shadow 根中以查找 activeElement

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

另一种焦点选项是 delegatesFocus: true 选项,它扩展了 shadow 树中元素的焦点行为

  • 如果您单击 shadow DOM 内部的节点,并且该节点不是可聚焦区域,则第一个可聚焦区域将获得焦点。
  • 当 shadow 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>

结果

delegatesFocus: true behavior.

上面是当 <x-focus> 获得焦点(用户单击、Tab 键进入、focus() 等)、单击“可点击的 Shadow DOM 文本”或内部 <input> 获得焦点(包括 autofocus)时的结果。

如果您将 delegatesFocus: false 设置为 false,您将看到以下内容

delegatesFocus: false and the internal input is focused.
delegatesFocus: false 并且内部 <input> 获得焦点。
delegatesFocus: false and x-focus
    gains focus (e.g. it has tabindex='0').
delegatesFocus: false 并且 <x-focus> 获得焦点(例如,它具有 tabindex="0")。
delegatesFocus: false and 'Clickable Shadow DOM text' is
    clicked (or other empty area within the element's shadow DOM is clicked).
delegatesFocus: false 并且单击“可点击的 Shadow DOM 文本”(或单击元素 shadow DOM 内的其他空白区域)。

提示与技巧

多年来,我了解了一些关于编写 web 组件的知识。我认为您会发现其中一些提示对于编写组件和调试 shadow DOM 很有用。

使用 CSS Containment

通常,web 组件的布局/样式/绘制是相当自包含的。在 :host 中使用 CSS Containment 可提高性能

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

重置可继承样式

可继承样式(backgroundcolorfontline-height 等)继续在 shadow DOM 中继承。也就是说,默认情况下,它们会穿透 shadow DOM 边界。如果您想从一个全新的状态开始,请使用 all: initial; 在可继承样式跨越 shadow 边界时将其重置为初始值。

<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>

查找页面使用的所有自定义元素

有时查找页面上使用的自定义元素很有用。为此,您需要递归遍历页面上使用的所有元素的 shadow 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> 创建元素

我们可以使用声明式 <template>,而不是使用 .innerHTML 填充 shadow 根。模板是声明 web 组件结构的理想占位符。

请参阅“自定义元素:构建可重用的 Web 组件”中的示例。

历史记录和浏览器支持

如果您在过去几年中一直关注 web components,您就会知道 Chrome 35+/Opera 已经发布了旧版本的 shadow DOM 一段时间了。Blink 将在一段时间内继续并行支持这两个版本。v0 规范提供了一种不同的方法来创建 shadow root(element.createShadowRoot 而不是 v1 的 element.attachShadow)。调用旧方法会继续创建具有 v0 语义的 shadow root,因此现有的 v0 代码不会中断。

如果您碰巧对旧的 v0 规范感兴趣,请查看 html5rocks 文章:123。还有一个关于 shadow DOM v0 和 v1 之间差异的精彩比较。

浏览器支持

Shadow DOM v1 已在 Chrome 53 (状态)、Opera 40、Safari 10 和 Firefox 63 中发布。Edge 已开始开发

要进行 shadow DOM 的特性检测,请检查 attachShadow 的存在。

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

Polyfill

在浏览器广泛支持之前,shadydomshadycss polyfill 为您提供 v1 功能。Shady DOM 模拟 Shadow DOM 的 DOM 作用域,而 shadycss polyfill CSS 自定义属性和原生 API 提供的样式作用域。

安装 polyfill

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

使用 polyfill

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/webcomponents/shadycss#usage,了解如何 shim/scope 您的样式的说明。

结论

有史以来第一次,我们拥有一个 API 原语,它可以实现正确的 CSS 作用域、DOM 作用域和真正的组合。结合其他 web component API(如自定义元素),shadow DOM 提供了一种编写真正封装的组件的方法,而无需 hack 或使用像 <iframe> 这样的旧包袱。

不要误解我的意思。Shadow DOM 确实是一个复杂的野兽!但它是一个值得学习的野兽。花一些时间学习它,并提出问题!

延伸阅读

FAQ

我今天可以使用 Shadow DOM v1 吗?

使用 polyfill,可以。请参阅浏览器支持

shadow DOM 提供哪些安全功能?

Shadow DOM 不是安全功能。它是一个轻量级工具,用于在组件中作用域 CSS 和隐藏 DOM 树。如果您想要真正的安全边界,请使用 <iframe>

web component 必须使用 shadow DOM 吗?

不用!您不必创建使用 shadow DOM 的 web component。但是,编写使用 Shadow DOM 的自定义元素意味着您可以利用 CSS 作用域、DOM 封装和组合等功能。

开放和封闭 shadow 根之间有什么区别?

请参阅封闭 shadow 根