写点什么

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

作者:qife122
  • 2025-09-10
    福建
  • 本文字数:5026 字

    阅读完需:约 16 分钟

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

大多数现代 Web 应用由来自不同提供商的库和组件组成。在传统(或“轻量”)DOM 中,样式和脚本很容易相互泄漏或冲突。如果使用框架,可能相信所有内容都已编写为无缝协作,但仍需努力确保所有元素具有唯一 ID,并且 CSS 规则尽可能具体地限定范围。这可能导致代码过于冗长,既增加应用加载时间,又降低可维护性。


<!-- 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 的元素

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


  • <aside>

  • <blockquote>

  • <body>

  • <div>

  • <footer>

  • <h1><h6>

  • <header>

  • <main>

  • <nav>

  • <p>

  • <section>

  • <span>


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

创建 Shadow Root

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

命令式实例化

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


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 节点一样查询它:


host.shadowRoot.querySelector('p'); // 选择段落元素
复制代码


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


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


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


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

声明式实例化

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


<my-widget>  <template shadowrootmode="closed">    <p> Declarative Shadow DOM content </p>  </template></my-widget>
复制代码


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


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,这意味着每个元素都需要自己的模板,并且它们不能被重用。通过这个新添加的功能,可以在需要时有选择地克隆组件:


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


<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伪类应用于宿主,这在创建旨在参与表单的组件时特别有用。


<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>;所有其他必须命名。命名插槽允许我们提供内容以填充组件的特定部分,以及回退内容以填充用户省略的任何插槽:


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


默认插槽也支持回退内容,但任何杂散文本节点都会填充它们。因此,这仅在折叠宿主元素标记中的所有空白时才有效:


<my-widget><template shadowrootmode="closed">  <slot><span>Fallback Content</span></slot></template></my-widget>
复制代码


当添加或删除assignedNodes()时,插槽元素发出slotchange事件。这些事件不包含对插槽或节点的引用,因此需要将它们传递到事件处理程序中:


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属性声明式或通过脚本:


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。与到目前为止涵盖的示例不同,这些元素可以直接从文档对象查询:


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 的另一个主要方面:样式封装。这将是另一篇文章的主题。更多精彩内容 请关注我的个人公众号 公众号(办公 AI 智能小助手)公众号二维码


办公AI智能小助手


用户头像

qife122

关注

还未添加个人签名 2021-05-19 加入

还未添加个人简介

评论

发布
暂无评论
深入解析Web Components:Shadow DOM实战指南_前端开发_qife122_InfoQ写作社区