Web组件:使用Shadow DOM

原文链接:Web Components: Working With Shadow DOM,2025年7月28,by Russell Beswick。
导读:Web组件不只是自定义元素那么简单。Shadow DOM、HTML模板和自定义元素各有其作用。在本文中,Russell Beswick将展示Shadow DOM在整体架构中的定位,解释其重要性、适用场景以及有效应用方法。

人们常将Web组件框架组件做对比,但大多数示例实际上仅针对自定义元素------而这只是Web组件的一部分。我们很容易忽略一个事实:Web组件其实是一组可单独使用的Web平台API集合,包括:

也就是说,我们可以创建不依赖Shadow DOMHTML模板自定义元素,但将这些特性结合使用,能增强组件的稳定性、可复用性、可维护性和安全性。它们是同一功能集的组成部分,既可单独使用,也可组合应用。

话虽如此,我想重点谈谈Shadow DOM及其定位。通过使用Shadow DOM ,我们可以在Web应用的各个部分之间划定清晰边界------将相关HTML和CSS封装DocumentFragment中,实现组件隔离、防止冲突,并保持关注点的清晰分离。

如何利用这种封装特性,涉及到权衡取舍和多种实现方式。本文将深入探讨这些细节,而在后续文章中,我们将深入讲解如何处理封装样式。

为什么会有Shadow DOM?

大多数现代Web应用都由来自不同提供商的各种库和组件组合而成。在传统(或"light")DOM中,样式和脚本很容易"泄露"或相互冲突。使用框架,你可以信任所有代码能无缝协作,但仍需努力确保所有元素都有唯一ID,而且CSS规则要做作用域限制。这往往会导致代码过于冗长,增加应用加载时间,降低可维护性。

html 复制代码
<!-- div嵌套地狱 -->
<div id="my-custom-app-framework-landingpage-header" class="my-custom-app-framework-foo">
  <div><div><div><div><div><div>等等......</div></div></div></div></div></div>
</div>

Shadow DOM的出现就是为了解决这些问题------它提供了一种隔离组件的方式。<video><details>元素就是很好的例子,这些原生HTML元素内部默认都使用了 Shadow DOM,避免全局样式或脚本的干扰。正是这种驱动原生浏览器组件的"隐藏能力",让Web组件与框架组件能真正区分开来。

可承载阴影根的元素

大多数情况下,阴影根(shadow root)与自定义元素关联,但它们也可以用在任何HTMLUnknownElement,许多标准元素也支持阴影根,包括:

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

每个元素只能有一个阴影根。有些元素(如<input><select>)已有内置的阴影根,且无法通过脚本访问。你可以在开发者工具(Developer Tools)中启用"显示用户代理Shadow DOM( Show User Agent Shadow DOM) "设置来查看它们(默认是关闭的)。

在Chrome开发者工具中的"显示用户代理DOM"设置

在DevTools中检查HTML <details> 元素的Shadow DOM

创建阴影根

要利用Shadow DOM的优势,首先需要在元素上创建阴影根。这可以通过命令式或声明式方式实现。

命令式创建

使用JavaScript创建阴影根时,可调用元素的attachShadow({ mode })方法。mode参数可以是open(允许通过element.shadowRoot访问)或closed(对外部脚本隐藏阴影根)。

js 复制代码
const host = document.createElement('div');
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<p>来自Shadow DOM的问候!</p>';
document.body.appendChild(host);

在这个示例中,我们创建了一个open模式的阴影根。这意味着元素内容可从外部访问,我们可以像查询其他DOM节点一样查询它:

js 复制代码
host.shadowRoot.querySelector('p'); // 查询<p>元素

如果想完全阻止外部脚本访问内部结构,可将模式设为closed。此时元素的shadowRoot属性会返回null,但我们仍能通过创建阴影根时的shadow引用来访问它:

js 复制代码
shadow.querySelector('p');

这是一项关键的安全特性。通过closed模式的阴影根,我们可以确保恶意攻击者无法从组件中提取用户隐私数据。例如,一个显示银行信息的组件可能包含用户账号------若使用open模式,页面上的任何脚本都能深入组件并解析内容;而在closed模式下,只有用户通过手动复制或检查元素才能执行此类操作。

我建议使用Shadow DOM时采用封闭优先(closed-first approach) 原则:养成使用closed模式的习惯,仅在调试时或确实遇到无法避免的实际限制时,才使用open模式。遵循这种方式,你会发现真正需要open模式的场景其实很少。

声明式创建

我们不必依赖JavaScript来使用Shadow DOM,也可以通过声明式方式注册阴影根。在任何支持的元素内嵌套带有shadowrootmode属性的<template>,浏览器会自动为该元素升级并创建阴影根。即使禁用JavaScript,这种方式也能生效。

html 复制代码
<my-widget>
  <template shadowrootmode="closed">
    <p>声明式Shadow DOM内容</p>
  </template>
</my-widget>

模式同样可以是openclosed。使用open模式前需考虑安全影响,但要注意:除非与已注册的自定义元素结合使用,否则无法通过脚本访问closed模式的内容。若结合自定义元素,可使用ElementInternals访问自动附加的阴影根:

