Shadow DOM v1 - 独立的 Web 组件

Shadow DOM 可让网络开发者为网络组件创建独立的 DOM 和 CSS

摘要

Shadow DOM 消除了构建 Web 应用的脆弱性。脆弱性是由 HTML、CSS 和 JS 的全局性引起的。多年来,我们发明了大量tools来规避这些问题。例如,当您使用新的 HTML ID/类时,我们无法判断它是否会与网页使用的现有名称相冲突。细微 bug 层出不穷,CSS 特异性成为大问题(全都 !important!),样式选择器变得失控,并且性能可能会受到影响。不一而足。

Shadow DOM 修复了 CSS 和 DOM。它向 Web 平台引入了作用域样式。在没有工具或命名惯例的情况下,您可以使用原版 JavaScript 将 CSS 与标记捆绑,隐藏实现详情,以及编写独立的组件

简介

Shadow DOM 是三种 Web 组件标准之一:HTML 模板Shadow DOM自定义元素HTML 导入曾经是列表中的一部分,但现在被视为已弃用

您无需编写使用 shadow DOM 的网络组件。但使用这种方式时,您可充分利用其优势(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 的作用域为相应 CSS。样式规则不会泄露,页面样式也不会渗入。
  • 组合:为组件设计一个基于标记的声明式 API。
  • 简化 CSS - 限定范围的 DOM 意味着您可以使用简单的 CSS 选择器和更通用的 ID/类名称,而不必担心命名冲突。
  • 效率 - 将应用视为多个 DOM 块,而不是一个大的(全局)页面。

fancy-tabs演示

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

在 GitHub 上查看源代码

什么是 shadow DOM?

DOM 相关背景

HTML 因其易于使用而推动网络的发展。通过声明几个标记,即可在几秒钟内编写出既具有呈现效果又有结构的页面。不过,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 树,该 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 最难理解的功能之一,但可以说是最重要的功能。

在网络开发世界中,组合是指我们如何使用 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 开发相关的新的基础知识。在开始讨论之前,我们先规范一些术语,这样我们就能讲同样的行话。

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

阴影 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 的结果,对最终产品进行渲染。扁平化树是指您在开发者工具中最终看到的内容以及在页面上渲染的内容。

<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 的边界。这些元素称为“分布式节点”。从概念上讲,分布式节点似乎有点奇怪。Slot 实际上并不移动 DOM;它们会在 shadow DOM 内的其他位置进行渲染。

组件可在其 shadow DOM 中定义零个或多个 slot。槽位可以为空,也可以提供回退内容。如果用户不提供轻量级 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>

您还可以创建已命名槽。已命名 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> 一样!

样式

有多种选项可用于设置网络组件的样式。使用 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> 能够根据您声明的属性为其设置不同的样式。网络组件也可以使用 :host 选择器对自身进行样式设置。

示例 - 组件自行设置样式

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

使用 :host 的一个问题是,父页面中的规则比在元素中定义的 :host 规则具有更高的特异性。也就是说,外部样式优先。这可让用户从外部替换您的顶级样式。此外,:host 仅适用于影子根,因此您无法在 shadow DOM 之外使用。

如果主机与 <selector> 匹配,您可以使用 :host(<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>

如果 :host-context(.darktheme).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 DOM 的另一情况称为“闭合”模式。创建闭合影子树后,在 JavaScript 外部无法访问组件的内部 DOM。这与 <video> 等原生元素的工作原理类似。JavaScript 无法访问 <video> 的 shadow DOM,因为浏览器使用闭合模式的影子根来实现此 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
  • 对于与 Shadow DOM 内的元素关联的事件,返回 Event.composedPath(),返回 []

在任何时候都不使用 {mode: 'closed'} 创建网络组件的原因如下:

  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 相关联非常有用。调用 slot.assignedNodes() 可查找插槽正在呈现哪些元素。{flatten: true} 选项还将返回槽的回退内容(如果未分发任何节点)。

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

<slot><b>fallback content</b></slot>
用法Call结果
<my-component>组件文本</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 提供的封装。也就是说,系统会对事件进行重定向,以使其看起来像是来自组件,而不是来自 shadow DOM 中的内部元素。有些事件甚至不会传播到 shadow 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() 等)时的结果,点击“Clickable Shadow DOM text”,或者获得内部 <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,并且点击了“Clickable Shadow DOM text”(或点击元素 shadow DOM 中的其他空白区域)。
已点击 delegatesFocus: false 和“可点击的 Shadow DOM text”(或点击元素 shadow DOM 中的其他空白区域)。

提示和技巧

这些年,我学到了一些关于编写网络组件的知识。我觉得这些提示对编写组件和调试 shadow DOM 会比较有用。

使用 CSS 包含

通常,网络组件的布局/样式/绘制相当独立。在 :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 组件结构的理想占位符。

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

历史记录和浏览器支持

如果您最近几年一直在关注网络组件,您会发现 Chrome 35+/Opera 随附的旧版 shadow DOM 有一段时间了。Blink 将继续在一段时间内同时支持这两个版本。v0 规范提供了创建影子根(element.createShadowRoot 而非 v1 的 element.attachShadow)的其他方法。调用旧方法会继续创建具有 v0 语义的影子根,因此现有的 v0 代码不会中断。

如果您恰好对旧版 v0 规范感兴趣,请查看以下 html5rocks 文章:123。 此外,shadow DOM v0 和 v1 之间的差异也非常值得一提。

浏览器支持

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

如需启用 shadow DOM 检测功能,请检查是否存在 attachShadow

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

聚酯纤维

在浏览器广泛支持之前,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

总结

我们首次拥有了可以执行适当的 CSS 作用域和 DOM 作用域的 API 基元,并且拥有真正意义上的组合。与自定义元素等其他网络组件 API 结合使用时,shadow DOM 提供了一种编写真正封装的组件的方法,无需使用技巧,也不需要使用 <iframe> 等陈旧的东西。

别弄错了。Shadow DOM 无疑是一个复杂的野兽!值得我们去学习花点时间研究。学习新知识并提出问题!

深入阅读

常见问题解答

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

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

shadow DOM 提供哪些安全功能?

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

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

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

开放影子根和闭合影子根有什么区别?

请参阅闭合的影子根