声明式 Shadow DOM

一种直接在 HTML 中实现和使用 Shadow DOM 的新方法。

Mason Freed
Mason Freed

声明式 Shadow DOM 是一项标准网络平台功能,自 90 版起的 Chrome 便已支持此功能。请注意,此功能的规范在 2023 年发生了更改(包括将 shadowroot 重命名为 shadowrootmode),并且在 Chrome 124 版中发布了此功能的所有部分的最新标准化版本。

Shadow DOM 是三项 Web 组件标准之一,由 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 模块也会创建其阴影根并设置其内容。不过,许多 Web 应用需要在构建时在服务器端呈现内容或呈现为静态 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 DevTools 的 Elements 面板的 Shadow DOM 内容显示规范。例如,👍? 字符表示分位的 Light DOM 内容。

这为我们提供了 Shadow DOM 在静态 HTML 中的封装和槽位投影的优势。无需 JavaScript 即可生成整个树,包括影子根。

成分水合

声明式 Shadow DOM 可单独用作封装样式或自定义子项位置的方式,但与自定义元素结合使用时效果最佳。使用自定义元素构建的组件会自动从静态 HTML 升级。随着声明式 Shadow DOM 的引入,自定义元素现在可以在升级之前拥有影子根。

从包含声明式阴影根的 HTML 升级的自定义元素已附加了该影子根。这意味着,元素在实例化时已具备一个 shadowRoot 属性,无需您的代码明确创建一个。最好检查元素构造函数中任何现有的影子根的 this.shadowRoot。如果已有相应的值,则此组件的 HTML 会包含声明式阴影根。如果值为 null,说明 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 做出了一项细微更改,使现有组件能够正常运行:对具有现有声明式阴影根的元素调用 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 的大多数情况下,这不太可能发生问题,因为每个影子根的内容几乎完全相同。虽然服务器呈现的 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

为避免一些重要的安全注意事项,不能使用 innerHTMLinsertAdjacentHTML() 等 fragment 解析 API 创建声明式影子根。若要解析应用了声明式阴影根的 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 样式规则,因为这些元素尚未附加和填充影子根。采用这种方式,内容在“ready”后才会显示:

<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 解析过程中会被移除。

功能检测和浏览器支持

声明式 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 执行 polyfill 操作,我们可以扫描 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);

深入阅读