js 复制代码
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

直到最近,如果一个标准元素已附加阴影根,当你使用Node.cloneNode(true)document.importNode(node,true)克隆它时,只能得到宿主元素的浅拷贝,而不包含阴影根内容。我们刚才看到的示例实际上会返回一个空的<div>。但对于内部构建阴影根的自定义元素,是没有这个问题。

但对于声明式Shadow DOM,这意味着每个元素都需要自己的模板,无法复用。有了这个新增特性,我们可以在需要时选择性地克隆组件:

html 复制代码
<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); // 包含阴影根内容
</script>

选项2:serializable:true

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

在Chrome中,即使是封闭的阴影根也支持此功能,因此使用时需注意避免意外泄露用户数据。更安全的方案是使用closed模式的外层容器隔离内部内容,同时保持内部为open模式:

html 复制代码
<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>深层Shadow DOM内容</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 = '修改后的内容!';
      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伪类,这在创建需参与表单交互的组件时特别有用。

html 复制代码
<custom-input>
  <template shadowrootmode="closed" shadowrootdelegatesfocus>
    <fieldset>
      <legend>自定义输入框</legend>
      <p>点击此元素任意位置聚焦输入框</p>
      <input type="text" placeholder="输入一些内容...">
    </fieldset>
  </template>
</custom-input>

这个示例仅展示了焦点委托。封装特性有一个特殊点:表单提交不会自动关联,这意味着输入框的值默认不会包含在表单提交数据中。表单验证和状态也无法传出Shadow DOM。无障碍访问也存在类似的关联问题------阴影根边界可能干扰ARIA属性。这些表单特有的问题可通过ElementInternals解决(这会是另一篇文章的主题),这也让我们思考:是否可以依赖light DOM表单替代?

插槽内容

到目前为止,我们只讨论了完全封装的组件。Shadow DOM的一个关键特性是使用插槽(slots)有选择地向组件内部结构注入内容。每个阴影根可以有一个默认(未命名)的<slot>,其他插槽必须命名。命名插槽允许我们为组件的特定部分提供内容,同时为用户未提供内容的插槽设置后备内容:

html 复制代码
<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>Foo</li>
  <li>Bar</li>
  <li>Baz</li>
</my-widget>

默认插槽也支持默认内容(fallback content),任何零散的文本节点都会填充它。因此,只有折叠宿主元素标记中的所有空白,默认内容才能生效:

html 复制代码
<my-widget><template shadowrootmode="closed">
  <slot><span>后备内容</span></slot>
</template></my-widget>

当插槽的assignedNodes()添加或移除时,<slot>元素会触发slotchange事件。这些事件不包含对插槽或节点的引用,因此需要在事件处理程序中传入这些信息:

js 复制代码
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 || '默认',
          assignedNodes: slot.assignedNodes()
        });
      });
    });
  }
}
customElements.define('slotted-widget', SlottedWidget);

多个元素可分配给同一个插槽,既可通过slot属性声明式实现,也可通过脚本实现:

js 复制代码
const widget = document.querySelector('slotted-widget');
const added = document.createElement('p');
added.textContent = '通过命名插槽添加的第二段文本。';
added.slot = 'description';
widget.append(added);

注意,此示例中的<p>元素被添加到宿主元素中。插槽内容实际上属于"light DOM",而非Shadow DOM。与前文示例不同,这些元素可直接通过document对象查询:

js 复制代码
const widgetTitle = document.querySelector('my-widget [slot=title]');
widgetTitle.textContent = '不同的标题';

如果想在类定义内部访问这些元素,可使用this.childrenthis.querySelector。只有<slot>元素本身可通过Shadow DOM查询,其内容不行。

从陌生到精通

现在你已经了解了为什么要使用Shadow DOM、何时应将其纳入开发工作,以及如何开始使用它。

但你的Web组件之旅不应止步于此。本文仅涵盖了标记和脚本部分,尚未涉及Web组件的另一个重要方面:样式封装(Style encapsulation) 。这将是我们下一篇文章的主题。

相关推荐
德育处主任16 分钟前
p5.js 掌握圆锥体 cone
前端·数据可视化·canvas
mazhenxiao19 分钟前
qiankunjs 微前端框架笔记
前端
无羡仙26 分钟前
事件流与事件委托:用冒泡机制优化前端性能
前端·javascript
秃头小傻蛋26 分钟前
Vue 项目中条件加载组件导致 CSS 样式丢失问题解决方案
前端·vue.js
CodeTransfer26 分钟前
今天给大家搬运的是利用发布-订阅模式对代码进行解耦
前端·javascript
阿邱吖28 分钟前
form.item接管受控组件
前端
韩劳模30 分钟前
基于vue-pdf实现PDF多页预览
前端
鹏多多30 分钟前
js中eval的用法风险与替代方案全面解析
前端·javascript
KGDragon30 分钟前
还在为 SVG 烦恼?我写了个 CLI 工具,一键打包,性能拉满!(已开源)
前端·svg
LovelyAqaurius30 分钟前
JavaScript中的ArrayBuffer详解
前端