深入解析Shadow DOM:Web组件的封装利器

本文详细介绍了Web Components中的Shadow DOM技术,包括其作用、创建方式、配置选项及实际应用场景,帮助开发者理解如何利用Shadow DOM实现组件封装、样式隔离和安全性提升。

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与框架对应物的真正区别。

可以托管Shadow Root的元素

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

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

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

创建Shadow Root

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

命令式实例化

要使用JavaScript创建Shadow Root,请在元素上使用attachShadow({ mode })。模式可以是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。这意味着元素的内容可以从外部访问,我们可以像查询任何其他DOM节点一样查询它:

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

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

1
shadow.querySelector('p');

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

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

声明式实例化

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

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

同样,这可以是openclosed。在使用开放模式之前考虑安全影响,但请注意,除非此方法与注册的自定义元素一起使用,否则无法通过任何脚本访问封闭模式内容,在这种情况下,你可以使用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配置

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

选项1:clonable:true

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

但对于声明式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); // 包括Shadow Root内容
</script>

选项2:serializable:true

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

在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>

注意setHTMLUnsafe()。这是因为内容包含<template>元素。当注入这种性质的受信任内容时,必须调用此方法。使用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中传达出来。在可访问性方面也存在类似的连接问题,其中Shadow Root边界可能会干扰ARIA。这些都是特定于表单的考虑因素,我们可以使用ElementInternals来解决,这是另一篇文章的主题,并且是质疑你是否可以依赖轻量DOM表单的原因。

插槽内容

到目前为止,我们只看了完全封装的组件。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之旅不能在这里结束。在本文中,我们只涵盖了标记和脚本。我们甚至没有触及Web Components的另一个主要方面:样式封装。这将是另一篇文章的主题。

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