Web Components:Shadow DOM 实战指南
Web Components 不仅仅是自定义元素。Shadow DOM、HTML 模板和自定义元素各自扮演着重要角色。本文中,Russell Beswick 将演示 Shadow DOM 在整体架构中的定位,解释其重要性、适用场景及高效应用方法。
常见误区是将 Web Components 直接与框架组件对比,但多数示例仅聚焦于自定义元素——这只是 Web Components 的一部分。Web Components 实际上是一组可独立使用的 Web 平台 API:
换言之,可以不使用 Shadow DOM 或 HTML 模板创建自定义元素,但结合这些特性可提升稳定性、可复用性、可维护性和安全性。它们是同一功能集的不同部分,既可单独使用也可协同工作。
本文将重点解析 Shadow DOM 的定位。通过 Shadow DOM 可明确定义 Web 应用各部分的边界——将相关 HTML 和 CSS 封装在 DocumentFragment 中,实现组件隔离、避免冲突并保持关注点分离。
Shadow DOM 的存在意义
现代 Web 应用通常由多种来源的库和组件构成。在传统(“轻量”)DOM 中,样式和脚本易相互泄漏或冲突。使用框架时虽可确保组件协同工作,但仍需确保元素 ID 唯一性且 CSS 规则严格限定作用域,这会导致代码冗余、增加加载时间并降低可维护性。
1
2
3
4
|
<!-- 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 通过隔离组件解决这些问题。<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,可通过开发者工具启用 “Show User Agent Shadow DOM” 设置进行查看。
创建 Shadow Root
命令式实例化
使用 JavaScript 的 attachShadow({ mode }) 方法创建 Shadow Root,模式可为 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,外部可查询其内容:
1
|
host.shadowRoot.querySelector('p'); // 选择段落元素
|
若需完全阻止外部脚本访问内部结构,可设置为 closed 模式,此时元素的 shadowRoot 属性返回 null,但创建时的 shadow 引用仍可访问:
1
|
shadow.querySelector('p');
|
此为关键安全特性。例如银行信息组件包含用户账号时,开放模式允许任意页面脚本解析内容,而关闭模式仅用户可通过手动复制或检查元素访问。建议优先采用关闭模式,仅在调试或无法规避实际限制时使用开放模式。
声明式实例化
无需 JavaScript 也可利用 Shadow DOM。在支持元素内嵌套带 shadowrootmode 属性的 <template>,浏览器会自动升级元素并附加 Shadow Root。此方法即使禁用 JavaScript 仍可生效。
1
2
3
4
5
|
<my-widget>
<template shadowrootmode="closed">
<p> Declarative Shadow DOM content </p>
</template>
</my-widget>
|
模式同样可为开放或关闭。若与已注册自定义元素结合使用,可通过 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 的标准元素时,仅得到不包含 Shadow Root 内容的浅拷贝。新特性允许选择性克隆组件:
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
启用后可通过 Element.getHTML() 获取 Shadow DOM 当前状态的字符串表示,用于注入其他宿主或缓存。注意 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>
|
注意:因内容包含 <template> 元素,需使用 setHTMLUnsafe() 方法注入可信内容,使用 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,可访问性方面也存在 ARIA 连接问题。这些表单特定问题需通过 ElementInternals 解决,后续文章将专门讨论。
插槽内容
目前仅涉及完全封装的组件。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.children 或 this.querySelector,仅 <slot> 元素本身可通过 Shadow DOM 查询,其内容不可直接访问。
从入门到精通
现在您已了解为何使用 Shadow DOM、何时集成及当前实践方法。但 Web Components 之旅不止于此,本文仅涵盖标记和脚本,样式封装将是后续文章的重点主题。