宣告式陰影 DOM

Mason Freed
Mason Freed

宣告式 Shadow DOM 是標準網路平台功能,Chrome 從第 90 版開始支援這項功能。請注意,這項功能的規格已於 2023 年變更 (包括將 shadowroot 重新命名為 shadowrootmode)。此外,這項功能也在 Chrome 124 版中推出的最新標準化版本。

瀏覽器支援

  • Chrome:111.
  • Edge:111。
  • Firefox:123。
  • Safari:16.4。

資料來源

Shadow DOM 是三種網頁元件標準 (由 HTML 範本自訂元素四捨五入) 之一。Shadow DOM 可讓您將 CSS 樣式範圍限定為特定 DOM 子樹狀結構,並將該子樹狀結構與文件的其他部分隔離。<slot> 元素可讓我們控制自訂元素的子項應插入陰影樹狀結構的位置。有了這些功能相結合,系統就能建立可重複使用的獨立元件,就像內建的 HTML 元素一樣,能流暢地整合至現有應用程式。

截至目前為止,使用 Shadow DOM 的唯一方法是使用 JavaScript 建構陰影根目錄:

const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

這類命令式 API 適用於用戶端轉譯:定義 Google Custom Elements 的 JavaScript 模組也會同時建立其陰影根,並設定內容。不過,許多網頁應用程式在建構時,需要將內容轉譯至伺服器端或靜態 HTML。訪客可能無法執行 JavaScript 時,能獲得合理的瀏覽體驗可能很重要。

伺服器端轉譯 (SSR) 的理由會因專案而異。為了符合無障礙規範,有些網站必須提供完整的伺服器轉譯 HTML,才能符合無障礙指南。有些網站則選擇提供無 JavaScript 基本體驗,藉此在連線速度緩慢或裝置上維持良好效能。

以往,使用 Shadow DOM 搭配「伺服器端轉譯」作業相當困難,因為系統沒有內建在伺服器產生的 HTML 中表示陰影根。將 Shadow Roots 附加至已算繪的 DOM 元素,也會在沒有這類元素的情況下,對效能造成影響。這可能會導致網頁在載入後發生版面配置位移,或是在載入陰影 Root 的樣式表時暫時顯示未樣式內容 (「FOUC」) 的閃爍情形。

宣告式 Shadow DOM (DSD) 有移除這項限制,將 Shadow DOM 導入伺服器。

如何建構宣告式陰影 Root 權限

宣告式陰影 Root 是具有 shadowrootmode 屬性的 <template> 元素:

<host-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
  <h2>Light content</h2>
</host-element>

HTML 剖析器會偵測具有 shadowrootmode 屬性的範本元素,並立即套用為其父項元素的陰影根。從上述範例載入純 HTML 標記會產生下列 DOM 樹狀結構:

<host-element>
  #shadow-root (open)
  <slot>
    ↳
    <h2>Light content</h2>
  </slot>
</host-element>

這個程式碼範例遵循 Chrome 開發人員工具元素面板顯示 Shadow DOM 內容的慣例。舉例來說, 字元代表插槽的 Light DOM 內容。

這樣我們就有 Shadow DOM 在靜態 HTML 中封裝和運算單元投影的好處。不需要 JavaScript 即可產生整個樹狀結構,包括陰影根。

元件飲水量

宣告式 Shadow DOM 可單獨用於封裝樣式或自訂子項刊登位置,但搭配自訂元素使用時最強大。使用自訂元素建立的元件會自動從靜態 HTML 升級。「宣告式陰影 DOM」推出後,自訂元素現在可以在升級之前具備陰影根層級。

從 HTML 升級的自訂元素 (其中包含宣告式陰影根層級) 已附加該陰影根層級。這表示當元素進行例項化時,元素將具備可用的 shadowRoot 屬性,無需明確建立程式碼。建議您檢查元素建構函式中是否有任何現有陰影根 this.shadowRoot。如果該元件已設有值,這個元件的 HTML 中就會包含宣告式陰影根 (Root) 權限。若為空值,表示 HTML 中沒有宣告式陰影根目錄,或瀏覽器不支援宣告式 Shadow DOM。

<menu-toggle>
  <template shadowrootmode="open">
    <button>
      <slot></slot>
    </button>
  </template>
  Open Menu
</menu-toggle>
<script>
  class MenuToggle extends HTMLElement {
    constructor() {
      super();

      // Detect whether we have SSR content already:
      if (this.shadowRoot) {
        // A Declarative Shadow Root exists!
        // wire up event listeners, references, etc.:
        const button = this.shadowRoot.firstElementChild;
        button.addEventListener('click', toggle);
      } else {
        // A Declarative Shadow Root doesn't exist.
        // Create a new shadow root and populate it:
        const shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `<button><slot></slot></button>`;
        shadow.firstChild.addEventListener('click', toggle);
      }
    }
  }

  customElements.define('menu-toggle', MenuToggle);
