Shadow DOM v1 - 独立的 Web 组件

借助 Shadow DOM,Web 开发者可以为 Web 组件创建分隔的 DOM 和 CSS

摘要

shadow DOM 消除了构建 Web 应用的脆弱性。这种脆弱性源于 HTML、CSS 和 JS 的全球性质。多年来,我们发明了大量工具来规避这些问题。例如,当您使用新的 HTML id/class 时,无法确定它是否会与网页使用的现有名称冲突。细微的 bug 会逐渐出现,CSS 特异性会成为一个大问题(!important 所有这些!),样式选择器会失控,并且性能可能会受到影响。还有更多功能。

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

简介

Shadow DOM 是 Web Components 的三大标准之一,另外两个标准是 HTML 模板Shadow DOM自定义元素HTML 导入以前属于此列表,但现在被视为已废弃

您无需编写使用 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 会受限于 Shadow DOM。样式规则不会泄露,页面样式也不会渗透。
  • 组合:为组件设计基于标记的声明式 API。
  • 简化 CSS - 使用作用域 DOM 意味着您可以使用简单的 CSS 选择器、更通用的 ID/类名称,而无需担心命名冲突。
  • 效率 - 将应用视为 DOM 的多个部分,而不是一个大型(全局)页面。

fancy-tabs 演示

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

在 GitHub 上查看源代码

什么是 Shadow DOM?

DOM 背景知识

HTML 是 Web 的强大动力,因为它易于使用。只需声明几个标记,您就可以在几秒钟内编写出具有呈现效果和结构的网页。不过,HTML 本身并不是那么有用。人类很容易理解基于文本的语言,但机器需要更多信息。进入文档对象模型 (DOM)。

浏览器在加载网页时会执行一系列有趣的操作。其中一个功能是将作者的 HTML 转换为动态文档。基本上,为了了解网页的结构,浏览器会将 HTML(静态文本字符串)解析为数据模型(对象/节点)。浏览器通过创建这些节点的树(DOM)来保留 HTML 的层次结构。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 树。此作用域限定的子树称为“阴影树”。其附加的元素是其阴影主机。您在阴影中添加的任何内容都会成为托管元素(包括 <style>)的本地内容。这就是 Shadow DOM 实现 CSS 样式作用域的方式。

创建 Shadow DOM

阴影根是附加到“宿主”元素的文档 fragment。元素通过附加影子根来获得 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 来填充阴影根,但您也可以使用其他 DOM API。这是网络。我们有选择。

规范定义了无法托管阴影树的元素列表。元素出现在列表中的原因有多种:

  • 浏览器已为该元素托管自己的内部 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() 中完成。其次,由于我们要创建阴影根,因此 <style> 中的 CSS 规则将限定为 <fancy-tabs>

组合和槽

组合是 Shadow DOM 中最不为人所知的功能之一,但可以说是最重要的功能。

在 Web 开发领域,我们通过声明方式使用 HTML 构建应用,而组合就是这种方式。不同的构建块(<div><header><form><input>)组合在一起形成应用。其中一些代码甚至可以相互配合使用。正是由于组合,<select><details><form><video> 等原生元素才如此灵活。每个标记都接受特定 HTML 作为子级,并对其执行特殊操作。例如,<select> 知道如何将 <option><optgroup> 渲染为下拉菜单和多选 widget。<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 树组合在一起。槽是组件内的占位符,用户可以使用自己的标记填充这些占位符。通过定义一个或多个槽,您可以邀请外部标记在组件的阴影 DOM 中呈现。本质上,您是在说“在此处渲染用户的标记”。

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

组件可以在其阴影 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>

您还可以创建命名槽。命名槽是阴影 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/类名称,而无需担心页面上的其他位置会出现冲突。在 Shadow DOM 中,使用更简单的 CSS 选择器是最佳实践。它们对提升广告效果也有帮助。

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

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

您是否曾想过,在添加 multiple 属性后,<select> 元素如何呈现多选 widget(而非下拉菜单):

<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 仅在阴影根的上下文中有效,因此您无法在阴影 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) 会为其设置样式:

: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> 不会移动用户的轻量 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,因此组件将使用 black 作为背景值。否则,默认值为 #9E9E9E

高级主题

创建封闭的阴影根(应避免)

shadow DOM 还有另一种变体,称为“封闭”模式。创建封闭的阴影树后,外部 JavaScript 将无法访问组件的内部 DOM。这与 <video> 等原生元素的工作方式类似。JavaScript 无法访问 <video> 的 shadow DOM,因为浏览器使用封闭模式影子根实现了它。

