人们常将 Web Components 与 框架组件 直接对比。但大多数示例实际上仅涉及 Web Components 技术栈中的 Custom Elements 部分。容易忽略的是,Web Components 实际上是一组可独立使用的 Web 平台 API:
- Custom Elements
- HTML Templates
- Shadow DOM
换言之,开发者可以创建不使用 Shadow DOM 或 HTML Templates 的 Custom Element,但结合这些特性能够显著提升组件的稳定性、可复用性、可维护性和安全性。这些技术属于同一功能集,既可单独使用,也可组合运用。
本文将重点探讨 Shadow DOM 及其应用场景。通过 Shadow DOM,我们能够在 Web 应用程序的各个部分之间建立清晰的边界——将相关的 HTML 和 CSS 封装在 DocumentFragment 中,实现组件隔离、避免冲突,并保持关注点分离。
如何利用这种封装特性涉及多种权衡与实现方式。本文将深入探讨这些细节,后续文章还将分析如何高效处理封装样式。
Shadow DOM 的诞生背景
现代 Web 应用通常由来自不同供应商的库和组件组合而成。在传统("light")DOM 中,样式和脚本容易相互渗透或冲突。使用框架时,虽然可以信任所有组件都经过协同设计,但仍需确保元素 ID 唯一且 CSS 规则作用域尽可能精确。这往往导致代码冗长,既增加应用加载时间,又降低可维护性。
<!-- 典型的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 通过组件隔离机制解决了这些问题。原生 HTML 元素如 <video>
和 <details>
默认就使用 Shadow DOM 来避免全局样式或脚本的干扰。掌握这种驱动原生浏览器组件的隐藏能力,正是 Web Components 区别于框架组件的关键所在。
Shadow DOM 在开发者工具中的展示
支持 Shadow Root 的元素类型
虽然 Shadow Root 最常与 Custom Elements 配合使用,但它也能应用于任何 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 设置(默认关闭)来检查这些元素。
Chrome 开发者工具中的 DOM 设置
用户代理 Shadow Root 示例
创建 Shadow Root
要使用 Shadow DOM 的优势,首先需要在元素上建立 Shadow Root,可通过命令式或声明式两种方式实现。
命令式创建
使用 JavaScript 的 attachShadow({ mode })
方法创建 Shadow Root。mode
可设为 open
(允许通过 element.shadowRoot
访问)或 closed
(对外部脚本隐藏 Shadow Root)。
const host = document.createElement("div");
const shadow = host.attachShadow({ mode: "open" });
shadow.innerHTML = "<p>Hello from the Shadow DOM!</p>";
document.body.appendChild(host);
此例创建了 open
模式的 Shadow Root,允许外部查询其内容:
host.shadowRoot.querySelector("p"); // 选中段落元素
若需完全阻止外部脚本访问内部结构,可设为 closed
模式,此时 shadowRoot
属性返回 null
。但创建时的 shadow
引用仍可在其作用域内访问:
shadow.querySelector("p");
这是重要的安全特性。例如显示银行信息的组件若使用 closed
模式,可确保恶意脚本无法提取用户账户信息。建议采用 closed-first 原则,仅在调试或确实无法规避限制时使用 open
模式。
声明式创建
无需 JavaScript 也能使用 Shadow DOM。在支持的元素内嵌套带有 shadowrootmode
属性的 <template>
,浏览器会自动升级该元素并附加 Shadow Root:
<my-widget>
<template shadowrootmode="closed">
<p>声明式Shadow DOM内容</p>
</template>
</my-widget>
同样支持 open
或 closed
模式。注意:除非与已注册的 Custom Element 配合使用,否则无法通过脚本访问 closed
模式内容。此时可通过 ElementInternals 访问自动附加的 Shadow Root:
class MyWidgetextendsHTMLElement {
#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 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); // 包含Shadow Root内容
</script>
选项 2:serializable:true
启用此选项可获取 Shadow Root 内容的字符串表示。在宿主元素上调用 Element.getHTML()
将返回 Shadow DOM 当前状态的模板副本(包括所有嵌套的 shadowrootserializable
实例)。需注意 Chrome 中此功能可穿透 closed
Shadow Root,存在数据泄露风险。更安全的做法是使用 closed
包装器:
class WrapperElementextendsHTMLElement {
#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();
}
// ...其余代码
}
注意必须使用 setHTMLUnsafe() 方法注入包含 <template>
的可信内容,使用 innerHTML
不会触发 Shadow Root 的自动初始化。
选项 3:delegatesFocus:true
此选项使宿主元素成为其内部内容的 <label>
。点击宿主或对其调用 .focus()
时,焦点会移至 Shadow Root 中的第一个可聚焦元素,同时为宿主应用 :focus
伪类,特别适合创建表单组件:
<custom-input>
<template shadowrootmode="closed" shadowrootdelegatesfocus>
<fieldset>
<legend>自定义输入框</legend>
<p>点击此元素任意位置聚焦输入框</p>
<input type="text" placeholder="输入文本..." />
</fieldset>
</template>
</custom-input>
需注意:表单提交不会自动连接 Shadow DOM 中的输入值,表单验证和状态也不会传出 Shadow DOM。类似地,Shadow Root 边界可能影响 ARIA 无障碍特性。这些问题可通过 ElementInternals 解决,或考虑使用 light DOM 表单。
插槽(Slotted)内容
目前我们只讨论了完全封装的组件。Shadow DOM 的关键特性是使用 插槽 将内容选择性地注入组件内部结构。每个 Shadow Root 可有一个默认(未命名)<slot>
,其他插槽必须命名。命名插槽允许我们为组件特定部分提供内容,并为用户省略的插槽设置回退内容:
<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>项目1</li>
<li>项目2</li>
<li>项目3</li>
</my-widget>
默认插槽也支持回退内容,但任何游离文本节点都会填充其中,因此需要压缩宿主元素标记中的所有空白:
<my-widget>
<template shadowrootmode="closed">
<slot><span>回退内容</span></slot>
</template>
</my-widget>
当插槽的 assignedNodes()
发生变化时,会触发 slotchange
事件。这些事件不包含插槽或节点的引用,需手动传递:
class SlottedWidgetextendsHTMLElement {
#internals;
#shadow;
constructor() {
super();
this.#internals = this.attachInternals();
this.#shadow = this.#internals.shadowRoot;
this.configureSlots();
}
configureSlots() {
const slots = this.#shadow.querySelectorAll("slot");
slots.forEach((slot) => {
slot.addEventListener("slotchange", () => {
console.log({
changedSlot: slot.name || "default",
assignedNodes: slot.assignedNodes(),
});
});
});
}
}
多个元素可通过 slot
属性或脚本分配到单个插槽:
const widget = document.querySelector("slotted-widget");
const added = document.createElement("p");
added.textContent = "通过命名插槽添加的段落";
added.slot = "description";
widget.append(added);
注意插槽内容实际属于 "light" DOM 而非 Shadow DOM。与之前示例不同,这些元素可直接从 document
对象查询:
const widgetTitle = document.querySelector("my-widget [slot=title]");
widgetTitle.textContent = "修改后的标题";
要在类定义内部访问这些元素,需使用 this.children
或 this.querySelector
。只有 <slot>
元素本身可通过 Shadow DOM 查询,其内容不可直接访问。
从入门到精通
现在您已了解:
- 为何使用 Shadow DOM
- 何时将其纳入开发工作
- 如何立即开始使用
但 Web Components 的探索不止于此。本文仅涵盖标记和脚本部分,尚未涉及另一个重要方面:样式封装。这将是我们下一篇文章的主题。