深入解析 Web Components 之 Shadow DOM 实战指南

本文详细探讨了 Web Components 中的 Shadow DOM 技术,包括其封装原理、创建方式(命令式与声明式)、配置选项及插槽机制。通过实际代码示例展示如何实现组件隔离、样式封装和安全性控制,帮助开发者掌握构建可复用、高维护性的 Web 组件。

Web Components:Shadow DOM 实战指南

Web Components 不仅仅是自定义元素。Shadow DOM、HTML 模板和自定义元素各自扮演着重要角色。本文中,Russell Beswick 将演示 Shadow DOM 在整体架构中的定位,解释其重要性、适用场景及高效应用方法。

常见误区是将 Web Components 直接与框架组件对比,但多数示例仅聚焦于自定义元素——这只是 Web Components 的一部分。Web Components 实际上是一组可独立使用的 Web 平台 API:

  • 自定义元素
  • HTML 模板
  • Shadow DOM

换言之,可以不使用 Shadow DOM 或 HTML 模板创建自定义元素,但结合这些特性可提升稳定性、可复用性、可维护性和安全性。它们是同一功能集的不同部分,既可单独使用也可协同工作。

本文将重点解析 Shadow DOM 的定位。通过 Shadow DOM 可明确定义 Web 应用各部分的边界——将相关 HTML 和 CSS 封装在 DocumentFragment 中,实现组件隔离、避免冲突并保持关注点分离。

Shadow DOM 的存在意义

现代 Web 应用通常由多种来源的库和组件构成。在传统(“轻量”)DOM 中,样式和脚本易相互泄漏或冲突。使用框架时虽可确保组件协同工作,但仍需确保元素 ID 唯一性且 CSS 规则严格限定作用域,这会导致代码冗余、增加加载时间并降低可维护性。

1
2
3
4
<!-- 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 通过隔离组件解决这些问题。<video><details> 元素是默认使用 Shadow DOM 的原生 HTML 元素典型案例,可避免全局样式或脚本干扰。利用这种驱动原生浏览器组件的隐藏能力,是 Web Components 与框架组件的本质区别。

可挂载 Shadow Root 的元素

Shadow Root 常与自定义元素关联,但也可用于任何 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” 设置进行查看。

创建 Shadow Root

命令式实例化

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

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

此例创建了开放模式的 Shadow Root,外部可查询其内容:

1
host.shadowRoot.querySelector('p'); // 选择段落元素

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

1
shadow.querySelector('p');

此为关键安全特性。例如银行信息组件包含用户账号时,开放模式允许任意页面脚本解析内容,而关闭模式仅用户可通过手动复制或检查元素访问。建议优先采用关闭模式,仅在调试或无法规避实际限制时使用开放模式。

声明式实例化

无需 JavaScript 也可利用 Shadow DOM。在支持元素内嵌套带 shadowrootmode 属性的 <template>,浏览器会自动升级元素并附加 Shadow Root。此方法即使禁用 JavaScript 仍可生效。

1
2
3
4
5
<my-widget>
  <template shadowrootmode="closed">
    <p> Declarative Shadow DOM content </p>
  </template>
</my-widget>

