Web Components 深入理解 Shadow DOM

人们常将  Web Components  与   框架组件   直接对比。但大多数示例实际上仅涉及 Web Components 技术栈中的 Custom Elements 部分。容易忽略的是,Web Components 实际上是一组可独立使用的 Web 平台 API:

  • Custom Elements
  • HTML Templates
  • Shadow DOM

换言之,开发者可以创建不使用 Shadow DOM 或 HTML Templates 的  Custom Element,但结合这些特性能够显著提升组件的稳定性、可复用性、可维护性和安全性。这些技术属于同一功能集,既可单独使用,也可组合运用。

本文将重点探讨  Shadow DOM  及其应用场景。通过 Shadow DOM,我们能够在 Web 应用程序的各个部分之间建立清晰的边界——将相关的 HTML 和 CSS 封装在  DocumentFragment  中,实现组件隔离、避免冲突,并保持关注点分离。

如何利用这种封装特性涉及多种权衡与实现方式。本文将深入探讨这些细节,后续文章还将分析如何高效处理封装样式。

Shadow DOM 的诞生背景

现代 Web 应用通常由来自不同供应商的库和组件组合而成。在传统("light")DOM 中,样式和脚本容易相互渗透或冲突。使用框架时,虽然可以信任所有组件都经过协同设计,但仍需确保元素 ID 唯一且 CSS 规则作用域尽可能精确。这往往导致代码冗长,既增加应用加载时间,又降低可维护性。

<!-- 典型的div嵌套问题 -->
<div
  id="my-custom-app-framework-landingpage-header"
  class="my-custom-app-framework-foo"
>
  <div>
    <div>
      <div>
        <div>
          <div><div>etc...</div></div>
        </div>
      </div>
    </div>
  </div>
</div>

Shadow DOM 通过组件隔离机制解决了这些问题。原生 HTML 元素如  <video>  和  <details>  默认就使用 Shadow DOM 来避免全局样式或脚本的干扰。掌握这种驱动原生浏览器组件的隐藏能力,正是 Web Components 区别于框架组件的关键所在。

Shadow DOM 在开发者工具中的展示

支持 Shadow Root 的元素类型

虽然 Shadow Root 最常与 Custom Elements 配合使用,但它也能应用于任何  HTMLUnknownElement  以及许多标准元素,包括:

  • <aside>
  • <blockquote>
  • <body>
  • <div>
  • <footer>
  • <h1>  至  <h6>
  • <header>
  • <main>
  • <nav>
  • <p>
  • <section>
  • <span>

每个元素只能拥有一个 Shadow Root。某些元素(如  <input>  和  <select>)已内置不可通过脚本访问的 Shadow Root。开发者可通过启用开发者工具中的  Show User Agent Shadow DOM  设置(默认关闭)来检查这些元素。

Chrome 开发者工具中的 DOM 设置

用户代理 Shadow Root 示例

创建 Shadow Root

要使用 Shadow DOM 的优势,首先需要在元素上建立  Shadow Root,可通过命令式或声明式两种方式实现。

命令式创建

使用 JavaScript 的  attachShadow({ mode })  方法创建 Shadow Root。mode  可设为  open(允许通过  element.shadowRoot  访问)或  closed(对外部脚本隐藏 Shadow Root)。

const host = document.createElement("div");
const shadow = host.attachShadow({ mode: "open" });
shadow.innerHTML = "<p>Hello from the Shadow DOM!</p>";
document.body.appendChild(host);

此例创建了  open  模式的 Shadow Root,允许外部查询其内容:

host.shadowRoot.querySelector("p"); // 选中段落元素

若需完全阻止外部脚本访问内部结构,可设为  closed  模式,此时  shadowRoot  属性返回  null。但创建时的  shadow  引用仍可在其作用域内访问:

shadow.querySelector("p");

这是重要的安全特性。例如显示银行信息的组件若使用  closed  模式,可确保恶意脚本无法提取用户账户信息。建议采用  closed-first 原则,仅在调试或确实无法规避限制时使用  open  模式。

声明式创建

无需 JavaScript 也能使用 Shadow DOM。在支持的元素内嵌套带有  shadowrootmode  属性的  <template>,浏览器会自动升级该元素并附加 Shadow Root:

<my-widget>
  <template shadowrootmode="closed">
    <p>声明式Shadow DOM内容</p>
  </template>
</my-widget>

同样支持  open  或  closed  模式。注意:除非与已注册的 Custom Element 配合使用,否则无法通过脚本访问  closed  模式内容。此时可通过  ElementInternals  访问自动附加的 Shadow Root:

class MyWidgetextendsHTMLElement {
  #internals;
  #shadowRoot;
  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.#shadowRoot = this.#internals.shadowRoot;
  }
  connectedCallback() {
    const p = this.#shadowRoot.querySelector("p");
    console.log(p.textContent); // 可正常访问
  }
}
customElements.define("my-widget", MyWidget);
export { MyWidget };

Shadow DOM 配置选项

除了  modeElement.attachShadow()  还支持三个配置选项。

选项 1:clonable:true

