使用 Shadow DOM

自定义元素的关键在于封装,因为自定义元素顾名思义就是一段可复用的功能:它可以被插入到任何网页中并期望其能正常工作。因此,页面中运行的代码不应该能够通过修改自定义元素的内部实现来意外破坏它。Shadow DOM 允许您将一个 DOM 树附加到一个元素上,并使该树的内部结构对页面中运行的 JavaScript 和 CSS 隐藏起来。

本文将介绍 Shadow DOM 的基础知识。

概览

本文假设您已熟悉 DOM(文档对象模型) 的概念——它是一个由连接的节点组成的树状结构,代表了标记文档(在 Web 文档的情况下通常是 HTML 文档)中出现的各种元素和文本字符串。例如,考虑以下 HTML 片段:

html
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>DOM example</title>
  </head>
  <body>
    <section>
      <img src="dinosaur.png" alt="A red Tyrannosaurus Rex." />
      <p>
        Here we will add a link to the
        <a href="https://www.mozilla.org/">Mozilla homepage</a>
      </p>
    </section>
  </body>
</html>

此片段将生成以下 DOM 结构(不包括仅包含空白的文本节点):

- HTML
    - HEAD
        - META charset="utf-8"
        - TITLE
            - #text: DOM example
    - BODY
        - SECTION
            - IMG src="dinosaur.png" alt="A red Tyrannosaurus Rex."
            - P
                - #text: Here we will add a link to the
                - A href="https://www.mozilla.org/"
                    - #text: Mozilla homepage

Shadow DOM 允许将隐藏的 DOM 树附加到常规 DOM 树中的元素上——这个 shadow DOM 树始于一个 shadow root,您可以在其下像在普通 DOM 中一样附加任何元素。

SVG version of the diagram showing the interaction of document, shadow root and shadow host.

有几个 Shadow DOM 的术语需要了解:

  • Shadow host:Shadow DOM 附加到的常规 DOM 节点。
  • Shadow tree:Shadow DOM 内部的 DOM 树。
  • Shadow boundary:Shadow DOM 结束、常规 DOM 开始的位置。
  • Shadow root:Shadow 树的根节点。

您可以通过与非 shadow 节点完全相同的方式来操作 shadow DOM 中的节点——例如,添加子节点或设置属性,使用 element.style.foo 为单个节点设置样式,或者在 <style> 元素中为整个 shadow DOM 树添加样式。区别在于,shadow DOM 内部的代码无法影响其外部的任何内容,从而实现了方便的封装。

在 Shadow DOM 可供 Web 开发者使用之前,浏览器就已经在使用它来封装元素的内部结构了。例如,可以考虑一个 <video> 元素,其默认的浏览器控件已公开。在 DOM 中您看到的只是 <video> 元素,但它在其 shadow DOM 中包含一系列按钮和其他控件。Shadow DOM 规范使您能够操作自己自定义元素的 shadow DOM。

属性继承

Shadow tree 和 <slot> 元素会从它们的 shadow host 继承 dirlang 属性。

创建 Shadow DOM

通过 JavaScript 命令式创建

以下页面包含两个元素:一个 <div> 元素,其 id"host",以及一个包含一些文本的 <span> 元素。

html
<div id="host"></div>
<span>I'm not in the shadow DOM</span>

我们将使用 "host" 元素作为 shadow host。我们调用 host 上的 attachShadow() 来创建 shadow DOM,然后就可以像操作主 DOM 一样向 shadow DOM 添加节点。在此示例中,我们添加了一个单独的 <span> 元素。

js
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);

结果如下所示

通过 HTML 声明式创建

通过 JavaScript API 创建 Shadow DOM 对于客户端渲染的应用程序来说可能是一个不错的选择。对于其他应用程序,服务器端渲染的 UI 可能具有更好的性能,从而带来更好的用户体验。在这种情况下,您可以使用 <template> 元素来声明式地定义 Shadow DOM。实现此行为的关键在于 枚举型shadowrootmode 属性,该属性可以设置为 openclosed,与 attachShadow() 方法的 mode 选项具有相同的值。

html
<div id="host">
  <template shadowrootmode="open">
    <span>I'm in the shadow DOM</span>
  </template>
</div>