模式同样可为开放或关闭。若与已注册自定义元素结合使用,可通过 ElementInternals 访问自动附加的 Shadow Root:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class MyWidget extends HTMLElement {
  #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 的标准元素时,仅得到不包含 Shadow Root 内容的浅拷贝。新特性允许选择性克隆组件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<div id="original">
  <template shadowrootmode="closed" shadowrootclonable>
    <p> This is a test  </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

启用后可通过 Element.getHTML() 获取 Shadow DOM 当前状态的字符串表示,用于注入其他宿主或缓存。注意 Chrome 中此功能可通过关闭的 Shadow Root 工作,需警惕意外泄露用户数据。更安全的替代方案是使用封闭包装器内部保持开放:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<wrapper-element></wrapper-element>

<script>
  class WrapperElement extends HTMLElement {
    #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> Deep Shadow DOM Content </p>
              </template>
            </div>
          </template>
        </nested-element>
      `);
      this.cloneContent();
    }
    cloneContent() {
      const nested = this.#shadow.querySelector('nested-element');
      const snapshot = nested.getHTML({ serializableShadowRoots: true });
      const temp = document.createElement('div');
      temp.setHTMLUnsafe(`<another-element>${snapshot}</another-element>`);
      const copy = temp.querySelector('another-element');
      copy.shadowRoot.querySelector('#test').shadowRoot.querySelector('p').textContent = 'Changed Content!';
      this.#shadow.append(copy);
    }
  }
  customElements.define('wrapper-element', WrapperElement);
  const wrapper = document.querySelector('wrapper-element');
  const test = wrapper.getHTML({ serializableShadowRoots: true });
  console.log(test); // 因关闭的 Shadow Root 返回空字符串
</script>

注意:因内容包含 <template> 元素,需使用 setHTMLUnsafe() 方法注入可信内容,使用 innerHTML 不会触发自动初始化为 Shadow Root。

选项 3: delegatesFocus:true

此选项使宿主元素充当内部内容的 <label>,点击宿主或调用 .focus() 时将光标移至 Shadow Root 内首个可聚焦元素,同时为宿主应用 :focus 伪类,特别适用于表单组件:

1
2
3
4
5
6
7
8
9
<custom-input>
  <template shadowrootmode="closed" shadowrootdelegatesfocus>
    <fieldset>
      <legend> Custom Input </legend>
      <p> Click anywhere on this element to focus the input </p>
      <input type="text" placeholder="Enter some text...">
    </fieldset>
  </template>
</custom-input>

注意:封装特性导致表单提交不会自动连接输入值,表单验证和状态也无法传出 Shadow DOM,可访问性方面也存在 ARIA 连接问题。这些表单特定问题需通过 ElementInternals 解决,后续文章将专门讨论。

插槽内容

目前仅涉及完全封装的组件。Shadow DOM 关键特性是使用插槽选择性注入内容到组件内部结构。每个 Shadow Root 可有一个默认(未命名)<slot>,其他必须命名。命名插槽允许用户填充特定部分,并可设置回退内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<my-widget>
  <template shadowrootmode="closed">
    <h2><slot name="title"><span>Fallback Title</span></slot></h2>
    <slot name="description"><p>A placeholder description.</p></slot>
    <ol><slot></slot></ol>
  </template>
  <span slot="title"> A Slotted Title</span>
  <p slot="description">An example of using slots to fill parts of a component.</p>
  <li>Foo</li>
  <li>Bar</li>
  <li>Baz</li>
</my-widget>

默认插槽也支持回退内容,但需压缩宿主元素标记中的所有空白文本节点:

1
2
3
<my-widget><template shadowrootmode="closed">
  <slot><span>Fallback Content</span></slot>
</template></my-widget>

插槽元素在 assignedNodes() 添加或移除时触发 slotchange 事件,事件不包含插槽或节点引用,需手动传递:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class SlottedWidget extends HTMLElement {
  #internals;
  #shadow;
  constructor() {
    super();
    this.#internals = this.attachInternals();
    this.#shadow = this.#internals.shadowRoot;
    this.configureSlots();
  }
  configureSlots() {
    const slots = this.#shadow.querySelectorAll('slot');
    console.log({ slots });
    slots.forEach(slot => {
      slot.addEventListener('slotchange', () => {
        console.log({
          changedSlot: slot.name || 'default',
          assignedNodes: slot.assignedNodes()
        });
      });
    });
  }
}
customElements.define('slotted-widget', SlottedWidget);

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

1
2
3
4
5
const widget = document.querySelector('slotted-widget');
const added = document.createElement('p');
added.textContent = 'A secondary paragraph added using a named slot.';
added.slot = 'description';
widget.append(added);

注意插槽内容属于 “轻量” DOM 而非 Shadow DOM,可直接从文档对象查询:

1
2
const widgetTitle = document.querySelector('my-widget [slot=title]');
widgetTitle.textContent = 'A Different Title';

内部访问这些元素需使用 this.childrenthis.querySelector,仅 <slot> 元素本身可通过 Shadow DOM 查询,其内容不可直接访问。

从入门到精通

现在您已了解为何使用 Shadow DOM、何时集成及当前实践方法。但 Web Components 之旅不止于此,本文仅涵盖标记和脚本,样式封装将是后续文章的重点主题。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计