</script>

自訂元素已有一段時間,直到目前沒有必要檢查現有陰影根層級,才能使用 attachShadow() 建立。宣告式 Shadow DOM 包含小幅變更,可讓現有元件在下列情況中也能運作:在具有現有「宣告式」陰影根根層級的元素上呼叫 attachShadow() 方法,不會擲回錯誤。系統會改為清除並傳回宣告式陰影 Root 權限。這可讓未針對宣告式 Shadow DOM 建構的舊版元件繼續運作,因為宣告式根憑證會保留,直到建立命令式替換元件為止。

對於新建立的自訂元素,全新的 ElementInternals.shadowRoot 屬性可讓您明確取得元素現有宣告式陰影根層級 (包括開放和關閉) 的參照。這可用來檢查及使用任何宣告式陰影 Root 權限,但在未提供宣告的情況下,仍可改回使用 attachShadow()

class MenuToggle extends HTMLElement {
  constructor() {
    super();

    const internals = this.attachInternals();

    // check for a Declarative Shadow Root:
    let shadow = internals.shadowRoot;

    if (!shadow) {
      // there wasn't one. create a new Shadow Root:
      shadow = this.attachShadow({
        mode: 'open'
      });
      shadow.innerHTML = `<button><slot></slot></button>`;
    }

    // in either case, wire up our event listener:
    shadow.firstChild.addEventListener('click', toggle);
  }
}

customElements.define('menu-toggle', MenuToggle);

每個根一個陰影

宣告式陰影 Root 僅與父項元素相關聯。也就是說,影子根一律與相關聯的元素會放在同一位置。這項設計決策可確保影子根可像其他 HTML 文件一樣串流。此外,編寫及產生也很方便,因為新增元素的陰影根目錄不需要維護現有影子根的註冊資料庫。

將陰影根與父項元素建立關聯的有利於,多個元素無法從同一個宣告式陰影根 <template> 初始化。不過,在使用「宣告式陰影 DOM」的情況下,這不太可能如此,因為每個陰影根的內容通常都不相同。雖然伺服器算繪的 HTML 通常包含重複的元素結構,但其內容通常有所不同,例如文字或屬性的細微差異。由於序列化宣告式陰影 Root 的內容完全為靜態,因此只有在元素一致時,才能從單一宣告式陰影根根層級升級多個元素。最後,由於壓縮的效果,重複的類似陰影 Root 對網路傳輸大小的影響相對較小。

日後可能會重新造訪共用影子根。如果 DOM 獲得內建範本的支援,宣告式陰影根層級可視為範本,並將其例項化,藉此為特定元素建構陰影根。目前的「宣告式陰影 DOM」會限制單一元素的陰影根關聯,在日後顯示此可能性。

串流播放很酷

將宣告式 Shadow Roots 直接與父項元素建立關聯,可簡化升級作業並將其附加至該元素的程序。系統會在 HTML 剖析期間偵測宣告式陰影根目錄,並在遇到其「開啟」<template> 標記時立即附加。<template> 中的剖析 HTML 會直接剖析為陰影根目錄,因此可以「串流」:在接收時進行轉譯。

<div id="el">
  <script>
    el.shadowRoot; // null
  </script>

  <template shadowrootmode="open">
    <!-- shadow realm -->
  </template>

  <script>
    el.shadowRoot; // ShadowRoot
  </script>
</div>

僅剖析器

宣告式 Shadow DOM 是 HTML 剖析器的一項功能。這表示系統只會針對含有 shadowrootmode 屬性的 <template> 標記進行剖析及附加在 HTML 剖析期間出現的宣告式陰影根層級。換句話說,您可以在初始 HTML 剖析期間建構宣告式陰影根:

<some-element>
  <template shadowrootmode="open">
    shadow root content for some-element
  </template>
</some-element>

設定 <template> 元素的 shadowrootmode 屬性不會產生任何作用,範本仍會是一般範本元素:

const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowrootmode', 'open'); // this does nothing
div.appendChild(template);
div.shadowRoot; // null

為避免一些重要的安全性考量,您無法使用 innerHTMLinsertAdjacentHTML() 等片段剖析 API 建立宣告式陰影根。套用宣告式陰影 Root 權限來剖析 HTML 的唯一方法,就是使用 setHTMLUnsafe()parseHTMLUnsafe()

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  div.setHTMLUnsafe(html); // Shadow roots included
  const newDocument = Document.parseHTMLUnsafe(html); // Also here