示例 - 创建封闭的阴影树:

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'} 创建 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 事件。例如,如果用户从轻量 DOM 中添加/移除子元素。

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

如需监控对轻量 DOM 的其他类型更改,您可以在元素的构造函数中设置 MutationObserver

哪些元素正在槽中呈现?

有时,了解与某个槽相关联的元素会很有用。调用 slot.assignedNodes() 可查找槽正在渲染哪些元素。{flatten: true} 选项还会返回槽的后备内容(如果未分发任何节点)。

例如,假设您的 shadow DOM 如下所示:

<slot><b>fallback content</b></slot>
用法致电结果
<my-component>component text</my-component> slot.assignedNodes(); [component text]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

元素会分配到哪个槽位?

还可以回答相反的问题。element.assignedSlot 会告知您元素分配到的组件槽。

Shadow DOM 事件模型

当事件从 shadow DOM 向上冒泡时,系统会调整其目标,以保持 shadow DOM 提供的封装。也就是说,事件会重新定位,看起来像是来自组件,而不是来自阴影 DOM 中的内部元素。某些事件甚至不会传播到阴影 DOM 之外。

跨越阴影边界的事件包括:

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

提示

如果阴影树处于打开状态,调用 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>

处理焦点

如需回顾 Shadow DOM 的事件模型,请注意,在 Shadow 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.

如果存在多个级别的 Shadow DOM(例如,一个自定义元素位于另一个自定义元素内),您需要递归钻入影子根以找到 activeElement

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

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

  • 如果您点击 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:真实行为。

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

如果您设置 delegatesFocus: false,则会看到以下内容:

delegatesFocus:false,并且内部输入获得焦点。
delegatesFocus: false 和内部 <input> 处于聚焦状态。
delegatesFocus: false 且 x-focus 获得焦点(例如,它具有 tabindex=&#39;0&#39;)。
delegatesFocus: false 并使 <x-focus> 获得焦点(例如,它具有 tabindex="0")。
delegatesFocus: false,并且点击了“可点击的 Shadow DOM 文本”(或点击了元素 Shadow DOM 中的其他空白区域)。
delegatesFocus: false 并点击“可点击的 Shadow DOM 文本”(或点击元素 Shadow DOM 中的其他空白区域)。

提示和技巧

这些年来,我学到了一些有关 Web 组件编写方面的知识。我认为其中一些提示对于编写组件和调试 Shadow DOM 很有用。

使用 CSS 容器

通常,Web 组件的布局/样式/绘制是完全独立的。在 :host 中使用 CSS Containment 可提升性能:

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

重置可继承的样式

可继承的样式(backgroundcolorfontline-height 等)会继续在 Shadow DOM 中继承。也就是说,它们默认会穿透 shadow 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>

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

有时,查找页面上使用的自定义元素会很有用。为此,您需要递归遍历页面上使用的所有元素的 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 来填充阴影根。模板是声明 Web 组件结构的理想占位符。

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

历史记录和浏览器支持

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

如果您对旧版 v0 规范感兴趣,请参阅 html5rocks 文章:123。还有一篇非常棒的文章,比较了阴影 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 会为 CSS 自定义属性和原生 API 提供的样式作用域提供 polyfill。

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

总结

我们首次推出了能够进行适当 CSS 作用域设置、DOM 作用域设置且具有真正组合功能的 API 基元。与自定义元素等其他 Web 组件 API 结合使用时,Shadow DOM 提供了一种编写真正封装的组件的方法,而无需使用黑客攻击或 <iframe> 等旧版功能。

请不要误会。Shadow DOM 确实是一个复杂的怪物!但它是一门值得学习的课程。花些时间与它相处。了解它并提出问题!

深入阅读

常见问题解答

我现在可以使用 Shadow DOM v1 吗?

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

Shadow DOM 提供了哪些安全功能?

Shadow DOM 不是安全功能。这是一个轻量级工具,用于限定 CSS 的范围并隐藏组件中的 DOM 树。如果您想要真正的安全边界,请使用 <iframe>

网络组件是否必须使用 shadow DOM?

不对!您无需创建使用 shadow DOM 的 Web 组件。不过,编写使用 Shadow DOM 的自定义元素意味着您可以利用 CSS 作用域、DOM 封装和组合等功能。

开放式阴影根和封闭式阴影根有何区别?

请参阅已关闭的阴影根