注意:默认情况下,<template> 的内容不会显示。在此情况下,由于包含了 shadowrootmode="open",因此 shadow root 会被渲染。在支持的浏览器中,该 shadow root 内的可见内容将被显示。

浏览器解析 HTML 后,它会用一个附加到父元素(在本例中是 <div id="host">)的 shadow root 包裹的内容替换 <template> 元素。生成的 DOM 树如下所示(DOM 树中没有 <template> 元素):

- DIV id="host"
  - #shadow-root
    - SPAN
      - #text: I'm in the shadow DOM

请注意,除了 shadowrootmode,您还可以使用 <template>shadowrootclonableshadowrootdelegatesfocus 等属性来指定生成的 shadow root 的其他属性。

JavaScript 封装

到目前为止,这可能看起来没什么。但让我们看看当页面中运行的代码尝试访问 shadow DOM 中的元素时会发生什么。

此页面与上一个页面相同,只是我们添加了两个 <button> 元素。

html
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
<br />

<button id="upper" type="button">Uppercase span elements</button>
<button id="reload" type="button">Reload</button>

点击“将 span 元素大写”按钮会找到页面中所有 <span> 元素并将它们的文本转换为大写。点击“重载”按钮只会重新加载页面,以便您可以再次尝试。

js
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);

const upper = document.querySelector("button#upper");
upper.addEventListener("click", () => {
  const spans = Array.from(document.querySelectorAll("span"));
  for (const span of spans) {
    span.textContent = span.textContent.toUpperCase();
  }
});

const reload = document.querySelector("#reload");
reload.addEventListener("click", () => document.location.reload());

如果您点击“将 span 元素大写”,您会发现 Document.querySelectorAll() 找不到我们 shadow DOM 中的元素:它们实际上对页面中的 JavaScript 是隐藏的。

Element.shadowRoot 和“mode”选项

在上面的示例中,我们将参数 { mode: "open" } 传递给了 attachShadow()。当 mode 设置为 "open" 时,页面中的 JavaScript 可以通过 shadow host 的 shadowRoot 属性访问您的 shadow DOM 的内部结构。

在此示例中,与之前一样,HTML 包含了 shadow host、主 DOM 树中的一个 <span> 元素以及两个按钮。

html
<div id="host"></div>
<span>I'm not in the shadow DOM</span>
<br />

<button id="upper" type="button">Uppercase shadow DOM span elements</button>
<button id="reload" type="button">Reload</button>

这次,“大写”按钮使用 shadowRoot 来查找 DOM 中的 <span> 元素。

js
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);

const upper = document.querySelector("button#upper");
upper.addEventListener("click", () => {
  const spans = Array.from(host.shadowRoot.querySelectorAll("span"));
  for (const span of spans) {
    span.textContent = span.textContent.toUpperCase();
  }
});

const reload = document.querySelector("#reload");
reload.addEventListener("click", () => document.location.reload());

这一次,页面中运行的 JavaScript 可以访问 shadow DOM 的内部结构。

{mode: "open"} 参数为页面提供了一种打破 shadow DOM 封装的方法。如果您不想赋予页面此能力,则改为传递 {mode: "closed"},此时 shadowRoot 返回 null

但是,您不应将其视为一种强大的安全机制,因为存在一些可以规避它的方法,例如通过页面中运行的浏览器扩展。它更多地是一种指示,表明页面不应访问您的 shadow DOM 树的内部结构。

CSS 封装

在这个版本的页面中,HTML 与原始版本相同。

html
<div id="host"></div>
<span>I'm not in the shadow DOM</span>

在 JavaScript 中,我们创建了 shadow DOM。

js
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);

这次,我们将有一些 CSS 来定位页面中的 <span> 元素。

css
span {
  color: blue;
  border: 1px solid black;
}

页面 CSS 不会影响 shadow DOM 内部的节点。

在 Shadow DOM 中应用样式

在本节中,我们将介绍在 shadow DOM 树中应用样式的两种不同方法:

在这两种情况下,在 shadow DOM 树中定义的样式都限定在该树的作用域内,因此正如页面样式不会影响 shadow DOM 中的元素一样,shadow DOM 样式也不会影响页面其余部分中的元素。

可构造样式表

