- 文档/教程
- 前端
【免费下载链接】en.javascript.info
Modern JavaScript Tutorial
这篇指南来自现代 JavaScript 教程(Modern JavaScript Tutorial)仓库的 Web Components 章节,深入讲解 Shadow DOM 中<slot>插槽的完整机制:命名插槽、默认插槽、插槽回退内容、slotchange事件以及配套的插槽 API。你将掌握如何让自定义元素(如<custom-menu>、<user-card>)接收并渲染 light DOM 传入的内容,理解"composition(组合)"与"flattened DOM(扁平化 DOM)"的真实含义,并能在实际项目中写出可复用、可动态更新内容的 Web Components。
组合问题的本质:组件需要内容
很多类型的组件——标签页(tabs)、菜单(menus)、图片画廊(image galleries)等——都需要渲染外部传入的内容。这与浏览器内置的<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 内不生效,视觉效果可能丢失;而且需要额外编写不少代码。
幸运的是,我们完全不必这么做:Shadow DOM 支持<slot>元素,浏览器会自动用 light DOM 的内容将其填充。这正是本仓库 5-slots-composition 章节 的核心主题。
命名插槽(Named slots)
先看一个简单示例。下面的<user-card>在 shadow DOM 中提供了两个插槽,由 light 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>在 shadow DOM 中,<slot name="X">定义了一个"插入点"(insertion point)——即带有slot="X"的元素被渲染的位置。
浏览器随后执行 "composition(组合)":它从 light DOM 中取出元素,把它们渲染到 shadow 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>我们创建了 shadow DOM,所以它出现在#shadow-root之下。此时元素同时拥有 light DOM 和 shadow DOM。
渲染时,对于 shadow DOM 中的每个<slot name="...">,浏览器会在 light DOM 中寻找同名的slot="..."元素,并把它们渲染进插槽内:
扁平化 DOM(flattened DOM)
组合后的结果被称为 "flattened DOM"(扁平化 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> 节点仍在原处,位于 <user-card> 之下 alert( document.querySelectorAll('user-card span').length ); // 2所以,扁平化 DOM 是由 shadow DOM 通过插入插槽推导而来的。浏览器渲染它,并基于它进行样式继承和事件传播(后文详述)。但 JavaScript 看到的仍然是"原样"的文档,即扁平化之前的结构。
注意:只有顶层子元素可以带
slot="..."属性
slot="..."属性只对 shadow host(本例中的<user-card>元素)的直接子元素有效,对嵌套元素会被忽略。例如下面代码中第二个
<span>会被忽略(因为它不是<user-card>的顶层子元素):<user-card> <span slot="username">John Smith</span> <div> <!-- 无效的 slot,必须是 user-card 的直接子元素 --> <span slot="birthday">01.01.2001</span> </div> </user-card>
如果 light DOM 中有多个同名 slot 的元素,它们会一个接一个地被追加进插槽。例如:
<user-card> <span slot="username">John</span> <span slot="username">Smith</span> </user-card>会得到如下扁平化 DOM——<slot name="username">中包含两个元素:
<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 fallback content)
如果在<slot>内部放入内容,它就变成回退("默认")内容。当 light DOM 中没有对应的填充元素时,浏览器会显示它。
例如,下面这段 shadow DOM 中,如果 light DOM 里没有slot="username",就会渲染Anonymous:
<div>Name: <slot name="username">Anonymous</slot> </div>默认插槽:第一个未命名的插槽
shadow DOM 中第一个没有 name 的<slot>是 "default" 插槽。它接收 light 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>所有未被插槽收纳的 light DOM 内容都会进入 "Other information" 的 fieldset 中。元素是一个接一个被追加进插槽的,所以两条未分类信息会一起待在默认插槽里。
对应的扁平化 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>的标记如下:
<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>带有合适插槽的 shadow 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 是上面的 shadow DOM 模板 this.shadowRoot.append( tmpl.content.cloneNode(true) ); // 我们无法选中 light DOM 的节点,所以把点击事件挂在插槽上 this.shadowRoot.querySelector('slot[name="title"]').onclick = () => { // 打开/关闭菜单 this.shadowRoot.querySelector('.menu').classList.toggle('closed'); }; } });仓库中的完整可运行示例位于 menu.view/index.html。该示例展示了插槽在实际组件中的完整用法,值得逐行研读:
- 模板通过
<template id="tmpl">定义,内含<style>与结构化的.menu布局; - 样式中使用
::slotted([slot="title"])选择器直接为从 light DOM 进入插槽的标题元素设置字号、加粗与光标样式(::slotted的完整语义在 Shadow DOM 样式章节 有专门讲解); - 通过
::slotted([slot="title"])::before添加文件夹图标,再配合.closed类实现展开/收起状态切换; .closed ul { display: none; }控制列表显隐;- 组件类在
connectedCallback()中attachShadow({mode: 'open'}),克隆模板内容到 shadowRoot,并把点击事件绑定在slot[name="title"]上——因为组件代码无法直接选择 light DOM 的节点,把事件挂在插槽上正是关键技巧。
<template id="tmpl"> <style> ul { margin: 0; list-style: none; padding-left: 20px; } ::slotted([slot="title"]) { font-size: 18px; font-weight: bold; cursor: pointer; } ::slotted([slot="title"])::before { content: '📂'; font-size: 14px; } .closed ::slotted([slot="title"])::before { content: '📁'; } .closed ul { display: none; } </style> <div class="menu"> <slot name="title"></slot> <ul><slot name="item"></slot></ul> </div> </template> <script> customElements.define('custom-menu', class extends HTMLElement { connectedCallback() { this.attachShadow({mode: 'open'}); this.shadowRoot.append( tmpl.content.cloneNode(true) ); this.shadowRoot.querySelector('slot[name="title"]').onclick = () => { this.shadowRoot.querySelector('.menu').classList.toggle('closed'); }; } }); </script> <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>当然,我们还可以给它添加更多功能:事件、方法等等。
动态更新插槽(Updating slots)
如果外部代码想要动态添加/移除菜单项怎么办?
浏览器会监视插槽,当被插槽收纳的元素被添加/移除时,自动更新渲染。
此外,由于 light 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 不能挂事件处理器,所以挂在其第一个子元素上 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 秒后:新增
<li slot="item">时,slotchange: item触发。
请注意:2 秒后修改slot="title"的内容时,没有触发slotchange事件。因为插槽本身没有变化——我们修改的是被插槽收纳元素内部的内容,这是另一回事。
如果想从 JavaScript 追踪 light DOM 内部的修改,可以使用更通用的机制:MutationObserver(详见 MutationObserver 章节)。
插槽 API(Slot API)
最后介绍与插槽相关的 JavaScript 方法。
如前所述,JavaScript 看到的是"真实"的 DOM,不做扁平化。但如果 shadow 树是{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>`; // 插槽内容变化时触发 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); } }); } }); // 1 秒后菜单项更新 setTimeout(() => { menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Cup Cake</li>') }, 1000); </script>这段代码把slotchange的event.target(即触发事件的插槽)与assignedElements()结合起来:当 item 插槽内容变化时,组件同步维护自己的items数组——这是组件内部数据模型与外部传入内容保持同步的典型模式。
总结
通常,如果元素拥有 shadow DOM,那么它的 light DOM 不会被显示。插槽允许我们在 shadow DOM 的指定位置展示来自 light DOM 的元素。
插槽有两种类型:
- 命名插槽:
<slot name="X">...</slot>—— 收纳带slot="X"的 light 子元素。 - 默认插槽:第一个没有 name 的
<slot>(后续未命名插槽会被忽略)—— 收纳未被插槽收纳的 light 子元素。 - 如果同一插槽有多个元素,它们会被一个接一个地追加。
<slot>元素的内容用作回退内容;当插槽没有对应的 light 子元素时显示。
将插槽元素渲染进对应插槽的过程称为 "composition(组合)",其结果称为 "flattened DOM(扁平化 DOM)"。
组合并不会真正移动节点——从 JavaScript 的角度看,DOM 保持不变。
JavaScript 可以通过以下方法访问插槽:
slot.assignedNodes/Elements()—— 返回插槽内部的节点/元素。node.assignedSlot—— 反向属性,由节点返回其所在的插槽。
如果想了解组件正在展示什么,可以用以下方式追踪插槽内容:
slotchange事件 —— 在插槽首次被填充时触发,并在被插槽收纳元素发生添加/移除/替换操作时触发(但不包括其子元素的变化)。触发事件的插槽就是event.target。- 使用
MutationObserver深入插槽内容,观察其内部变化(见 MutationObserver 章节)。
了解了如何在 shadow DOM 中展示 light DOM 元素后,下一步就是如何正确地给它们设置样式。基本规则是:shadow 元素在内部设置样式,light 元素在外部设置样式,但也有值得注意的例外——:host、:host(selector)、::slotted(selector)以及穿透 Shadow DOM 的 CSS 自定义属性等细节,详见 Shadow DOM 样式章节。
如果你希望进一步夯实基础,建议顺带阅读同一 Web Components 系列中的 Shadow DOM 入门章节(了解 shadow tree 与 light tree 的基本概念)以及 Template 元素章节(了解<template>在组件模板复用中的作用),它们与插槽组合一起构成了完整的 Web Components 实现路径。
- 文档/教程
- 前端
【免费下载链接】en.javascript.info
Modern JavaScript Tutorial
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考