Skip to content

slot 插槽

slot 全局属性用于在 Web Component 中为 Shadow DOM 的插槽(Slot)分配内容。通过 slot 属性,使用者可以将自定义内容插入到组件的特定位置,实现灵活的组件组合和内容分发。

前置知识

阅读本节前,建议先了解:part 与 exportparts

基础概念

什么是插槽

插槽(Slot)是 Web Components 中 Shadow DOM 的内容分发机制。组件定义者使用 <slot> 元素在 Shadow DOM 中声明"占位符",使用者通过 slot 属性将内容分发到对应的占位符中。

html
<!-- 组件定义:Shadow DOM 中的 slot 占位符 -->
<template id="my-card">
  <div class="card">
    <div class="header"><slot name="title">默认标题</slot></div>
    <div class="body"><slot>默认内容</slot></div>
    <div class="footer"><slot name="actions">默认操作</slot></div>
  </div>
</template>

<!-- 组件使用:通过 slot 属性分发内容 -->
<my-card>
  <span slot="title">自定义标题</span>
  <p>自定义内容</p>
  <button slot="actions">确认</button>
</my-card>

具名插槽与默认插槽

插槽类型组件定义使用方式说明
默认插槽<slot>直接放入内容不需要 slot 属性
具名插槽<slot name="xxx"><span slot="xxx">需要匹配 name 和 slot 属性

语法

定义插槽

html
<!-- 默认插槽 -->
<slot>默认内容(当没有分发内容时显示)</slot>

<!-- 具名插槽 -->
<slot name="header"></slot>
<slot name="body"></slot>
<slot name="footer"></slot>

使用插槽

html
<!-- 默认插槽:直接放入内容 -->
<my-component>
  <p>这会放入默认插槽</p>
</my-component>

<!-- 具名插槽:使用 slot 属性匹配 -->
<my-component>
  <h2 slot="header">标题</h2>
  <p>这会放入默认插槽</p>
  <button slot="footer">操作</button>
</my-component>

详细说明

插槽机制原理

当浏览器渲染 Web Component 时,插槽的分发过程如下:

  1. 收集组件标签内的所有子元素("轻量 DOM"或 Light DOM)
  2. 根据 slot 属性值,将子元素与 Shadow DOM 中的 <slot> 元素匹配
  3. 未匹配的子元素分配给默认 <slot>(如果存在)
  4. 渲染时,被分配的元素"投影"到对应插槽的位置
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>插槽分发机制</title>
</head>
<body>
  <script>
    class UserCard extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
          <style>
            .card {
              border: 1px solid #e2e8f0;
              border-radius: 8px;
              padding: 16px;
              width: 300px;
            }
            .avatar {
              width: 64px;
              height: 64px;
              border-radius: 50%;
              background: #e2e8f0;
            }
            .info { margin-top: 12px; }
            .name { font-weight: 600; font-size: 18px; }
            .bio { color: #64748b; margin-top: 4px; }
            .actions { margin-top: 12px; display: flex; gap: 8px; }
          </style>
          <div class="card">
            <slot name="avatar">
              <!-- 默认头像 -->
              <div class="avatar"></div>
            </slot>
            <div class="info">
              <div class="name"><slot name="name">匿名用户</slot></div>
              <div class="bio"><slot name="bio">这个人很懒,什么都没写。</slot></div>
            </div>
            <div class="actions">
              <slot name="actions">
                <button>关注</button>
              </slot>
            </div>
          </div>
        `;
      }
    }
    customElements.define('user-card', UserCard);
  </script>

  <!-- 完整使用 -->
  <user-card>
    <img slot="avatar" src="avatar.jpg" alt="用户头像" width="64" height="64">
    <span slot="name">张三</span>
    <span slot="bio">前端开发工程师,热爱开源。</span>
    <div slot="actions">
      <button>关注</button>
      <button>发消息</button>
    </div>
  </user-card>

  <!-- 部分使用(其余显示默认内容) -->
  <user-card>
    <span slot="name">李四</span>
  </user-card>
</body>
</html>

多个元素分配到同一插槽

多个元素可以使用相同的 slot 属性值,它们都会被分发到同一个插槽中:

html
<!-- 组件定义 -->
<template id="icon-group">
  <style>
    .icons { display: flex; gap: 8px; }
  </style>
  <div class="icons">
    <slot name="icon"></slot>
  </div>
</template>

<!-- 使用:多个元素共享同一个具名插槽 -->
<icon-group>
  <img slot="icon" src="star.svg" alt="收藏">
  <img slot="icon" src="heart.svg" alt="喜欢">
  <img slot="icon" src="share.svg" alt="分享">
</icon-group>

插槽的样式化

通过 ::slotted() 伪元素,组件作者可以为被分发到插槽中的内容定义样式:

javascript
class StyledCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        /* ::slotted() 选择被分发到插槽的顶层元素 */
        ::slotted(h2) {
          font-size: 20px;
          color: #1e293b;
          margin: 0;
        }

        ::slotted(p) {
          color: #64748b;
          line-height: 1.6;
        }

        ::slotted([slot="actions"]) {
          display: flex;
          gap: 8px;
        }

        /* 只能选择直接子元素,不能深入选择 */
        /* ::slotted(.inner-class) 如果 .inner-class 不是顶层元素则无效 */
      </style>
      <div class="card">
        <slot name="header"></slot>
        <slot></slot>
        <slot name="actions"></slot>
      </div>
    `;
  }
}

插槽事件与 Slotchange

slotchange 事件在插槽内容发生变化时触发:

