许多类型的组件,例如选项卡、菜单、图片库等,都需要内容才能渲染。
就像内置浏览器 <select>
预期 <option>
项目一样,我们的 <custom-tabs>
可能预期传递实际的选项卡内容。而 <custom-menu>
可能预期菜单项。
使用 <custom-menu>
的代码可能如下所示
<custom-menu>
<title>Candy menu</title>
<item>Lollipop</item>
<item>Fruit Toast</item>
<item>Cup Cake</item>
</custom-menu>
…然后我们的组件应该将其正确渲染,作为具有给定标题和项目的漂亮菜单,处理菜单事件等。
如何实现它?
我们可以尝试分析元素内容并动态复制重新排列 DOM 节点。这是可能的,但如果我们将元素移动到 Shadow DOM,那么文档中的 CSS 样式在其中不适用,因此视觉样式可能会丢失。此外,这需要一些编码。
幸运的是,我们不必这样做。Shadow DOM 支持 <slot>
元素,这些元素会自动由来自 Light DOM 的内容填充。
命名插槽
让我们通过一个简单的例子来了解插槽的工作原理。
这里,<user-card>
的影子 DOM 提供了两个插槽,它们由光 DOM 填充。
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
`;
}
});
</script>
<user-card>
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
</user-card>
在影子 DOM 中,<slot name="X">
定义了一个“插入点”,即带有 slot="X"
属性的元素被渲染的位置。
然后浏览器执行“组合”:它从光 DOM 中获取元素,并将它们渲染到影子 DOM 中相应的插槽。最终,我们得到了我们想要的东西——一个可以填充数据的组件。
这是脚本执行后,不考虑组合的 DOM 结构。
<user-card>
#shadow-root
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
</user-card>
我们创建了影子 DOM,所以它就在这里,在 #shadow-root
下。现在这个元素同时拥有光 DOM 和影子 DOM。
为了渲染目的,对于影子 DOM 中的每个 <slot name="...">
,浏览器会在光 DOM 中查找具有相同名称的 slot="..."
。这些元素会被渲染到插槽中。
结果被称为“扁平化”DOM。
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<!-- slotted element is inserted into the slot -->
<span slot="username">John Smith</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday">
<span slot="birthday">01.01.2001</span>
</slot>
</div>
</user-card>
…但扁平化的 DOM 仅用于渲染和事件处理目的。它是一种“虚拟”的。这就是事物显示的方式。但文档中的节点实际上并没有移动!
如果我们运行 querySelectorAll
,可以很容易地检查这一点:节点仍然在它们的位置。
// light DOM <span> nodes are still at the same place, under `<user-card>`
alert( document.querySelectorAll('user-card span').length ); // 2
因此,扁平化的 DOM 是通过插入插槽从影子 DOM 中派生的。浏览器渲染它并将其用于样式继承、事件传播(稍后会详细介绍)。但 JavaScript 仍然看到“原样”的文档,在扁平化之前。
slot="..."
属性。slot="..."
属性仅对影子宿主(在本例中为 <user-card>
元素)的直接子元素有效。对于嵌套元素,它会被忽略。
例如,这里第二个 <span>
被忽略了(因为它不是 <user-card>
的顶层子元素)。
<user-card>
<span slot="username">John Smith</span>
<div>
<!-- invalid slot, must be direct child of user-card -->
<span slot="birthday">01.01.2001</span>
</div>
</user-card>
如果光 DOM 中有多个元素具有相同的插槽名称,它们将被追加到插槽中,一个接一个。
例如,这个
<user-card>
<span slot="username">John</span>
<span slot="username">Smith</span>
</user-card>
在 <slot name="username">
中,它会生成一个扁平化的 DOM,其中包含两个元素。
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<span slot="username">John</span>
<span slot="username">Smith</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
</user-card>
插槽回退内容
如果我们在 <slot>
中放了一些东西,它就会成为回退内容,“默认”内容。如果光 DOM 中没有相应的填充内容,浏览器就会显示它。
例如,在这段影子 DOM 代码中,如果光 DOM 中没有 slot="username"
,则会渲染 Anonymous
。
<div>Name:
<slot name="username">Anonymous</slot>
</div>
默认插槽:第一个未命名插槽
影子 DOM 中第一个没有名称的 `<slot>` 是一个“默认”插槽。它接收来自光 DOM 中所有未被分配到其他插槽的节点。
例如,让我们将默认插槽添加到我们的 `<user-card>` 中,它显示有关用户的全部未分配信息。
<script>
customElements.define('user-card', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `
<div>Name:
<slot name="username"></slot>
</div>
<div>Birthday:
<slot name="birthday"></slot>
</div>
<fieldset>
<legend>Other information</legend>
<slot></slot>
</fieldset>
`;
}
});
</script>
<user-card>
<div>I like to swim.</div>
<span slot="username">John Smith</span>
<span slot="birthday">01.01.2001</span>
<div>...And play volleyball too!</div>
</user-card>
所有未分配的光 DOM 内容都进入“其他信息”字段集。
元素按顺序追加到插槽中,因此两个未分配的信息片段都一起位于默认插槽中。
扁平化的 DOM 如下所示
<user-card>
#shadow-root
<div>Name:
<slot name="username">
<span slot="username">John Smith</span>
</slot>
</div>
<div>Birthday:
<slot name="birthday">
<span slot="birthday">01.01.2001</span>
</slot>
</div>
<fieldset>
<legend>Other information</legend>
<slot>
<div>I like to swim.</div>
<div>...And play volleyball too!</div>
</slot>
</fieldset>
</user-card>
菜单示例
现在让我们回到本章开头提到的 `<custom-menu>`。
我们可以使用插槽来分配元素。
以下是 `<custom-menu>` 的标记
<custom-menu>
<span slot="title">Candy menu</span>
<li slot="item">Lollipop</li>
<li slot="item">Fruit Toast</li>
<li slot="item">Cup Cake</li>
</custom-menu>
带有适当插槽的影子 DOM 模板
<template id="tmpl">
<style> /* menu styles */ </style>
<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>
</template>
- `<span slot="title">` 进入 `<slot name="title">`。
- 在 `<custom-menu>` 中有很多 `<li slot="item">`,但在模板中只有一个 `<slot name="item">`。因此,所有这样的 `<li slot="item">` 都按顺序追加到 `<slot name="item">`,从而形成列表。
扁平化的 DOM 变成
<custom-menu>
#shadow-root
<style> /* menu styles */ </style>
<div class="menu">
<slot name="title">
<span slot="title">Candy menu</span>
</slot>
<ul>
<slot name="item">
<li slot="item">Lollipop</li>
<li slot="item">Fruit Toast</li>
<li slot="item">Cup Cake</li>
</slot>
</ul>
</div>
</custom-menu>
可能有人会注意到,在一个有效的 DOM 中,`<li>` 必须是 `<ul>` 的直接子元素。但那是扁平化的 DOM,它描述了组件的渲染方式,这种事情在这里自然而然地发生。
我们只需要添加一个 `click` 处理程序来打开/关闭列表,`<custom-menu>` 就准备好了
customElements.define('custom-menu', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
// tmpl is the shadow DOM template (above)
this.shadowRoot.append( tmpl.content.cloneNode(true) );
// we can't select light DOM nodes, so let's handle clicks on the slot
this.shadowRoot.querySelector('slot[name="title"]').onclick = () => {
// open/close the menu
this.shadowRoot.querySelector('.menu').classList.toggle('closed');
};
}
});
以下是完整的演示
当然,我们可以为它添加更多功能:事件、方法等等。
更新插槽
如果外部代码想要动态添加/删除菜单项怎么办?
浏览器会监控插槽,并在插槽元素被添加/删除时更新渲染。
此外,由于光 DOM 节点不是被复制,而是直接渲染在插槽中,因此它们内部的更改会立即变得可见。
因此,我们不需要做任何事情来更新渲染。但是,如果组件代码想要了解插槽的变化,那么可以使用 `slotchange` 事件。
例如,这里在 1 秒后动态插入菜单项,并在 2 秒后更改标题
<custom-menu id="menu">
<span slot="title">Candy menu</span>
</custom-menu>
<script>
customElements.define('custom-menu', class extends HTMLElement {
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>`;
// shadowRoot can't have event handlers, so using the first child
this.shadowRoot.firstElementChild.addEventListener('slotchange',
e => alert("slotchange: " + e.target.name)
);
}
});
setTimeout(() => {
menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Lollipop</li>')
}, 1000);
setTimeout(() => {
menu.querySelector('[slot="title"]').innerHTML = "New menu";
}, 2000);
</script>
菜单渲染在每次无需我们干预的情况下更新。
这里有两个slotchange
事件
-
在初始化时
slotchange: title
立即触发,因为来自 light DOM 的slot="title"
进入相应的插槽。 -
1 秒后
slotchange: item
触发,当一个新的<li slot="item">
被添加时。
请注意:在 2 秒后,当slot="title"
的内容被修改时,没有slotchange
事件。这是因为没有插槽更改。我们修改了插槽元素内部的内容,这是另一回事。
如果我们想从 JavaScript 中跟踪 light DOM 的内部修改,这也是可能的,可以使用更通用的机制:MutationObserver.
插槽 API
最后,让我们提一下与插槽相关的 JavaScript 方法。
正如我们之前所见,JavaScript 查看“真实”DOM,而不进行扁平化。但是,如果 shadow tree 有{mode: 'open'}
,那么我们可以找出哪些元素分配给插槽,反之亦然,通过元素内部的插槽。
node.assignedSlot
– 返回node
分配到的<slot>
元素。slot.assignedNodes({flatten: true/false})
– 分配给插槽的 DOM 节点。flatten
选项默认值为false
。如果显式设置为true
,则它会更深入地查看扁平化的 DOM,在嵌套组件的情况下返回嵌套插槽,以及在没有分配节点的情况下返回回退内容。slot.assignedElements({flatten: true/false})
– 分配给插槽的 DOM 元素(与上面相同,但仅限于元素节点)。
当我们不仅需要显示插槽内容,还需要在 JavaScript 中跟踪它时,这些方法很有用。
例如,如果<custom-menu>
组件想要知道它显示了什么,那么它可以跟踪slotchange
并从slot.assignedElements
获取项目。
<custom-menu id="menu">
<span slot="title">Candy menu</span>
<li slot="item">Lollipop</li>
<li slot="item">Fruit Toast</li>
</custom-menu>
<script>
customElements.define('custom-menu', class extends HTMLElement {
items = []
connectedCallback() {
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = `<div class="menu">
<slot name="title"></slot>
<ul><slot name="item"></slot></ul>
</div>`;
// triggers when slot content changes
this.shadowRoot.firstElementChild.addEventListener('slotchange', e => {
let slot = e.target;
if (slot.name == 'item') {
this.items = slot.assignedElements().map(elem => elem.textContent);
alert("Items: " + this.items);
}
});
}
});
// items update after 1 second
setTimeout(() => {
menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Cup Cake</li>')
}, 1000);
</script>
总结
通常,如果一个元素有 shadow DOM,那么它的 light DOM 不会被显示。插槽允许在 shadow DOM 的指定位置显示来自 light DOM 的元素。
插槽有两种类型
- 命名插槽:
<slot name="X">...</slot>
– 获取带有slot="X"
的 light 子元素。 - 默认插槽:第一个没有名称的
<slot>
(后续的无名插槽将被忽略) – 获取未插槽的 light 子元素。 - 如果同一个插槽有多个元素,它们将一个接一个地追加。
<slot>
元素的内容用作回退。如果没有轻量级子元素,则显示它。
在插槽内渲染插槽元素的过程称为“组合”。结果称为“扁平化的 DOM”。
组合实际上不会移动节点,从 JavaScript 的角度来看,DOM 仍然相同。
JavaScript 可以使用以下方法访问插槽
slot.assignedNodes/Elements()
- 返回slot
内的节点/元素。node.assignedSlot
- 反向属性,通过节点返回插槽。
如果我们想知道显示了什么,我们可以使用以下方法跟踪插槽内容
slotchange
事件 - 在插槽首次填充时触发,以及在插槽元素的任何添加/删除/替换操作时触发,但不包括其子元素。插槽是event.target
。- MutationObserver 可以更深入地了解插槽内容,观察其内部的变化。
现在,我们已经知道如何在阴影 DOM 中显示来自轻量级 DOM 的元素,让我们看看如何正确地为它们设置样式。基本规则是,阴影元素在内部设置样式,而轻量级元素在外部设置样式,但有一些值得注意的例外。
我们将在下一章中详细介绍。
评论
<code>
标签,对于多行代码,请将它们包装在<pre>
标签中,对于超过 10 行的代码,请使用沙盒 (plnkr,jsbin,codepen…)