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

有關說明自定義元素實現不同方面的更多示例,請參閱我們的 自定義元素指南

另見