声明式 Shadow DOM

Mason Freed
Mason Freed

声明式 Shadow DOM 是一项标准 Web 平台功能,从 Chrome 90 开始就受支持。请注意,此功能的规范在 2023 年发生了变化(包括将 shadowroot 重命名为 shadowrootmode),并且该功能的所有部分的最新标准化版本均已在 Chrome 124 中发布。

浏览器支持

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

来源

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 元素,也会影响性能。这可能会导致页面加载后布局发生偏移,或者在加载阴影根的样式表时暂时显示未设置样式的闪烁内容(“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 内容。

这样,我们就可以在静态 HTML 中获得 Shadow DOM 封装和插槽投影的好处。无需 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 样式规则应用于尚未加载的自定义元素,因为这些元素尚未附加和填充影子根。这样一来,内容在“准备就绪”之前不会显示:

<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 的浏览器中,系统会忽略此规则,因为 HTML 解析期间会移除 <template shadowrootmode> 子元素。

功能检测和浏览器支持

从 Chrome 90 和 Edge 91 开始,声明式 Shadow DOM 就已推出,但它使用的是名为 shadowroot 的旧版非标准属性,而不是标准化的 shadowrootmode 属性。Chrome 111 和 Edge 111 中提供了较新的 shadowrootmode 属性和流式传输行为。

作为一项新的 Web 平台 API,声明式 Shadow DOM 尚未在所有浏览器中获得广泛支持。您可以通过检查 HTMLTemplateElement 的原型上是否存在 shadowRootMode 属性来检测浏览器支持情况:

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

polyfill

为声明式 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);

深入阅读