Declarative Shadow DOM 是一项 标准 Web 平台功能,自 Chrome 90 版本起已受支持。请注意,此功能的规范在 2023 年进行了更改(包括将 shadowroot
重命名为 shadowrootmode
),并且该功能所有部分的最新标准化版本已在 Chrome 124 版本中发布。
Shadow DOM 是三个 Web Components 标准之一,另外两个标准是 HTML 模板和 自定义元素。Shadow DOM 提供了一种将 CSS 样式限定到特定 DOM 子树并将该子树与文档的其余部分隔离的方法。<slot>
元素使我们能够控制自定义元素的子元素应插入其 Shadow Tree 中的位置。这些功能结合在一起,构成了一个用于构建自包含、可重用组件的系统,这些组件可以像内置 HTML 元素一样无缝集成到现有应用程序中。
到目前为止,使用 Shadow DOM 的唯一方法是使用 JavaScript 构建 shadow root
const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';
像这样的命令式 API 非常适合客户端渲染:定义自定义元素的 JavaScript 模块也创建其 Shadow Root 并设置其内容。但是,许多 Web 应用程序需要在服务器端或在构建时渲染静态 HTML 内容。这对于向可能无法运行 JavaScript 的访问者提供合理的体验来说可能很重要。
服务器端渲染 (SSR) 的理由因项目而异。一些网站必须提供功能齐全的服务器渲染 HTML,以符合辅助功能指南,另一些网站则选择提供基本的无 JavaScript 体验,以确保在慢速连接或设备上的良好性能。
从历史上看,将 Shadow DOM 与服务器端渲染结合使用一直很困难,因为没有内置方法可以在服务器生成的 HTML 中表达 Shadow Root。当将 Shadow Root 附加到已经渲染但没有 Shadow Root 的 DOM 元素时,也会产生性能影响。这可能会导致页面加载后布局偏移,或在加载 Shadow Root 的样式表时暂时显示未设置样式的闪烁内容(“FOUC”)。
Declarative Shadow DOM (DSD) 消除了此限制,将 Shadow DOM 带到了服务器端。
如何构建 Declarative Shadow Root
Declarative Shadow Root 是一个带有 shadowrootmode
属性的 <template>
元素
<host-element>
<template shadowrootmode="open">
<slot></slot>
</template>
<h2>Light content</h2>
</host-element>
带有 shadowrootmode
属性的 template 元素由 HTML 解析器检测,并立即作为其父元素的 shadow root 应用。从上面的示例加载纯 HTML 标记会导致以下 DOM 树
<host-element>
#shadow-root (open)
<slot>
↳
<h2>Light content</h2>
</slot>
</host-element>
此代码示例遵循 Chrome DevTools Elements 面板的约定来显示 Shadow DOM 内容。例如,↳
字符表示 slotted Light DOM 内容。
这使我们在静态 HTML 中获得了 Shadow DOM 的封装和 slot 投影的好处。生成整个树(包括 Shadow Root)不需要 JavaScript。
自定义元素和检测现有 Shadow Root
Declarative Shadow DOM 可以单独使用,作为一种封装样式或自定义子元素放置的方法,但当与自定义元素一起使用时,它最强大。使用自定义元素构建的组件会自动从静态 HTML 升级。随着 Declarative Shadow DOM 的引入,自定义元素现在可以在升级之前拥有 shadow root。
自定义元素已经存在一段时间了,到目前为止,在通过 attachShadow()
创建 shadow root 之前,没有理由检查现有的 shadow root。Declarative Shadow DOM 包含一个小的更改,允许现有组件尽管如此仍可工作:在具有现有声明式 Shadow Root 的元素上调用 attachShadow()
方法不会抛出错误。相反,Declarative Shadow Root 将被清空并返回。这允许未针对 Declarative Shadow DOM 构建的旧组件继续工作,因为声明式 root 会被保留,直到创建命令式替换。
对于新创建的自定义元素,新的 ElementInternals.shadowRoot 属性提供了一种显式方法来获取对元素现有声明式 Shadow Root(开放和封闭)的引用。这可用于检查和使用任何声明式 Shadow Root,同时仍然在未提供 Shadow Root 的情况下回退到 attachShadow()
。
组件水合
从包含 Declarative Shadow Root 的 HTML 升级的自定义元素将已附加该 shadow root。这意味着元素的 ElementInternals
在实例化时将已有一个可用的 shadowRoot
属性,而无需您的代码显式创建。最好在元素的构造函数中检查 ElementInternals.shadowRoot
以查找任何现有的 shadow root。如果已有一个值,则此组件的 HTML 包含 Declarative Shadow Root。如果该值为 null,则 HTML 中不存在 Declarative Shadow Root,或者浏览器不支持 Declarative Shadow DOM。
<menu-toggle>
<template shadowrootmode="open">
<button>
<slot></slot>
</button>
</template>
Open Menu
</menu-toggle>
<script>
class MenuToggle extends HTMLElement {
constructor() {
super();
const supportsDeclarative = HTMLElement.prototype.hasOwnProperty("attachInternals");
const internals = supportsDeclarative ? this.attachInternals() : undefined;
const toggle = () => {
console.log("menu toggled!");
};
// check for a Declarative Shadow Root.
let shadow = internals?.shadowRoot;
if (!shadow) {
// there wasn't one. create a new Shadow Root:
shadow = this.attachShadow({
mode: "open",
});
shadow.innerHTML = `<button><slot></slot></button>`;
}
// in either case, wire up our event listener:
shadow.firstElementChild.addEventListener("click", toggle);
}
}
customElements.define("menu-toggle", MenuToggle);
</script>
每个根一个 shadow
Declarative Shadow Root 仅与其父元素关联。这意味着 shadow root 始终与其关联元素并置。此设计决策确保 shadow root 可像 HTML 文档的其余部分一样进行流式传输。它对于创作和生成也很方便,因为向元素添加 shadow root 不需要维护现有 shadow root 的注册表。
将 shadow root 与其父元素关联的权衡是,不可能从同一个 Declarative Shadow Root <template>
初始化多个元素。但是,这在大多数使用 Declarative Shadow DOM 的情况下不太可能重要,因为每个 shadow root 的内容很少相同。虽然服务器渲染的 HTML 通常包含重复的元素结构,但它们的内容通常不同 - 例如,文本或属性的细微变化。由于序列化的 Declarative Shadow Root 的内容完全是静态的,因此仅当元素恰好相同时,从单个 Declarative Shadow Root 升级多个元素才有效。最后,由于压缩效果,重复的类似 shadow root 对网络传输大小的影响相对较小。
将来,可能会重新审视共享 shadow root。如果 DOM 获得对 内置模板的支持,则可以将 Declarative Shadow Root 视为模板,这些模板被实例化以构造给定元素的 shadow root。当前的 Declarative Shadow DOM 设计允许将来存在这种可能性,方法是将 shadow root 关联限制为单个元素。
流式传输很酷
将 Declarative Shadow Root 直接与其父元素关联简化了升级和将其附加到该元素的过程。Declarative Shadow Root 在 HTML 解析期间被检测到,并在遇到其起始 <template>
标记时立即附加。 <template>
中的已解析 HTML 直接解析到 shadow root 中,因此可以“流式传输”:在接收时渲染。
<div id="el">
<script>
el.shadowRoot; // null
</script>
<template shadowrootmode="open">
<!-- shadow realm -->
</template>
<script>
el.shadowRoot; // ShadowRoot
</script>
</div>
仅限解析器
Declarative Shadow DOM 是 HTML 解析器的一项功能。这意味着仅当 <template>
标记在 HTML 解析期间存在 shadowrootmode
属性时,才会解析和附加 Declarative Shadow Root。换句话说,可以在初始 HTML 解析期间构造 Declarative Shadow Root
<some-element>
<template shadowrootmode="open">
shadow root content for some-element
</template>
</some-element>
设置 <template>
元素的 shadowrootmode
属性没有任何作用,并且该模板仍然是一个普通的 template 元素
const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowrootmode', 'open'); // this does nothing
div.appendChild(template);
div.shadowRoot; // null
为了避免一些重要的安全考虑,Declarative Shadow Root 也不能使用片段解析 API(如 innerHTML
或 insertAdjacentHTML()
)创建。解析应用了 Declarative Shadow Root 的 HTML 的唯一方法是使用 setHTMLUnsafe()
或 parseHTMLUnsafe()
<script>
const html = `
<div>
<template shadowrootmode="open"></template>
</div>
`;
const div = document.createElement('div');
div.innerHTML = html; // No shadow root here
div.setHTMLUnsafe(html); // Shadow roots included
const newDocument = Document.parseHTMLUnsafe(html); // Also here
</script>
使用样式进行服务器渲染
在 Declarative Shadow Root 内部完全支持内联和外部样式表,使用标准 <style>
和 <link>
标记
<nineties-button>
<template shadowrootmode="open">
<style>
button {
color: seagreen;
}
</style>
<link rel="stylesheet" href="/comicsans.css" />
<button>
<slot></slot>
</button>
</template>
I'm Blue
</nineties-button>
以这种方式指定的样式也经过高度优化:如果同一样式表存在于多个 Declarative Shadow Root 中,则只会加载和解析一次。浏览器使用由所有 shadow root 共享的单个后备 CSSStyleSheet
,从而消除重复的内存开销。
Declarative Shadow DOM 中不支持可构造样式表。这是因为,目前,无法在 HTML 中序列化可构造样式表,也无法在填充 adoptedStyleSheets
时引用它们。
如何避免未设置样式的闪烁内容
在尚不支持 Declarative Shadow DOM 的浏览器中,一个潜在问题是避免“未设置样式的闪烁内容”(FOUC),其中对于尚未升级的自定义元素,会显示原始内容。在 Declarative Shadow DOM 之前,避免 FOUC 的一种常见技术是对尚未加载的自定义元素应用 display:none
样式规则,因为这些元素尚未附加和填充其 shadow root。通过这种方式,内容在“准备就绪”之前不会显示
<style>
x-foo:not(:defined) > * {
display: none;
}
</style>
随着 Declarative Shadow DOM 的引入,可以在 HTML 中渲染或创作自定义元素,以便它们的 shadow 内容在客户端组件实现加载之前就已就位并准备就绪
<x-foo>
<template shadowrootmode="open">
<style>h2 { color: blue; }</style>
<h2>shadow content</h2>
</template>
</x-foo>
在这种情况下,display:none
“FOUC”规则将阻止声明式 shadow root 的内容显示。但是,删除该规则将导致不支持 Declarative Shadow DOM 的浏览器显示不正确或未设置样式的内容,直到 Declarative Shadow DOM polyfill 加载并将 shadow root 模板转换为真正的 shadow root。
幸运的是,可以通过修改 FOUC 样式规则在 CSS 中解决此问题。在支持 Declarative Shadow DOM 的浏览器中,<template shadowrootmode>
元素会立即转换为 shadow root,从而在 DOM 树中不留下 <template>
元素。不支持 Declarative Shadow DOM 的浏览器会保留 <template>
元素,我们可以使用它来防止 FOUC
<style>
x-foo:not(:defined) > template[shadowrootmode] ~ * {
display: none;
}
</style>
修订后的“FOUC”规则不是隐藏尚未定义的自定义元素,而是隐藏其子元素,当它们跟随 <template shadowrootmode>
元素时。一旦定义了自定义元素,该规则将不再匹配。在支持 Declarative Shadow DOM 的浏览器中,该规则将被忽略,因为 <template shadowrootmode>
子元素在 HTML 解析期间被删除。
功能检测和浏览器支持
Declarative Shadow DOM 自 Chrome 90 和 Edge 91 起已可用,但它使用了一个较旧的非标准属性 shadowroot
而不是标准化的 shadowrootmode
属性。较新的 shadowrootmode
属性和流式传输行为在 Chrome 111 和 Edge 111 中可用。
作为新的 Web 平台 API,Declarative Shadow DOM 尚未在所有浏览器中得到广泛支持。可以通过检查 HTMLTemplateElement
的原型上是否存在 shadowRootMode
属性来检测浏览器支持
function supportsDeclarativeShadowDOM() {
return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}
Polyfill
构建 Declarative Shadow DOM 的简化 polyfill 相对简单,因为 polyfill 不需要完美地复制浏览器实现所关注的定时语义或仅限解析器的特性。要 polyfill Declarative Shadow DOM,我们可以扫描 DOM 以查找所有 <template shadowrootmode>
元素,然后将它们转换为其父元素上附加的 Shadow Root。此过程可以在文档准备就绪后完成,也可以由更具体的事件(如自定义元素生命周期)触发。
(function attachShadowRoots(root) {
if (supportsDeclarativeShadowDOM()) {
// Declarative Shadow DOM is supported, no need to polyfill.
return;
}
root.querySelectorAll("template[shadowrootmode]").forEach(template => {
const mode = template.getAttribute("shadowrootmode");
const shadowRoot = template.parentNode.attachShadow({ mode });
shadowRoot.appendChild(template.content);
template.remove();
attachShadowRoots(shadowRoot);
});
})(document);