news 2026/10/7 2:39:14

Shadow DOM Slots and Composition in Modern JavaScript Tutorial: Building Composable Web Components

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Shadow DOM Slots and Composition in Modern JavaScript Tutorial: Building Composable Web Components
  • 文档/教程
  • 前端

【免费下载链接】en.javascript.info

Modern JavaScript Tutorial

项目地址:https://gitcode.com/gh_mirrors/en/en.javascript.info
点击查看免费下载

这篇指南来自现代 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事件:

  1. 初始化时:slotchange: title立即触发,因为 light DOM 中的slot="title"进入了对应的插槽。
  2. 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

项目地址:https://gitcode.com/gh_mirrors/en/en.javascript.info
点击查看免费下载
上一篇:FlatBuffers 编译器 `flatc` 完全指南:命令行用法、生成器选项与数据转换实战
下一篇:9Router CLI 完整指南:安装、启动参数与终端管理实战

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 2:37:39

WeChatMsg 完整上手指南:Mac 微信聊天记录导出 4 种格式 + 年度报告

WeChatMsg 完整上手指南&#xff1a;Mac 微信聊天记录导出 4 种格式 年度报告 【免费下载链接】WeChatMsg 提取微信聊天记录&#xff0c;将其导出成HTML、Word、CSV文档永久保存&#xff0c;对聊天记录进行分析生成年度聊天报告 项目地址: https://gitcode.com/GitHub_Trend…

作者头像 李华
网站建设 2026/10/7 2:36:57

会展经济与管理论文怎么写?2026年热门AI工具推荐⚡这个写会展专业论文的AI工具一定要收藏✨

会展经济与管理专业的毕业论文&#xff0c;写起来比很多同学预想的要复杂。选题要贴合展览策划、节事活动、会议管理、场馆运营等方向&#xff0c;理论框架得从GETS模型、AIPA模型、利益相关者理论、体验经济理论里挑合适的来套&#xff0c;数据收集要涉及参展商满意度调研、观…

作者头像 李华
网站建设 2026/10/7 2:36:37

Ubuntu挂载硬盘全攻略:从lsblk到fstab,解决权限与NTFS问题

能折腾到挂载硬盘这一步的&#xff0c;多半已经不是第一次装Ubuntu了。我见过太多人卡在这一关&#xff1a;系统装好了&#xff0c;数据盘认不出来&#xff0c;插上U盘没反应&#xff0c;重启之后挂载又丢了&#xff0c;fstab写错直接开机进维护模式。这几个场景&#xff0c;基…

作者头像 李华
网站建设 2026/10/7 2:35:35

Codex生态插件怎么选?GitHub万星5款AI编程工具实战对比

最近我把 GitHub 上跟 Codex 生态相关的插件翻了个底朝天&#xff0c;从几十星的小工具到几万星的大项目都试着跑了一遍&#xff0c;这篇直接给你划重点&#xff1a;5 个万星级的 Codex 类插件和周边工具&#xff0c;装完把日常编码体验拉满。所谓“装完拉满”&#xff0c;不是…

作者头像 李华
网站建设 2026/10/7 2:35:34

Codex插件实测:从聊天助手到自动改代码的智能体

最近我把 Codex 新出的插件功能翻来覆去用了两天&#xff0c;用完第一反应不是“哇好强”&#xff0c;而是实实在在有点慌。不是怕失业那种矫情的慌&#xff0c;是发现自己的开发习惯、代码审查流程、甚至整个提 PR 之前的“肌肉记忆”&#xff0c;全都被这个插件按在地上摩擦了…

作者头像 李华