Shadow DOM 允许 Web 开发者为 Web 组件创建分隔的 DOM 和 CSS
摘要
Shadow DOM 消除了构建 Web 应用的脆弱性。这种脆弱性源于 HTML、CSS 和 JS 的全局性。多年来,我们发明了数量
惊人
的工具来规避这些问题。例如,当您使用新的 HTML ID/类时,无法确定它是否会与页面使用的现有名称冲突。细微的 bug 悄然出现,CSS 特异性成为一个大问题(!important 所有内容!),样式选择器失控,性能可能会受到影响。不胜枚举。
Shadow DOM 可修复 CSS 和 DOM。它为 Web 平台引入了限定范围的样式。无需工具或命名规范,您就可以将 CSS 与标记捆绑在一起、隐藏实现细节,并使用原生 JavaScript 编写自包含组件。
简介
Shadow DOM 是三大 Web 组件标准之一:HTML 模板、Shadow DOM 和自定义元素。HTML 导入曾经是该列表的一部分,但现在被视为已弃用。
您无需编写使用 shadow DOM 的网络组件。但一旦使用,您就可以利用其优势(CSS 作用域、DOM 封装、组合),并构建可复用的自定义元素,这些元素具有弹性、高度可配置且极具可复用性。如果说自定义元素是创建新 HTML(通过 JS API)的方式,那么 shadow DOM 就是您提供其 HTML 和 CSS 的方式。这两个 API 结合使用,可创建包含独立 HTML、CSS 和 JavaScript 的组件。
Shadow DOM 旨在作为构建基于组件的应用的工具。因此,它为 Web 开发中的常见问题提供了解决方案:
- 隔离的 DOM:组件的 DOM 是自包含的(例如,
document.querySelector()不会返回组件的 shadow DOM 中的节点)。 - 限定范围的 CSS:在 Shadow DOM 中定义的 CSS 仅适用于该 Shadow DOM。样式规则不会泄露,页面样式也不会渗入。
- 组合:为组件设计基于标记的声明式 API。
- 简化 CSS - 作用域 DOM 意味着您可以使用简单的 CSS 选择器、更通用的 ID/类名称,而无需担心命名冲突。
- 工作效率 - 将应用视为 DOM 块,而不是一个大型(全局)页面。
fancy-tabs 演示
在本文中,我将提及一个演示组件 (<fancy-tabs>) 并引用其中的代码段。如果您的浏览器支持这些 API,您应该会在下方看到一个实时演示。否则,请查看 GitHub 上的完整源代码。
什么是 shadow DOM?
DOM 背景知识
HTML 易于使用,因此为网络提供了强大的支持。通过声明一些标记,您可以在几秒钟内编写一个兼具呈现效果和结构的网页。不过,HTML 本身并没有那么有用。人类很容易理解基于文本的语言,但机器需要更多信息。文档对象模型 (DOM) 登场。
当浏览器加载网页时,会执行一系列有趣的操作。它所做的一件事是将作者的 HTML 转换为实时文档。基本上,为了了解网页的结构,浏览器会将 HTML(静态文本字符串)解析为数据模型(对象/节点)。浏览器通过创建这些节点的树(即 DOM)来保留 HTML 的层次结构。DOM 的一个很酷的特点是,它是网页的实时表示。与我们编写的静态 HTML 不同,浏览器生成的节点包含属性、方法,最重要的是,它们可以由程序进行操作!这就是为什么我们能够直接使用 JavaScript 创建 DOM 元素:
const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);
会生成以下 HTML 标记:
<body>
<header>
<h1>Hello DOM</h1>
</header>
</body>
这些都很好。那么,shadow DOM 到底是什么?
DOM… in the shadows
Shadow DOM 只是普通的 DOM,但有两点不同:1) 它的创建/使用方式;2) 它与网页其余部分的关系。通常,您会创建 DOM 节点,并将其作为另一个元素的子级附加。借助 shadow DOM,您可以创建一个作用域 DOM 树,该树附加到元素,但与其子元素分开。此限定范围的子树称为影子树。它所附加到的元素是其影子宿主。您在 shadow 中添加的所有内容都会成为宿主元素的本地内容,包括 <style>。这就是影子 DOM 实现 CSS 样式作用域的方式。
创建 Shadow DOM
影子根是附加到“宿主”元素的文档片段。附加 shadow root 的行为就是元素获取其 shadow DOM 的方式。如需为元素创建 shadow DOM,请调用 element.attachShadow():
const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().
// header.shadowRoot === shadowRoot
// shadowRoot.host === header
我使用 .innerHTML 来填充 shadow root,但您也可以使用其他 DOM API。这就是网络。我们有选择。
该规范定义了无法托管 shadow 树的元素列表。元素可能出现在列表中的原因有多种:
- 浏览器已为元素(
<textarea>、<input>)托管自己的内部 shadow DOM。 - 该元素不适合托管 shadow DOM (
<img>)。
例如,以下方法不起作用:
document.createElement('input').attachShadow({mode: 'open'});
// Error. `<input>` cannot host shadow dom.
为自定义元素创建 shadow DOM
在创建自定义元素时,Shadow DOM 特别有用。使用 shadow DOM 划分元素的 HTML、CSS 和 JS,从而生成“Web 组件”。
示例 - 自定义元素将 shadow DOM 附加到自身,封装其 DOM/CSS:
// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
// Attach a shadow root to <fancy-tabs>.
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
<div id="tabs">...</div>
<div id="panels">...</div>
`;
}
...
});
这里有几处有趣的地方。首先,当创建 <fancy-tabs> 的实例时,自定义元素会创建自己的 shadow DOM。这在 constructor() 中完成。其次,由于我们创建了 shadow root,<style> 内的 CSS 规则将限定在 <fancy-tabs> 范围内。
组合和槽
组合是 Shadow DOM 最难理解的功能之一,但它无疑也是最重要的功能。
在 Web 开发领域,组合是指我们如何以声明方式通过 HTML 构建应用。不同的构建块(<div>、<header>、<form>、<input>)组合在一起,形成应用。其中一些代码甚至可以相互配合使用。组合是 <select>、<details>、<form> 和 <video> 等原生元素如此灵活的原因。这些标记中的每一个都接受某些 HTML 作为子级,并对它们执行一些特殊操作。例如,<select> 知道如何将 <option> 和 <optgroup> 呈现为下拉菜单和多选微件。<details> 元素会将 <summary> 呈现为可展开的箭头。即使是 <video> 也知道如何处理某些子元素:<source> 元素不会呈现,但它们会影响视频的行为。太神奇了!
术语:light DOM 与 shadow DOM
Shadow DOM 组合在 Web 开发中引入了许多新的基本概念。在深入探讨之前,我们先统一一些术语,以便使用相同的语言进行交流。
Light DOM
组件用户编写的标记。此 DOM 位于组件的 shadow DOM 之外。它是元素的实际子级。
<better-button>
<!-- the image and span are better-button's light DOM -->
<img src="gear.svg" slot="icon">
<span>Settings</span>
</better-button>
Shadow DOM
组件作者编写的 DOM。Shadow DOM 是组件的本地 DOM,用于定义其内部结构、限定范围的 CSS,并封装实现细节。它还可以定义如何呈现由组件使用者编写的标记。
#shadow-root
<style>...</style>
<slot name="icon"></slot>
<span id="wrapper">
<slot>Button</slot>
</span>
扁平化 DOM 树
浏览器将用户的 light DOM 分配到您的 shadow DOM 中,从而呈现最终产品。扁平化树是您最终在开发者工具中看到的内容,也是在网页上呈现的内容。
<better-button>
#shadow-root
<style>...</style>
<slot name="icon">
<img src="gear.svg" slot="icon">
</slot>
<span id="wrapper">
<slot>
<span>Settings</span>
</slot>
</span>
</better-button>
<slot> 元素
Shadow DOM 使用 <slot> 元素将不同的 DOM 树组合在一起。
槽是组件内的占位符,用户可以使用自己的标记填充。通过定义一个或多个槽,您可以邀请外部标记在组件的 shadow DOM 中呈现。本质上,您是在说“在此处呈现用户的标记”。
当 <slot> 邀请元素进入时,允许元素“跨越”shadow DOM 边界。这些元素称为“分布式节点”。从概念上讲,分布式节点可能看起来有点奇怪。插槽不会在物理上移动 DOM,而是在 shadow DOM 内的其他位置呈现 DOM。
组件可以在其 Shadow DOM 中定义零个或多个槽。槽可以为空,也可以提供后备内容。如果用户未提供 light DOM 内容,则槽会呈现其后备内容。
<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>
<slot>fallback content</slot> <!-- default slot with fallback content -->
<slot> <!-- default slot entire DOM tree as fallback -->
<h2>Title</h2>
<summary>Description text</summary>
</slot>
您还可以创建具名槽。具名槽是 Shadow DOM 中的特定空洞,用户可通过名称引用。
示例 - <fancy-tabs> 的 shadow DOM 中的槽:
#shadow-root
<div id="tabs">
<slot id="tabsSlot" name="title"></slot> <!-- named slot -->
</div>
<div id="panels">
<slot id="panelsSlot"></slot>
</div>
组件用户按如下方式声明 <fancy-tabs>:
<fancy-tabs>
<button slot="title">Title</button>
<button slot="title" selected>Title 2</button>
<button slot="title">Title 3</button>
<section>content panel 1</section>
<section>content panel 2</section>
<section>content panel 3</section>
</fancy-tabs>
<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
<h2 slot="title">Title</h2>
<section>content panel 1</section>
<h2 slot="title" selected>Title 2</h2>
<section>content panel 2</section>
<h2 slot="title">Title 3</h2>
<section>content panel 3</section>
</fancy-tabs>
如果您想知道,扁平化树大致如下所示:
<fancy-tabs>
#shadow-root
<div id="tabs">
<slot id="tabsSlot" name="title">
<button slot="title">Title</button>
<button slot="title" selected>Title 2</button>
<button slot="title">Title 3</button>
</slot>
</div>
<div id="panels">
<slot id="panelsSlot">
<section>content panel 1</section>
<section>content panel 2</section>
<section>content panel 3</section>
</slot>
</div>
</fancy-tabs>
请注意,我们的组件能够处理不同的配置,但扁平化的 DOM 树保持不变。我们还可以从 <button> 切换到 <h2>。此组件旨在处理不同类型的子项,就像 <select> 一样!
样式
设置 Web 组件样式的方法有很多。使用 shadow DOM 的组件可以由主页面设置样式、定义自己的样式,或提供钩子(以 CSS 自定义属性的形式)供用户替换默认值。
组件定义的样式
Shadow DOM 最实用的功能无疑是作用域 CSS:
- 外部网页中的 CSS 选择器不适用于组件内部。
- 内部定义的样式不会溢出。它们的作用域限定为主机元素。
在 Shadow DOM 中使用的 CSS 选择器会局部应用于您的组件。在实践中,这意味着我们可以再次使用常见的 ID/类名称,而无需担心与页面上的其他位置发生冲突。在 Shadow DOM 中, 使用更简单的 CSS 选择器是最佳实践。它们对性能也有好处。
示例 - 在 shadow root 中定义的样式是局部的
#shadow-root
<style>
#panels {
box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
background: white;
...
}
#tabs {
display: inline-flex;
...
}
</style>
<div id="tabs">
...
</div>
<div id="panels">
...
</div>
样式表也限定在 shadow tree 的范围内:
#shadow-root
<link rel="stylesheet" href="styles.css">
<div id="tabs">
...
</div>
<div id="panels">
...
</div>
您是否曾想过,当您添加 multiple 属性时,<select> 元素如何呈现多选 widget(而不是下拉菜单):
<select multiple>
<option>Do</option>
<option selected>Re</option>
<option>Mi</option>
<option>Fa</option>
<option>So</option>
</select>
<select> 能够根据您为其声明的属性,以不同的方式设置自身的样式。Web 组件也可以使用 :host 选择器自行设置样式。
示例 - 组件自行设置样式
<style>
:host {
display: block; /* by default, custom elements are display: inline */
contain: content; /* CSS containment FTW. */
}
</style>
:host 的一个问题是,父页面中的规则比元素中定义的 :host 规则具有更高的特异性。也就是说,外部样式会胜出。这样,用户就可以从外部替换您的顶级样式。此外,:host 仅在 shadow root 的上下文中有效,因此您无法在 shadow DOM 之外使用它。
:host(<selector>) 的函数形式允许您定位与 <selector> 匹配的主机。这是一种很好的方式,可让组件封装对用户互动做出反应的行为,或根据宿主设置内部节点的样式。
<style>
:host {
opacity: 0.4;
will-change: opacity;
transition: opacity 300ms ease-in-out;
}
:host(:hover) {
opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
background: grey;
pointer-events: none;
opacity: 0.4;
}
:host(.blue) {
color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>
根据上下文设置样式
如果组件或其任何祖先实体与 <selector> 匹配,则 :host-context(<selector>) 与该组件匹配。此功能的一个常见用途是根据组件周围的环境进行主题设置。例如,许多人通过将类应用于 <html> 或 <body> 来设置主题:
<body class="darktheme">
<fancy-tabs>
...
</fancy-tabs>
</body>
当 <fancy-tabs> 是 .darktheme 的后代时,:host-context(.darktheme) 会为其设置样式:
:host-context(.darktheme) {
color: white;
background: black;
}
:host-context() 可用于主题设置,但更好的方法是使用 CSS 自定义属性创建样式钩子。
设置分布式节点的样式
::slotted(<compound-selector>) 匹配分布到 <slot> 中的节点。
假设我们创建了一个姓名徽章组件:
<name-badge>
<h2>Eric Bidelman</h2>
<span class="title">
Digital Jedi, <span class="company">Google</span>
</span>
</name-badge>
组件的 shadow DOM 可以设置用户的 <h2> 和 .title 的样式:
<style>
::slotted(h2) {
margin: 0;
font-weight: 300;
color: red;
}
::slotted(.title) {
color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
text-transform: uppercase;
}
*/
</style>
<slot></slot>
如果您还记得,<slot> 不会移动用户的 Light DOM。当节点分布到 <slot> 中时,<slot> 会呈现其 DOM,但节点在物理上保持不变。在分发前应用的样式在分发后仍会继续应用。不过,当 light DOM 分布时,它可以采用其他样式(由 shadow DOM 定义的样式)。
<fancy-tabs>中的另一个更深入的示例:
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
<style>
#panels {
box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
background: white;
border-radius: 3px;
padding: 16px;
height: 250px;
overflow: auto;
}
#tabs {
display: inline-flex;
-webkit-user-select: none;
user-select: none;
}
#tabsSlot::slotted(*) {
font: 400 16px/22px 'Roboto';
padding: 16px 8px;
...
}
#tabsSlot::slotted([aria-selected="true"]) {
font-weight: 600;
background: white;
box-shadow: none;
}
#panelsSlot::slotted([aria-hidden="true"]) {
display: none;
}
</style>
<div id="tabs">
<slot id="tabsSlot" name="title"></slot>
</div>
<div id="panels">
<slot id="panelsSlot"></slot>
</div>
`;
在此示例中,有两个槽:一个用于标签页标题的具名槽,以及一个用于标签页面板内容的槽。当用户选择某个标签页时,我们会将所选内容加粗,并显示其面板。为此,您需要选择具有 selected 属性的分布式节点。自定义元素的 JS(此处未显示)会在正确的时间添加该属性。
从外部设置组件样式
您可以通过以下几种方式从外部设置组件的样式。最简单的方法是将标记名称用作选择器:
fancy-tabs {
width: 500px;
color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
box-shadow: 0 3px 3px #ccc;
}
外部样式始终优先于 shadow DOM 中定义的样式。例如,如果用户编写了选择器 fancy-tabs { width: 500px; },它将覆盖组件的规则::host { width: 650px;}。
仅设置组件本身的样式是不够的。但如果您想为组件的内部设置样式,该怎么办?为此,我们需要 CSS 自定义属性。
使用 CSS 自定义属性创建样式钩子
如果组件的作者使用 CSS 自定义属性提供样式钩子,用户可以调整内部样式。从概念上讲,该方法类似于 <slot>。您需要创建“样式占位符”供用户替换。
示例 - <fancy-tabs> 允许用户替换背景颜色:
<!-- main page -->
<style>
fancy-tabs {
margin-bottom: 32px;
--fancy-tabs-bg: black;
}
</style>
<fancy-tabs background>...</fancy-tabs>
在其 shadow DOM 内:
:host([background]) {
background: var(--fancy-tabs-bg, #9E9E9E);
border-radius: 10px;
padding: 10px;
}
在这种情况下,组件将使用 black 作为背景值,因为用户提供了该值。否则,默认值为 #9E9E9E。
高级主题
创建封闭式 shadow root(应避免)
还有另一种影子 DOM,称为“封闭”模式。创建封闭式 shadow tree 时,外部 JavaScript 将无法访问组件的内部 DOM。这与 <video> 等原生元素的工作方式类似。
JavaScript 无法访问 <video> 的 shadow DOM,因为浏览器使用封闭模式 shadow root 实现它。
示例 - 创建封闭的影子树:
const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div
其他 API 也会受到封闭模式的影响:
Element.assignedSlot/TextNode.assignedSlot返回nullEvent.composedPath()对于与 Shadow DOM 内的元素关联的事件,返回 []
以下是我总结的为什么不应该使用 {mode: 'closed'} 创建 Web 组件的原因:
虚假的安全感。攻击者可以随意劫持
Element.prototype.attachShadow。封闭模式会阻止自定义元素代码访问其自己的影子 DOM。这完全是失败的。相反,如果您想使用
querySelector()等内容,则必须存储引用以供日后使用。这完全违背了封闭模式的初衷!customElements.define('x-element', class extends HTMLElement { constructor() { super(); // always call super() first in the constructor. this._shadowRoot = this.attachShadow({mode: 'closed'}); this._shadowRoot.innerHTML = '<div class="wrapper"></div>'; } connectedCallback() { // When creating closed shadow trees, you'll need to stash the shadow root // for later if you want to use it again. Kinda pointless. const wrapper = this._shadowRoot.querySelector('.wrapper'); } ... });封闭模式会降低组件对最终用户的灵活性。在构建 Web 组件时,您有时会忘记添加某项功能。配置选项。用户希望实现的用例。一个常见示例是忘记为内部节点添加足够的样式钩子。在封闭模式下,用户无法替换默认设置和调整样式。能够访问组件的内部结构非常有用。 最终,如果您的组件无法满足用户的需求,用户会派生您的组件、寻找其他组件或创建自己的组件 :(
在 JS 中使用槽
Shadow DOM API 提供了用于处理槽和分布式节点的实用程序。在编写自定义元素时,这些功能非常有用。
slotchange 事件
当槽的分布式节点发生变化时,会触发 slotchange 事件。例如,如果用户从 Light DOM 中添加/移除子级。
const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
console.log('light dom children changed!');
});
如需监控对 Light DOM 的其他类型的更改,您可以在元素的构造函数中设置 MutationObserver。
插槽中正在渲染哪些元素?
有时,了解哪些元素与某个槽位相关联会很有用。调用 slot.assignedNodes() 可查找槽正在呈现哪些元素。{flatten: true} 选项还会返回槽的后备内容(如果未分发任何节点)。
举例来说,假设您的 shadow DOM 如下所示:
<slot><b>fallback content</b></slot>
| 用法 | 致电 | 结果 |
|---|---|---|
| <my-component>component text</my-component> | slot.assignedNodes(); |
[component text] |
| <my-component></my-component> | slot.assignedNodes(); |
[] |
| <my-component></my-component> | slot.assignedNodes({flatten: true}); |
[<b>fallback content</b>] |
元素被分配到哪个槽?
也可以回答相反的问题。element.assignedSlot 会告诉您元素分配到了哪个组件槽。
Shadow DOM 事件模型
当事件从 shadow DOM 向上冒泡时,其目标会进行调整,以保持 shadow DOM 提供的封装。也就是说,事件会被重新定位,看起来像是来自组件,而不是 shadow DOM 中的内部元素。有些事件甚至不会传播到 Shadow DOM 之外。
确实会跨越 shadow 边界的事件包括:
- 焦点事件:
blur、focus、focusin、focusout - 鼠标事件:
click、dblclick、mousedown、mouseenter、mousemove等。 - 滚轮事件:
wheel - 输入事件:
beforeinput、input - 键盘事件:
keydown、keyup - 合成事件:
compositionstart、compositionupdate、compositionend - DragEvent:
dragstart、drag、dragend、drop等。
提示
如果影子树处于打开状态,调用 event.composedPath() 将返回事件经过的节点数组。
使用自定义事件
在 shadow tree 中的内部节点上触发的自定义 DOM 事件不会冒泡到 shadow 边界之外,除非该事件是使用 composed: true 标志创建的:
// Inside <fancy-tab> custom element class definition:
selectTab() {
const tabs = this.shadowRoot.querySelector('#tabs');
tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}
如果为 composed: false(默认),则使用方将无法在 shadow root 之外监听事件。
<fancy-tabs></fancy-tabs>
<script>
const tabs = document.querySelector('fancy-tabs');
tabs.addEventListener('tab-select', e => {
// won't fire if `tab-select` wasn't created with `composed: true`.
});
</script>
处理焦点
如果您还记得 shadow DOM 的事件模型,就会知道在 shadow DOM 内部触发的事件会被调整为看起来像是来自托管元素。例如,假设您点击了 shadow root 中的 <input>:
<x-focus>
#shadow-root
<input type="text" placeholder="Input inside shadow dom">
focus 事件看起来像是来自 <x-focus>,而不是 <input>。
同样,document.activeElement 将是 <x-focus>。如果影子根是使用 mode:'open' 创建的(请参阅封闭模式),您还可以访问获得焦点的内部节点:
document.activeElement.shadowRoot.activeElement // only works with open mode.
如果存在多个级别的 shadow DOM(例如,一个自定义元素位于另一个自定义元素内),您需要以递归方式深入到 shadow root 中才能找到 activeElement:
function deepActiveElement() {
let a = document.activeElement;
while (a && a.shadowRoot && a.shadowRoot.activeElement) {
a = a.shadowRoot.activeElement;
}
return a;
}
另一个焦点选项是 delegatesFocus: true 选项,它会扩展 shadow tree 中元素的焦点行为:
- 如果您点击 Shadow DOM 内的节点,且该节点不是可聚焦区域,则第一个可聚焦区域会获得焦点。
- 当 shadow DOM 内的节点获得焦点时,
:focus会应用于宿主以及获得焦点的元素。
示例 - delegatesFocus: true 如何改变焦点行为
<style>
:focus {
outline: 2px solid red;
}
</style>
<x-focus></x-focus>
<script>
customElements.define('x-focus', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
const root = this.attachShadow({mode: 'open', delegatesFocus: true});
root.innerHTML = `
<style>
:host {
display: flex;
border: 1px dotted black;
padding: 16px;
}
:focus {
outline: 2px solid blue;
}
</style>
<div>Clickable Shadow DOM text</div>
<input type="text" placeholder="Input inside shadow dom">`;
// Know the focused element inside shadow DOM:
this.addEventListener('focus', function(e) {
console.log('Active element (inside shadow dom):',
this.shadowRoot.activeElement);
});
}
});
</script>
结果
上图是 <x-focus> 获得焦点(用户点击、通过 Tab 键切换到、focus() 等)、“Clickable Shadow DOM text”被点击或内部 <input> 获得焦点(包括 autofocus)时的结果。
如果您设置了 delegatesFocus: false,则会看到以下内容:
delegatesFocus: false 且内部 <input> 获得焦点。
delegatesFocus: false 和 <x-focus> 获得焦点(例如,它具有 tabindex="0")。
delegatesFocus: false 和“可点击的 Shadow DOM 文本”被点击(或该元素的 Shadow DOM 中的其他空白区域被点击)。
提示和技巧
多年来,我学到了一些关于编写 Web 组件的知识。我相信,您会发现其中一些提示对于编写组件和调试 shadow DOM 非常有用。
使用 CSS 包含
通常,Web 组件的布局/样式/绘制是相当独立的。在 :host 中使用 CSS 包含可提升性能:
<style>
:host {
display: block;
contain: content; /* Boom. CSS containment FTW. */
}
</style>
重置可沿用的样式
可继承的样式(background、color、font、line-height 等)会继续在 Shadow DOM 中继承。也就是说,它们默认会穿透 shadow DOM 边界。如果您想从头开始,请使用 all: initial; 将可继承样式在跨越阴影边界时重置为其初始值。
<style>
div {
padding: 10px;
background: red;
font-size: 25px;
text-transform: uppercase;
color: white;
}
</style>
<div>
<p>I'm outside the element (big/white)</p>
<my-element>Light DOM content is also affected.</my-element>
<p>I'm outside the element (big/white)</p>
</div>
<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
<style>
:host {
all: initial; /* 1st rule so subsequent properties are reset. */
display: block;
background: white;
}
</style>
<p>my-element: all CSS properties are reset to their
initial value using <code>all: initial</code>.</p>
<slot></slot>
`;
</script>
查找网页使用的所有自定义元素
有时,查找网页上使用的自定义元素会很有用。为此,您需要以递归方式遍历网页上使用的所有元素的 shadow DOM。
const allCustomElements = [];
function isCustomElement(el) {
const isAttr = el.getAttribute('is');
// Check for <super-button> and <button is="super-button">.
return el.localName.includes('-') || isAttr && isAttr.includes('-');
}
function findAllCustomElements(nodes) {
for (let i = 0, el; el = nodes[i]; ++i) {
if (isCustomElement(el)) {
allCustomElements.push(el);
}
// If the element has shadow DOM, dig deeper.
if (el.shadowRoot) {
findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
}
}
}
findAllCustomElements(document.querySelectorAll('*'));
通过 <template> 创建元素
我们可以使用声明式 <template>,而不是使用 .innerHTML 填充 shadow root。模板是声明 Web 组件结构的理想占位符。
请参阅“自定义元素:构建可重复使用的 Web 组件”中的示例。
历史记录和浏览器支持
如果您在过去几年中一直关注 Web 组件,就会知道 Chrome 35+/Opera 已经发布了旧版 Shadow DOM 一段时间。Blink 将在一段时间内继续同时支持这两个版本。v0 规范提供了一种不同的方法来创建 shadow root(element.createShadowRoot,而不是 v1 的 element.attachShadow)。调用旧方法会继续创建具有 v0 语义的 shadow root,因此现有的 v0 代码不会中断。
如果您碰巧对旧版 v0 规范感兴趣,请参阅 html5rocks 文章:1、2、3。此外,还有一篇关于 Shadow DOM v0 和 v1 之间的差异的精彩对比文章。
浏览器支持
Shadow DOM v1 在 Chrome 53(状态)、Opera 40、Safari 10 和 Firefox 63 中提供。Edge 已开始开发。
如需检测 shadow DOM,请检查是否存在 attachShadow:
const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;
Polyfill
在浏览器广泛支持之前,shadydom 和 shadycss polyfill 可为您提供 v1 功能。Shady DOM 模拟 Shadow DOM 的 DOM 作用域,而 shadycss 则对 CSS 自定义属性和原生 API 提供的样式作用域进行 polyfill。
安装 polyfill:
bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss
使用 polyfill:
function loadScript(src) {
return new Promise(function(resolve, reject) {
const script = document.createElement('script');
script.async = true;
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
loadScript('/bower_components/shadydom/shadydom.min.js')
.then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
.then(e => {
// Polyfills loaded.
});
} else {
// Native shadow dom v1 support. Go to go!
}
如需了解如何对样式进行填充/限定范围,请参阅 https://github.com/webcomponents/shadycss#usage。
总结
我们首次拥有了一个 API 原语,它能正确限定 CSS 作用域、DOM 作用域,并具有真正的组合能力。Shadow DOM 与自定义元素等其他 Web 组件 API 结合使用,提供了一种无需黑客手段或使用 <iframe> 等旧有负担即可编写真正封装组件的方法。
别误会。Shadow DOM 确实是一个复杂的怪物!但它值得学习。花点时间熟悉一下。学习并提问!
深入阅读
- Shadow DOM v1 与 v0 之间的区别
- WebKit 博客中的“Introducing Slot-Based Shadow DOM API”。
- Web 组件和模块化 CSS 的未来 作者:Philip Walton
- Google WebFundamentals 中的“自定义元素:构建可重复使用的 Web 组件”。
- Shadow DOM v1 规范
- 自定义元素 v1 规范
常见问题解答
现在可以使用 Shadow DOM v1 吗?
如果使用 polyfill,则可以。请参阅浏览器支持。
Shadow DOM 提供哪些安全功能?
Shadow DOM 不是一项安全功能。它是一个轻量级工具,用于限定 CSS 范围
并在组件中隐藏 DOM 树。如果您需要真正的安全边界,请使用 <iframe>。
网络组件是否必须使用 shadow DOM?
不对!您无需创建使用 shadow DOM 的 Web 组件。不过,编写使用 Shadow DOM 的自定义元素意味着您可以利用 CSS 作用域、DOM 封装和组合等功能。
开放式和封闭式 shadow root 有何区别?
请参阅封闭式影子根。