使用 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 Tree 的根节点。

你可以以与非 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 后,会用一个 Shadow Root 替换 <template> 元素,该 Shadow Root 附加到父元素上,在本例中是 <div id="host">。最终的 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>

点击 "Uppercase span elements" 按钮会找到页面中所有的 <span> 元素,并将它们的文本更改为大写。点击 "Reload" 按钮会重新加载页面,以便你可以再次尝试。

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());

如果你点击 "Uppercase span elements",你会发现 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>

这一次,"Uppercase" 按钮使用 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 树,以及我们已经为其分配了 CSSStyleSheet 的任何其他 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 对象之外,还可以将一个 <style> 元素包含在用于定义 Web 组件的 <template> 元素中。

在这种情况下,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 树内,而不会应用于页面的其他地方

在编程和声明式选项之间进行选择

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

创建 CSSStyleSheet 并使用 adoptedStyleSheets 将其分配给影子根,允许您创建一个样式表并将其在多个 DOM 树之间共享。例如,组件库可以创建一个样式表,然后将其共享到该库的所有自定义元素中。浏览器将解析该样式表一次。此外,您可以对样式表进行动态更改,并使更改传播到使用该样式表的所有组件。

如果希望使用声明式方法,样式较少且不需要跨不同组件共享样式,则附加 <style> 元素的方法非常适合。

Shadow DOM 和自定义元素

如果没有影子 DOM 提供的封装,自定义元素 将难以维护。页面很容易意外地通过运行一些页面 JavaScript 或 CSS 来破坏自定义元素的行为或布局。作为自定义元素开发者,您永远不会知道自定义元素内部适用的选择器是否与选择使用您的自定义元素的页面中适用的选择器冲突。

自定义元素实现为一个类,该类扩展了基本 HTMLElement 或内置的 HTML 元素,例如 HTMLParagraphElement。通常,自定义元素本身是一个影子主机,元素在该根目录下创建多个元素,以提供元素的内部实现。

以下示例创建了一个 <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>

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

另请参见