宣告式陰影 DOM

直接在 HTML 中實作及使用 Shadow DOM 的新方式。

Mason Freed
Mason Freed

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

Shadow DOM 是三種網頁元件標準之一,並由 HTML 範本Custom Elements 四捨五入。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 自訂元素的 JavaScript 模組也會建立其陰影根,並設定內容。不過,許多網頁應用程式必須在建構期間,將內容轉譯至伺服器端或靜態 HTML。為可能無法執行 JavaScript 的訪客提供合理的體驗時,這可能會很重要。

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

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

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

建立宣告式陰影根

宣告式陰影 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 內容的慣例。例如 pdf 字元代表插槽的 Light DOM 內容。

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

元件飲水量

宣告式 Shadow DOM 可單獨用於封裝樣式或自訂子項位置,但與自訂元素搭配使用時最強大。使用自訂元素建構的元件會自動從靜態 HTML 升級。導入宣告式 Shadow 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> 初始化。不過,在使用宣告式 Shadow DOM 的大多數情況下,這可能不太重要,因為每個陰影根的內容通常都不相同。雖然伺服器算繪的 HTML 通常包含重複的元素結構,但其內容通常有所不同,例如文字或屬性的細微變化。由於序列化宣告式陰影 Root 的內容完全靜態,因此只有在元素狀態相同時,才能從單一宣告式陰影 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

為避免一些重要的安全性考量,您也無法使用片段剖析 API (例如 innerHTMLinsertAdjacentHTML()) 建立宣告式陰影根。套用宣告式陰影 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」規則會防止宣告式陰影根層級的內容無法顯示。不過,移除這項規則會導致不支援宣告 Shadow DOM 支援的瀏覽器顯示不正確或未設定樣式的內容,直到宣告式 Shadow 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> 元素,然後將元素轉換為其父項元素上附加的陰影根。這項程序可以在文件準備就緒後完成,或是由自訂元素生命週期等更具體的事件觸發。

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

其他資訊