javascript
class SlotWatcher extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <div class="container">
        <slot name="items"></slot>
      </div>
    `;

    // 监听插槽内容变化
    this.shadowRoot.querySelector('slot[name="items"]')
      .addEventListener('slotchange', (e) => {
        const items = e.target.assignedElements();
        console.log('插槽内容已更新,共', items.length, '个元素');

        // 对每个分发元素进行处理
        items.forEach(item => {
          item.classList.add('slot-item');
        });
      });
  }
}
customElements.define('slot-watcher', SlotWatcher);

实战示例

完整的选项卡组件

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Slot 属性实战:选项卡组件</title>
  <style>
    body { font-family: system-ui; padding: 40px; }
    .demo { margin-bottom: 40px; }
  </style>
</head>
<body>
  <script>
    class TabPanel extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.shadowRoot.innerHTML = `
          <style>
            :host {
              display: block;
              border: 1px solid #e2e8f0;
              border-radius: 8px;
              overflow: hidden;
            }
            .tab-bar {
              display: flex;
              border-bottom: 1px solid #e2e8f0;
              background: #f8fafc;
            }
            .tab-btn {
              padding: 10px 20px;
              border: none;
              background: transparent;
              cursor: pointer;
              font-size: 14px;
              color: #64748b;
              transition: all 0.2s;
              border-bottom: 2px solid transparent;
            }
            .tab-btn[aria-selected="true"] {
              color: #3b82f6;
              border-bottom-color: #3b82f6;
              background: white;
            }
            .tab-content {
              padding: 20px;
            }
            /* 通过 ::slotted() 样式化分发内容 */
            ::slotted(h3) {
              margin: 0 0 8px;
              font-size: 16px;
            }
            ::slotted(p) {
              color: #475569;
              line-height: 1.6;
              margin: 0;
            }
          </style>
          <div class="tab-bar" part="tab-bar" role="tablist"></div>
          <div class="tab-content">
            <slot></slot>
          </div>
        `;
        this._tabs = [];
        this._activeIndex = 0;
      }

      connectedCallback() {
        // 收集所有带 slot 属性的 tab-panel 元素
        this._tabs = Array.from(this.querySelectorAll('[slot^="panel-"]'));

        // 创建标签按钮
        const tabBar = this.shadowRoot.querySelector('.tab-bar');
        this._tabs.forEach((panel, index) => {
          const btn = document.createElement('button');
          btn.className = 'tab-btn';
          btn.role = 'tab';
          btn.textContent = panel.dataset.label || `标签 ${index + 1}`;
          btn.setAttribute('aria-selected', index === 0 ? 'true' : 'false');
          btn.addEventListener('click', () => this.switchTab(index));
          tabBar.appendChild(btn);
        });

        // 初始隐藏非激活面板
        this._tabs.forEach((panel, index) => {
          panel.style.display = index === 0 ? 'block' : 'none';
        });
      }

      switchTab(index) {
        this._activeIndex = index;
        const buttons = this.shadowRoot.querySelectorAll('.tab-btn');
        buttons.forEach((btn, i) => {
          btn.setAttribute('aria-selected', i === index ? 'true' : 'false');
        });
        this._tabs.forEach((panel, i) => {
          panel.style.display = i === index ? 'block' : 'none';
        });
      }
    }
    customElements.define('tab-panel', TabPanel);
  </script>

  <div class="demo">
    <h2>使用 slot 属性的选项卡</h2>
    <tab-panel>
      <div slot="panel-0" data-label="概述">
        <h3>产品概述</h3>
        <p>这是一个功能强大的选项卡组件,使用 Web Components 和 slot 属性实现。</p>
      </div>
      <div slot="panel-1" data-label="特性">
        <h3>核心特性</h3>
        <p>支持任意内容、自定义标签名称、键盘导航和无障碍访问。</p>
      </div>
      <div slot="panel-2" data-label="文档">
        <h3>使用文档</h3>
        <p>使用 slot="panel-N" 属性定义面板内容,通过 data-label 属性设置标签名。</p>
      </div>
    </tab-panel>
  </div>
</body>
</html>

注意事项

::slotted() 的限制

css
/* ::slotted() 只能选择直接子元素 */
::slotted(p) { /* 有效:p 是顶层被分发元素 */ }

/* ::slotted() 不能选择嵌套元素 */
/* ::slotted(p span) { 无效 } */

/* ::slotted() 不能与后代选择器组合 */
/* ::slotted(p).active { 无效,但 ::slotted(p.active) 有效 } */

slot 属性只在组件使用时有效

html
<!-- 在普通 HTML 中,slot 属性无意义 -->
<div slot="title">这不会做任何事情</div>

<!-- slot 属性只在 Web Component 的 light DOM 中有效 -->
<my-component>
  <div slot="title">这里有效</div>
</my-component>

最佳实践

1. 为所有插槽提供默认内容

html
<!-- 推荐:每个插槽都有默认内容 -->
<slot name="header"><h3>默认标题</h3></slot>
<slot><p>默认内容区域</p></slot>
<slot name="footer"><button>确定</button></slot>

<!-- 避免:空插槽,使用者必须提供内容 -->
<slot name="header"></slot>

2. 语义化 slot 命名

html
<!-- 推荐:使用语义化名称 -->
<slot name="title">标题</slot>
<slot name="description">描述</slot>
<slot name="avatar">头像</slot>
<slot name="actions">操作</slot>

<!-- 避免:使用数字或无意义名称 -->
<slot name="slot1">标题</slot>
<slot name="a">描述</slot>
<slot name="xxx">头像</slot>

下一节

继续学习:popover 弹出层

参考链接