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 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 与框架对应物真正区别开来的地方。
在 DevTools 中检查 HTML <details>
元素的 Shadow DOM。(大预览)
可以托管 Shadow Root 的元素
最常见的是,您会看到影子根与自定义元素相关联。但是,它们也可以与任何 HTMLUnknownElement 一起使用,并且许多标准元素也支持它们,包括:
<aside>
<blockquote>
<body>
<div>
<footer>
<h1>
到 <h6>
<header>
<main>
<nav>
<p>
<section>
<span>
每个元素只能有一个影子根。一些元素,包括 <input>
和 <select>
,已经有一个内置的影子根,无法通过脚本访问。您可以通过启用“显示用户代理 Shadow DOM”设置来使用开发者工具检查它们,该设置默认情况下为“关闭”。
在 Chrome 开发者工具中显示用户代理 DOM 设置。(大预览)
在 Chrome 开发者工具中检查用户代理影子根。(大预览)
创建影子根
在利用 Shadow DOM 的好处之前,首先需要在元素上建立影子根。这可以通过命令式或声明式实例化。
命令式实例化
要使用 JavaScript 创建影子根,请在元素上使用 attachShadow({ mode })
。模式可以是 open
(允许通过 element.shadowRoot
访问)或 closed
(对外部脚本隐藏影子根)。
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);
|
在此示例中,我们建立了一个开放的影子根。这意味着元素的内容可以从外部访问,我们可以像查询任何其他 DOM 节点一样查询它:
1
|
host.shadowRoot.querySelector('p'); // 选择段落元素
|
如果我们想完全防止外部脚本访问我们的内部结构,可以将模式设置为 closed
。这会导致元素的 shadowRoot
属性返回 null
。我们仍然可以从创建它的范围内的影子引用访问它。
1
|
shadow.querySelector('p');
|
这是一个关键的安全功能。使用封闭的影子根,我们可以确信恶意行为者无法从我们的组件中提取私人用户数据。例如,考虑一个显示银行信息的小部件。也许它包含用户的帐号。使用开放的影子根,页面上的任何脚本都可以深入我们的组件并解析其内容。在封闭模式下,只有用户可以通过手动复制粘贴或检查元素来执行此类操作。
我建议在使用 Shadow DOM 时采用封闭优先的方法。养成使用封闭模式的习惯,除非您正在调试,或者只有在无法避免实际限制时才绝对必要。如果您遵循这种方法,您会发现实际上需要开放模式的情况很少见。
声明式实例化
我们不必使用 JavaScript 来利用 Shadow DOM。可以声明式注册影子根。在任何受支持的元素中嵌套带有 shadowrootmode
属性的 <template>
将导致浏览器自动升级该元素并附加影子根。即使禁用 JavaScript,也可以以这种方式附加影子根。
1
2
3
4
5
|
<my-widget>
<template shadowrootmode="closed">
<p> Declarative Shadow DOM content </p>
</template>
</my-widget>
|
同样,这可以是开放的或封闭的。在使用开放模式之前考虑安全影响,但请注意,除非此方法与注册的自定义元素一起使用,否则您无法通过任何脚本访问封闭模式内容,在这种情况下,您可以使用 ElementInternals
访问自动附加的影子根:
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 配置
除了模式之外,我们还可以向 Element.attachShadow()
传递三个其他选项。
选项 1:clonable:true
直到最近,如果一个标准元素附加了影子根,并且您尝试使用 Node.cloneNode(true)
或 document.importNode(node,true)
克隆它,您只会得到宿主元素的浅拷贝,而没有影子根内容。我们刚刚查看的示例实际上会返回一个空的 <div>
。这对于在内部构建自己的影子根的自定义元素来说从来不是问题。
但对于声明式 Shadow DOM,这意味着每个元素都需要自己的模板,并且它们不能被重用。通过这个新添加的功能,我们可以在需要时有选择地克隆组件:
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); // 包括影子根内容
</script>
|
选项 2:serializable:true
启用此选项允许您保存元素影子根内内容的字符串表示形式。在宿主元素上调用 Element.getHTML()
将返回 Shadow DOM 当前状态的模板副本,包括所有嵌套的 shadowrootserializable
实例。这可用于将影子根的副本注入另一个宿主,或将其缓存以供以后使用。
在 Chrome 中,这实际上通过封闭的影子根工作,因此请小心意外泄漏用户数据。更安全的替代方法是使用封闭包装器来屏蔽内部内容免受外部影响,同时在内部保持开放:
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); // 由于封闭的影子根,空字符串
</script>
|
注意 setHTMLUnsafe()
。这是因为内容包含 <template>
元素。当注入这种性质的受信任内容时,必须调用此方法。使用 innerHTML
插入模板不会触发自动初始化为影子根。
选项 3:delegatesFocus:true
此选项本质上使我们的宿主元素充当其内部内容的 <label>
。启用后,单击宿主上的任何位置或在其上调用 .focus()
会将光标移动到影子根中的第一个可聚焦元素。这还将 :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
来解决,这是另一篇文章的主题,并且是质疑您是否可以依赖轻量 DOM 表单的原因。
插槽内容
到目前为止,我们只看了完全封装的组件。Shadow DOM 的一个关键功能是使用插槽选择性地将内容注入组件的内部结构。每个影子根可以有一个默认(未命名)的 <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 之旅不能就此结束。在本文中,我们仅涵盖了标记和脚本。我们甚至还没有触及 Web Components 的另一个主要方面:样式封装。这将是另一篇文章的主题。