</script>

時尚有型的伺服器轉譯

宣告式陰影根層級可以透過標準 <style><link> 標記完整支援內嵌和外部樣式表:

<nineties-button>
  <template shadowrootmode="open">
    <style>
      button {
        color: seagreen;
      }
    </style>
    <link rel="stylesheet" href="/comicsans.css" />
    <button>
      <slot></slot>
    </button>
  </template>
  I'm Blue
</nineties-button>

以這種方式指定的樣式也經過高度最佳化:如果相同的樣式表出現在多個宣告式陰影根層級,則系統只會載入並剖析一次。瀏覽器會使用所有陰影根層級共用的單一支援 CSSStyleSheet,進而消除重複的記憶體負擔。

宣告式 Shadow DOM 不支援可建構的樣式表。這是因為目前您無法在 HTML 中序列化可建構的樣式表,且在填入 adoptedStyleSheets 時也無法參照這些工作表。

如何避免無樣式的內容閃爍

我們發現,瀏覽器不支援宣告式 Shadow DOM 的一項潛在問題,就是避免「閃爍內容」(FOUC),「自訂元素」會顯示尚未升級的原始內容。在宣告式 Shadow DOM 推出前,常見避免 FOUC 的常見技術,是將 display:none 樣式規則套用至尚未載入的自訂元素 (因為這些項目尚未附加及填入陰影根層級)。採用這種方式,直到內容「就緒」後才會顯示:

<style>
  x-foo:not(:defined) > * {
    display: none;
  }
</style>

隨著宣告式 Shadow DOM 問世,自訂元素可在 HTML 中轉譯或撰寫,讓其陰影內容就位,而且在載入用戶端元件實作前已完成:

<x-foo>
  <template shadowrootmode="open">
    <style>h2 { color: blue; }</style>
    <h2>shadow content</h2>
  </template>
</x-foo>

在本例中,display:none「FOUC」會阻止宣告式陰影 Root 的內容顯示不過,如果移除這項規則,會導致瀏覽器在不支援宣告式 Shadow DOM 的情況下顯示不正確或未設定樣式的內容,直到宣告式陰影 DOM polyfill 載入並將陰影根範本轉換為真實陰影根範本為止。

幸好,只要修改 FOUC 樣式規則,就能在 CSS 中解決這個問題。在支援宣告式 Shadow DOM 的瀏覽器中,<template shadowrootmode> 元素會立即轉換為陰影根目錄,進而讓 DOM 樹狀結構中沒有 <template> 元素。如果瀏覽器不支援宣告式陰影 DOM,就會保留 <template> 元素,可用於防範 FOUC:

<style>
  x-foo:not(:defined) > template[shadowrootmode] ~ *  {
    display: none;
  }
</style>

修改過的自訂元素「FOUC」,而不是隱藏尚未定義的自訂元素當元素跟隨 <template shadowrootmode> 元素時,就會隱藏子項。定義自訂元素後,規則就不會再相符。在支援宣告式 Shadow DOM 的瀏覽器中,系統會忽略這項規則,因為 <template shadowrootmode> 子項會在 HTML 剖析期間遭到移除。

功能偵測和瀏覽器支援

宣告式 Shadow DOM 自 Chrome 90 版和 Edge 91 版起就已推出,但使用了稱為 shadowroot 的舊版非標準屬性,而不是標準化的 shadowrootmode 屬性。Chrome 111 和 Edge 111 支援新版 shadowrootmode 屬性和串流行為。

「宣告式 Shadow DOM」為新的網路平台 API,尚未對所有瀏覽器提供廣泛的支援。您可以檢查 HTMLTemplateElement 原型上是否存在 shadowRootMode 屬性,藉此偵測瀏覽器支援:

function supportsDeclarativeShadowDOM() {
  return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}

聚合物

為宣告式 Shadow DOM 建構簡化的 polyfill 相對簡單,因為 polyfill 不需要完全複製瀏覽器實作本身考量的時間語意或僅有剖析器的特徵。為了執行 polyfill 宣告式 Shadow DOM,我們可掃描 DOM 找出所有 <template shadowrootmode> 元素,然後將其轉換為父項元素上附加的 Shadow Roots。這項程序可以在文件準備就緒後完成,或是由自訂元素生命週期等更具體的事件觸發。

(function attachShadowRoots(root) {
  root.querySelectorAll("template[shadowrootmode]").forEach(template => {
    const mode = template.getAttribute("shadowrootmode");
    const shadowRoot = template.parentNode.attachShadow({ mode });

    shadowRoot.appendChild(template.content);
    template.remove();
    attachShadowRoots(shadowRoot);
  });
})(document);

延伸閱讀