要使用可构造样式表为 shadow DOM 中的页面元素设置样式,我们可以:

  1. 创建一个空的 CSSStyleSheet 对象。
  2. 使用 CSSStyleSheet.replace()CSSStyleSheet.replaceSync() 设置其内容。
  3. 通过将其分配给 ShadowRoot.adoptedStyleSheets 将其添加到 shadow root。

CSSStyleSheet 中定义的规则将作用于 shadow DOM 树,以及我们已为其分配的任何其他 DOM 树。

这里再次展示了包含我们的 host 和一个 <span> 的 HTML。

html
<div id="host"></div>
<span>I'm not in the shadow DOM</span>

这次我们将创建 shadow DOM 并为其分配一个 CSSStyleSheet 对象。

js
const sheet = new CSSStyleSheet();
sheet.replaceSync("span { color: red; border: 2px dotted black;}");

const host = document.querySelector("#host");

const shadow = host.attachShadow({ mode: "open" });
shadow.adoptedStyleSheets = [sheet];

const span = document.createElement("span");
span.textContent = "I'm in the shadow DOM";
shadow.appendChild(span);

在 shadow DOM 树中定义的样式不会在页面的其余部分应用。

<template> 声明中添加 <style> 元素

构造 CSSStyleSheet 对象的一种替代方法是,在用于定义 Web 组件的 <template> 元素中包含一个 <style> 元素。

在这种情况下,HTML 包含了 <template> 声明。

html
<template id="my-element">
  <style>
    span {
      color: red;
      border: 2px dotted black;
    }
  </style>
  <span>I'm in the shadow DOM</span>
</template>

<div id="host"></div>
<span>I'm not in the shadow DOM</span>

在 JavaScript 中,我们将创建 shadow DOM 并将 <template> 的内容添加到其中。

js
const host = document.querySelector("#host");
const shadow = host.attachShadow({ mode: "open" });
const template = document.getElementById("my-element");

shadow.appendChild(template.content);

同样,在 <template> 中定义的样式仅在 shadow DOM 树内部应用,而不在页面的其余部分应用。

选择编程方式还是声明式选项

选择哪种选项取决于您的应用程序和个人偏好。

通过 adoptedStyleSheets 创建一个 CSSStyleSheet 并将其分配给 shadow root,可以允许您创建一个样式表并将其共享到多个 DOM 树中。例如,组件库可以创建一个样式表,然后将其共享到属于该库的所有自定义元素中。浏览器将仅解析一次该样式表。此外,您还可以对样式表进行动态更改,并让这些更改传播到使用该样式表的所有组件。

如果您希望以声明式方式进行,样式较少,并且不需要在不同组件之间共享样式,那么附加 <style> 元素的方法非常有效。

Shadow DOM 与自定义元素

如果没有 Shadow DOM 提供的封装,自定义元素将极其脆弱。页面很容易通过运行一些页面 JavaScript 或 CSS 来意外破坏自定义元素的行为或布局。作为自定义元素开发者,您将无法知道应用于自定义元素内部的选择器是否与选择使用您的自定义元素的页面的选择器发生冲突。

自定义元素实现为一个类,该类扩展了基础的 HTMLElement 或内置 HTML 元素(如 HTMLParagraphElement)。通常,自定义元素本身就是一个 shadow host,元素在该 root 下创建多个元素,以提供元素的内部实现。

下面的示例创建了一个 <filled-circle> 自定义元素,它只渲染一个实心填充的圆。

js
class FilledCircle extends HTMLElement {
  constructor() {
    super();
  }
  connectedCallback() {
    // Create a shadow root
    // The custom element itself is the shadow host
    const shadow = this.attachShadow({ mode: "open" });

    // create the internal implementation
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
    const circle = document.createElementNS(
      "http://www.w3.org/2000/svg",
      "circle",
    );
    circle.setAttribute("cx", "50");
    circle.setAttribute("cy", "50");
    circle.setAttribute("r", "50");
    circle.setAttribute("fill", this.getAttribute("color"));
    svg.appendChild(circle);

    shadow.appendChild(svg);
  }
}

customElements.define("filled-circle", FilledCircle);
html
<filled-circle color="blue"></filled-circle>

有关说明自定义元素实现不同方面的更多示例,请参阅我们的 自定义元素指南

另见