深入解析 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 soup -->
<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 与框架对应物真正区别开来的地方。

在 DevTools 中检查 HTML <details> 元素的 Shadow DOM。(大预览)

可以托管 Shadow Root 的元素

最常见的是,您会看到影子根与自定义元素相关联。但是,它们也可以与任何 HTMLUnknownElement 一起使用,并且许多标准元素也支持它们,包括:

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

每个元素只能有一个影子根。一些元素,包括 <input><select>,已经有一个内置的影子根,无法通过脚本访问。您可以通过启用“显示用户代理 Shadow DOM”设置来使用开发者工具检查它们,该设置默认情况下为“关闭”。

在 Chrome 开发者工具中显示用户代理 DOM 设置。(大预览)

在 Chrome 开发者工具中检查用户代理影子根。(大预览)

创建影子根

在利用 Shadow DOM 的好处之前,首先需要在元素上建立影子根。这可以通过命令式或声明式实例化。

命令式实例化

要使用 JavaScript 创建影子根,请在元素上使用 attachShadow({ mode })。模式可以是 open(允许通过 element.shadowRoot 访问)或 closed(对外部脚本隐藏影子根)。

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);

在此示例中,我们建立了一个开放的影子根。这意味着元素的内容可以从外部访问,我们可以像查询任何其他 DOM 节点一样查询它:

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

如果我们想完全防止外部脚本访问我们的内部结构,可以将模式设置为 closed。这会导致元素的 shadowRoot 属性返回 null。我们仍然可以从创建它的范围内的影子引用访问它。

1
shadow.querySelector('p');

这是一个关键的安全功能。使用封闭的影子根,我们可以确信恶意行为者无法从我们的组件中提取私人用户数据。例如,考虑一个显示银行信息的小部件。也许它包含用户的帐号。使用开放的影子根,页面上的任何脚本都可以深入我们的组件并解析其内容。在封闭模式下,只有用户可以通过手动复制粘贴或检查元素来执行此类操作。

我建议在使用 Shadow DOM 时采用封闭优先的方法。养成使用封闭模式的习惯,除非您正在调试,或者只有在无法避免实际限制时才绝对必要。如果您遵循这种方法,您会发现实际上需要开放模式的情况很少见。

声明式实例化

我们不必使用 JavaScript 来利用 Shadow DOM。可以声明式注册影子根。在任何受支持的元素中嵌套带有 shadowrootmode 属性的 <template> 将导致浏览器自动升级该元素并附加影子根。即使禁用 JavaScript,也可以以这种方式附加影子根。

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

同样,这可以是开放的或封闭的。在使用开放模式之前考虑安全影响,但请注意,除非此方法与注册的自定义元素一起使用,否则您无法通过任何脚本访问封闭模式内容,在这种情况下,您可以使用 ElementInternals 访问自动附加的影子根:

 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 配置

除了模式之外,我们还可以向 Element.attachShadow() 传递三个其他选项。

选项 1:clonable:true

直到最近,如果一个标准元素附加了影子根,并且您尝试使用 Node.cloneNode(true)document.importNode(node,true) 克隆它,您只会得到宿主元素的浅拷贝,而没有影子根内容。我们刚刚查看的示例实际上会返回一个空的 <div>。这对于在内部构建自己的影子根的自定义元素来说从来不是问题。

但对于声明式 Shadow DOM,这意味着每个元素都需要自己的模板,并且它们不能被重用。通过这个新添加的功能,我们可以在需要时有选择地克隆组件:

 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); // 包括影子根内容
</script>

选项 2:serializable:true

启用此选项允许您保存元素影子根内内容的字符串表示形式。在宿主元素上调用 Element.getHTML() 将返回 Shadow DOM 当前状态的模板副本,包括所有嵌套的 shadowrootserializable 实例。这可用于将影子根的副本注入另一个宿主,或将其缓存以供以后使用。

在 Chrome 中,这实际上通过封闭的影子根工作,因此请小心意外泄漏用户数据。更安全的替代方法是使用封闭包装器来屏蔽内部内容免受外部影响,同时在内部保持开放:

 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); // 由于封闭的影子根,空字符串
</script>

注意 setHTMLUnsafe()。这是因为内容包含 <template> 元素。当注入这种性质的受信任内容时,必须调用此方法。使用 innerHTML 插入模板不会触发自动初始化为影子根。

选项 3:delegatesFocus:true

此选项本质上使我们的宿主元素充当其内部内容的 <label>。启用后,单击宿主上的任何位置或在其上调用 .focus() 会将光标移动到影子根中的第一个可聚焦元素。这还将 :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 来解决,这是另一篇文章的主题,并且是质疑您是否可以依赖轻量 DOM 表单的原因。

插槽内容

到目前为止,我们只看了完全封装的组件。Shadow DOM 的一个关键功能是使用插槽选择性地将内容注入组件的内部结构。每个影子根可以有一个默认(未命名)的 <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 之旅不能就此结束。在本文中,我们仅涵盖了标记和脚本。我们甚至还没有触及 Web Components 的另一个主要方面:样式封装。这将是另一篇文章的主题。

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