宣告式陰影 DOM

直接在 HTML 中導入及使用 Shadow DOM 的新方式。

Mason Freed
Mason Freed

宣告式 Shadow DOM 是網路平台功能,目前還在標準化程序中。在 Chrome 111 版中預設為啟用。

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 適用於用戶端轉譯:定義自訂元素的 JavaScript 模組也會建立其 Shadow Roots,並設定其內容。不過,許多網頁應用程式必須在建構時在伺服器端算繪內容,或算繪為靜態 HTML。這是為可能無法執行 JavaScript 的訪客提供合理體驗的重要環節。

「伺服器端算繪」(SSR) 的理由會因專案而異。有些網站必須提供功能完整的伺服器轉譯 HTML 才能符合無障礙指南,有些網站則會選擇提供基準無 JavaScript 體驗,設法在連線或裝置速度緩慢時提供良好效能。

以往,使用 Shadow DOM 與伺服器端算繪作業非常困難,因為在伺服器產生的 HTML 中,沒有內建可以表示陰影根層級的方式。將陰影根目錄附加至未經轉譯的 DOM 元素時,也會影響效能。這可能會導致頁面在載入後發生版面配置位移,或是在載入 Shadow Root 的樣式表時暫時閃爍未設定樣式的內容 (「FOUC」)。

「宣告式 Shadow DOM」 (DSD) 可移除這項限制,將 Shadow DOM 帶進伺服器。

建構宣告式陰影根

宣告式陰影根是具有 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 內容顯示慣例。例如,URL 字元代表了版位 Light DOM 內容。

因此,我們在靜態 HTML 中提供了 Shadow DOM 的封裝和版位投影功能。不需要 JavaScript 就能產生整個樹狀結構,包括陰影根。

元件飲水量

宣告式 Shadow DOM 本身可以用來封裝樣式或自訂子位置,但與自訂元素搭配使用時,最為實用。使用自訂元素建立的元件會從靜態 HTML 中自動升級。導入宣告式 Shadow DOM 後,自訂元素現在可以在升級前取得陰影根層級。

如果自訂元素是從 HTML 升級,且含有宣告式陰影根,則已附加陰影根層級。這表示在執行個體化時,該元素會具有 shadowRoot 屬性,而無需程式碼明確建立該元素。建議您在元素建構函式中,檢查 this.shadowRoot 是否有任何現有的陰影根。如果已有值,這個元件的 HTML 就會包含宣告式陰影根。如果這個值為空值,表示 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() 方法,「不會」擲回錯誤。而是先清空並傳回宣告式陰影根。這樣做可讓非用於宣告式 Shadow DOM 的舊版元件繼續運作,因為宣告式根會保留到建立命令式替代項目為止。

針對新建立的自訂元素,新的 ElementInternals.shadowRoot 屬性可讓您明確取得開啟和關閉元素現有宣告式陰影根的參照。這項功能可用於檢查及使用任何宣告式陰影根,但如果未提供宣告式陰影根,則會改回使用 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);

每個根 1 個陰影

宣告式陰影根只與其父項元素相關聯。這表示陰影根會一直與相關聯的元素在同一處。這項設計決策可確保陰影根金鑰能夠串流處理,就像 HTML 文件的其餘部分一樣。這對編寫及產生也很方便,因為在元素中新增陰影根,不需維護現有陰影根的註冊資料庫。

將陰影根與父項元素建立關聯,這樣做的優缺點在於,您無法從相同的宣告式陰影根層級 <template> 初始化多個元素。但是,在大多數使用宣告式 Shadow DOM 的情況下,此情況不太可能發生,因為每個陰影根的內容幾乎完全相同。雖然伺服器轉譯的 HTML 通常包含重複的元素結構,但其內容通常有所不同,例如文字或屬性略有不同。由於序列化宣告式陰影根的內容完全是靜態的,因此只有在元素相同的情況下,才能從單一宣告式陰影根升級多個元素。最後,由於壓縮的影響,重複類似的陰影根對網路傳輸大小的影響相對較小。

日後,或許可以重新造訪共用陰影根層級。如果 DOM 取得支援內建範本的支援功能,宣告式陰影根可視為例項化的範本,以便建構特定元素的陰影根層級。目前的宣告式陰影 DOM 設計限制了單一元素中的陰影根關聯,以便日後提供服務。

串流播放品質不錯

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

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

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

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

僅限剖析器

宣告式陰影 DOM 是 HTML 剖析器的一項功能。也就是說,系統只會剖析宣告式陰影根,並附加在 <template> 標記 (其包含 shadowrootmode 屬性) 的 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

為了避免某些重要的安全性考量,您無法使用片段剖析 API (例如 innerHTMLinsertAdjacentHTML()) 建立宣告式陰影根。套用宣告式陰影根層級來剖析 HTML 的唯一方法,是將新的 includeShadowRoots 選項傳遞至 DOMParser

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  const fragment = new DOMParser().parseFromString(html, 'text/html', {
    includeShadowRoots: true
  }); // Shadow root 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,由所有陰影根層級共用,以消除重複的記憶體負擔。

宣告式陰影 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」規則會阻止宣告式陰影根的內容顯示。不過,移除這項規則會導致不支援宣告式 Shadow DOM 的瀏覽器顯示不正確或未設定的內容,直到宣告式 Shadow DOM polyfill 載入並將陰影根範本轉換成真正的陰影根層級。

幸好,只要在 CSS 中修改 FOUC 樣式規則,即可解決這個問題。在支援宣告式 Shadow DOM 的瀏覽器中,<template shadowrootmode> 元素會立即轉換為陰影根,不會在 DOM 樹狀結構中保留任何 <template> 元素。不支援宣告式 Shadow DOM 的瀏覽器會保留 <template> 元素,但我們可以使用這個元素防範 FOUC:

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

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

功能偵測和瀏覽器支援

宣告式 Shadow DOM 自 Chrome 90 和 Edge 91 版起即可使用,但使用了名為 shadowroot 的舊版非標準屬性,而非標準化的 shadowrootmode 屬性。Chrome 111 和 Edge 111 支援新版 shadowrootmode 屬性和串流行為。

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

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

聚酯纖維

為宣告式 Shadow DOM 建構簡化的 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);

其他資訊