原文链接: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 DOM 或HTML模板 的自定义元素,但将这些特性结合使用,能增强组件的稳定性、可复用性、可维护性和安全性。它们是同一功能集的组成部分,既可单独使用,也可组合应用。
话虽如此,我想重点谈谈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>
模式同样可以是open
或closed
。使用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配置
除了mode
,Element.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.children
或this.querySelector
。只有<slot>
元素本身可通过Shadow DOM查询,其内容不行。
从陌生到精通
现在你已经了解了为什么要使用Shadow DOM、何时应将其纳入开发工作,以及如何开始使用它。
但你的Web组件之旅不应止步于此。本文仅涵盖了标记和脚本部分,尚未涉及Web组件的另一个重要方面:样式封装(Style encapsulation) 。这将是我们下一篇文章的主题。