声明式 Shadow DOM

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

Mason Freed
Mason Freed

声明式 Shadow DOM 是一项 Web 平台功能,目前处于标准化过程中。 Chrome 111 版中默认启用此功能。

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 内容。

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

组成部分水化

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

对于从包含声明性阴影根的 HTML 升级的自定义元素,系统将会附加该阴影根。这意味着,元素在实例化时将具有 shadowRoot 属性,而您的代码无需明确创建该属性。最好检查 this.shadowRoot 元素构造函数中是否存在任何现有的影子根。如果已有一个值,则此组件的 HTML 会包含声明性阴影根。如果值为 null,则表示 HTML 中没有声明式 Shadow Root,或者浏览器不支持声明式 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 包含一项可让现有组件正常运行的细微更改,尽管如此:对具有现有声明式 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 时,这不太可能会重要,因为每个影子根的内容很少完全相同。虽然服务器渲染的 HTML 通常包含重复的元素结构,但其内容通常会有所不同,例如文本或属性的细微差异。由于序列化的声明式影子根的内容是完全静态的,因此只有在元素发生相同时,才能从单个声明式影子根升级多个元素。最后,由于压缩的影响,重复的类似影子根对网络传输大小的影响相对较小。

将来,您或许可以重新访问共享影子根。如果 DOM 获得对内置模板的支持,声明式阴影根可被视为为了为给定元素构建影子根而实例化的模板。当前的声明式 Shadow DOM 设计通过将影子根关联限定为单个元素,实现了在未来存在这种可能性。

在线播放很酷

将声明式影子根直接与其父元素相关联可简化升级并将其附加到该元素的过程。声明式影子根会在 HTML 解析过程中检测出来,并在遇到其 opening <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 的唯一方法是将新的 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,从而消除了重复的内存开销。

声明式 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> 子级。

功能检测和浏览器支持

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

深入阅读