此前克隆带有 Shadow Root 的标准元素时,Node.cloneNode(true)  或  document.importNode(node,true)  只会返回宿主元素的浅拷贝(不含 Shadow Root 内容)。新增此选项后,可选择性地克隆组件:

<div id="original">
  <template shadowrootmode="closed" shadowrootclonable>
    <p>测试内容</p>
  </template>
</div>

<script>
  const original = document.getElementById("original");
  const copy = original.cloneNode(true);
  copy.id = "copy";
  document.body.append(copy); // 包含Shadow Root内容
</script>

选项 2:serializable:true

启用此选项可获取 Shadow Root 内容的字符串表示。在宿主元素上调用  Element.getHTML()  将返回 Shadow DOM 当前状态的模板副本(包括所有嵌套的  shadowrootserializable  实例)。需注意 Chrome 中此功能可穿透  closed Shadow Root,存在数据泄露风险。更安全的做法是使用  closed  包装器:

class WrapperElementextendsHTMLElement {
  #shadow;
  constructor() {
    super();
    this.#shadow = this.attachShadow({ mode: "closed" });
    this.#shadow.setHTMLUnsafe(`<nested-element>
        <template shadowrootmode="open" shadowrootserializable>
          <div id="test">
            <template shadowrootmode="open" shadowrootserializable>
              <p>深层Shadow DOM内容</p>
            </template>
          </div>
        </template>
      </nested-element>`);
    this.cloneContent();
  }
  // ...其余代码
}

注意必须使用  setHTMLUnsafe()  方法注入包含  <template>  的可信内容,使用  innerHTML  不会触发 Shadow Root 的自动初始化。

选项 3:delegatesFocus:true

此选项使宿主元素成为其内部内容的  <label>。点击宿主或对其调用  .focus()  时,焦点会移至 Shadow Root 中的第一个可聚焦元素,同时为宿主应用  :focus  伪类,特别适合创建表单组件:

<custom-input>
  <template shadowrootmode="closed" shadowrootdelegatesfocus>
    <fieldset>
      <legend>自定义输入框</legend>
      <p>点击此元素任意位置聚焦输入框</p>
      <input type="text" placeholder="输入文本..." />
    </fieldset>
  </template>
</custom-input>

需注意:表单提交不会自动连接 Shadow DOM 中的输入值,表单验证和状态也不会传出 Shadow DOM。类似地,Shadow Root 边界可能影响 ARIA 无障碍特性。这些问题可通过  ElementInternals  解决,或考虑使用 light DOM 表单。

插槽(Slotted)内容

目前我们只讨论了完全封装的组件。Shadow DOM 的关键特性是使用  插槽  将内容选择性地注入组件内部结构。每个 Shadow Root 可有一个默认(未命名)<slot>,其他插槽必须命名。命名插槽允许我们为组件特定部分提供内容,并为用户省略的插槽设置回退内容:

<my-widget>
  <template shadowrootmode="closed">
    <h2>
      <slot name="title"><span>默认标题</span></slot>
    </h2>
    <slot name="description"><p>默认描述文本。</p></slot>
    <ol>
      <slot></slot>
    </ol>
  </template>
  <span slot="title">自定义标题</span>
  <p slot="description">使用插槽填充组件内容的示例</p>
  <li>项目1</li>
  <li>项目2</li>
  <li>项目3</li>
</my-widget>

默认插槽也支持回退内容,但任何游离文本节点都会填充其中,因此需要压缩宿主元素标记中的所有空白:

<my-widget>
  <template shadowrootmode="closed">
    <slot><span>回退内容</span></slot>
  </template>
</my-widget>

当插槽的  assignedNodes()  发生变化时,会触发  slotchange  事件。这些事件不包含插槽或节点的引用,需手动传递:

class SlottedWidgetextendsHTMLElement {
  #internals;
  #shadow;
  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.#shadow = this.#internals.shadowRoot;
    this.configureSlots();
  }
  configureSlots() {
    const slots = this.#shadow.querySelectorAll("slot");
    slots.forEach((slot) => {
      slot.addEventListener("slotchange", () => {
        console.log({
          changedSlot: slot.name || "default",
          assignedNodes: slot.assignedNodes(),
        });
      });
    });
  }
}

多个元素可通过  slot  属性或脚本分配到单个插槽:

const widget = document.querySelector("slotted-widget");
const added = document.createElement("p");
added.textContent = "通过命名插槽添加的段落";
added.slot = "description";
widget.append(added);

注意插槽内容实际属于 "light" DOM 而非 Shadow DOM。与之前示例不同,这些元素可直接从  document  对象查询:

const widgetTitle = document.querySelector("my-widget [slot=title]");
widgetTitle.textContent = "修改后的标题";

要在类定义内部访问这些元素,需使用  this.children  或  this.querySelector。只有  <slot>  元素本身可通过 Shadow DOM 查询,其内容不可直接访问。

从入门到精通

现在您已了解:

  • 为何使用 Shadow DOM
  • 何时将其纳入开发工作
  • 如何立即开始使用

但 Web Components 的探索不止于此。本文仅涵盖标记和脚本部分,尚未涉及另一个重要方面:样式封装。这将是我们下一篇文章的主题。