宣告式陰影 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 是三個 Web Components 標準之一,其他兩個標準是 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 模組也會建立其陰影根目錄並設定內容。不過,許多網路應用程式都需要在建構期間,將內容算繪至伺服器端或靜態 HTML。這項功能對於無法執行 JavaScript 的訪客來說,可能相當重要,可提供合理的使用體驗。

伺服器端算繪 (SSR) 的使用理由因專案而異。為了符合無障礙指南,有些網站必須提供功能完整的伺服器算繪 HTML,其他網站則選擇提供無 JavaScript 的基礎體驗,以確保在連線速度較慢或裝置較舊的情況下,也能維持良好效能。

以往,Shadow DOM 與伺服器端轉譯的搭配使用方式相當複雜,因為伺服器產生的 HTML 中並沒有內建方式可用於表示 Shadow Root。將陰影根附加到已顯示 DOM 元素 (而非在沒有陰影根的情況下顯示) 時,也會影響效能。這可能會導致網頁載入後版面配置移位,或是在載入陰影根目錄的樣式表時,暫時顯示未設定樣式的內容閃爍畫面 (「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 開發人員工具 Elements 面板的慣例,用於顯示 Shadow DOM 內容。舉例來說, 字元代表插槽 Light DOM 內容。

這樣一來,我們就能在靜態 HTML 中享有 Shadow DOM 的封裝和投影功能。您不需要使用 JavaScript 即可產生整個樹狀結構,包括陰影根目錄。

元件補水

宣告式 Shadow DOM 可單獨使用,用於封裝樣式或自訂子項位置,但搭配自訂元素使用時,效果最強大。使用自訂元素建構的元件會自動從靜態 HTML 升級。隨著宣告式 Shadow DOM 的推出,自訂元素現在可以在升級前擁有陰影根。

從含有宣告式陰影根的 HTML 升級的自訂元素,會已附加該陰影根。這表示元素在例項化時,就會具有 shadowRoot 屬性,而不需要程式碼明確建立。建議您檢查 this.shadowRoot 是否有元素建構函式中的任何現有陰影根。如果已有值,這個元件的 HTML 就會包含宣告式陰影根目錄。如果值為空值,表示 HTML 中沒有宣告式陰影根,或是瀏覽器不支援宣告式陰影 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 包含一項小幅變更,可讓現有元件在以下情況下運作:在具有現有 宣告式 Shadow Root 的元素上呼叫 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);

每個根目錄一個陰影

宣告式陰影根只與其父項元素相關聯。也就是說,陰影根會一律與相關聯的元素一同放置。這項設計決策可確保陰影根源可像 HTML 文件的其他部分一樣流傳。這也方便撰寫和產生內容,因為在元素中加入陰影根時,不需要維護現有陰影根的註冊表。

將陰影根與其父項元素建立關聯的缺點是,無法從同一個宣告式陰影根 <template> 初始化多個元素。不過,在大多數使用宣告式 Shadow DOM 的情況下,這項差異不太可能造成影響,因為每個 shadow root 的內容很少會相同。雖然伺服器算繪的 HTML 通常含有重複的元素結構,但內容通常不同,例如文字或屬性略有差異。由於序列化的宣告式陰影根目錄內容完全為靜態,因此只有在元素剛好相同的情況下,從單一宣告式陰影根目錄升級多個元素才會有效。最後,由於壓縮的效果,重複的類似陰影根對網路傳輸大小的影響相對較小。

日後,我們可能會重新審視共用陰影根目錄。如果 DOM 支援內建範本,宣告式陰影根目錄可視為範本,可在實例化時為特定元素建構陰影根目錄。目前的宣告式 Shadow 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>

僅限剖析器

宣告式 Shadow DOM 是 HTML 剖析器的功能。也就是說,只有在 HTML 剖析期間,具有 shadowrootmode 屬性的 <template> 標記,才會剖析及附加宣告式陰影根。換句話說,宣告式陰影根目錄可以在初始 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()) 建立宣告式陰影根目錄。如要剖析已套用宣告式 Shadow 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> 元素。不支援宣告式 Shadow DOM 的瀏覽器會保留 <template> 元素,我們可以利用這個元素來防止 FOUC:

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

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

功能偵測和瀏覽器支援

自 Chrome 90 和 Edge 91 起,宣告式 Shadow DOM 就已可供使用,但它使用的是較舊的非標準屬性 shadowroot,而非標準化的 shadowrootmode 屬性。新版 shadowrootmode 屬性和串流行為可在 Chrome 111 和 Edge 111 中使用。

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

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

聚酯纖維

為宣告式 Shadow DOM 建構簡易的 polyfill 相對簡單,因為 polyfill 不需要完美複製瀏覽器實作所關心的時間點意義或僅限剖析器的特性。如要為宣告式 Shadow DOM 提供 polyfill,我們可以掃描 DOM 來找出所有 <template shadowrootmode> 元素,然後將這些元素轉換為父項元素上附加的 Shadow Root。這項程序可以在文件準備就緒時執行,也可以由自訂元素生命週期等更具體的事件觸發。

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

延伸閱讀