Slot 内容分发

深入理解 Web Components 中的 Slot 机制:命名插槽、备用内容、slotchange 事件,以及 ::slotted 伪元素。

Slot 内容分发

一、Slot 概述

1.1 什么是 Slot

Slot(插槽)是 Web Components 中的一种内容分发机制,允许在外部分别定义组件的各个部分,同时保持组件内部的封装性。Slot 可以理解为 Shadow DOM 内部的"占位符",外部内容会通过这个占位符被分发到 Shadow DOM 中渲染。

<!-- 定义组件 -->
<my-card>
  <h2 slot="title">Card Title</h2>
  <p slot="content">Card content goes here</p>
</my-card>
// 组件内部
class MyCard extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <div class="card">
        <slot name="title"></slot>
        <slot name="content"></slot>
      </div>
    `;
  }
}

1.2 Slot 的类型

类型 描述 示例
命名插槽 name 属性,接收指定 slot 的内容 <slot name="header">
默认插槽 没有 name 属性的插槽,接收所有未匹配的内容 <slot>

二、命名插槽

2.1 基本用法

<!-- 组件使用 -->
<user-profile>
  <img slot="avatar" src="/avatar.jpg" alt="User avatar" />
  <span slot="username">john_doe</span>
  <span slot="bio">Software developer</span>
</user-profile>
// 组件定义
class UserProfile extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        .profile { display: flex; gap: 16px; padding: 16px; }
        .info { display: flex; flex-direction: column; }
      </style>
      <div class="profile">
        <slot name="avatar"></slot>
        <div class="info">
          <slot name="username"></slot>
          <slot name="bio"></slot>
        </div>
      </div>
    `;
  }
}
customElements.define('user-profile', UserProfile);

2.2 多个元素到同一插槽

<!-- 多个元素可以匹配到同一个命名插槽 -->
<my-list>
  <li slot="item">Item 1</li>
  <li slot="item">Item 2</li>
  <li slot="item">Item 3</li>
</my-list>
// 组件内部
class MyList extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <ul>
        <slot name="item"></slot>
      </ul>
    `;
  }
}

三、默认插槽与备用内容

3.1 默认插槽

没有 name 属性的 <slot> 会接收所有没有指定 slot 属性的外部内容:

<my-component>
  <p>Goes to default slot</p>
  <p>Also default slot</p>
  <p slot="named">Goes to named slot</p>
</my-component>
class MyComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <slot></slot>  <!-- 接收前两个 p -->
      <slot name="named"></slot>  <!-- 接收第三个 p -->
    `;
  }
}

3.2 备用内容

插槽内部的内容作为"备用内容",当没有外部内容分发时显示:

<slot name="title">
  <h3>Default Title</h3>
  <p>Default description</p>
</slot>
<!-- 没有提供 title 内容时,显示默认标题 -->
<my-card></my-card>

<!-- 提供了 title 内容时,默认内容被替换 -->
<my-card>
  <h2 slot="title">Custom Title</h2>
</my-card>

四、slotchange 事件

4.1 基本用法

当插槽内的节点发生变化时,会触发 slotchange 事件:

class MyComponent extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `<slot></slot>`;

    // 监听 slotchange
    this.shadowRoot.querySelector('slot').addEventListener('slotchange', (e) => {
      console.log('Slot content changed');
      console.log('Assigned nodes:', e.target.assignedNodes());
    });
  }
}

4.2 实际应用

// 动态更新组件状态
class TagList extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <style>
        :host { display: flex; flex-wrap: wrap; gap: 8px; }
        .tag { padding: 4px 8px; background: #e0e0e0; border-radius: 4px; font-size: 14px; }
      </style>
      <slot id="tags"></slot>
    `;

    this.shadowRoot.getElementById('tags').addEventListener('slotchange', () => {
      this.updateCount();
    });
  }

  updateCount() {
    const count = this.shadowRoot.querySelector('slot')
      .assignedNodes()
      .filter(n => n.nodeType === Node.ELEMENT_NODE)
      .length;
    console.log(`Tag count: ${count}`);
  }
}
customElements.define('tag-list', TagList);

五、::slotted 伪元素

5.1 基本用法

::slotted() 伪元素允许从 Shadow DOM 内部样式化被分发到插槽的元素:

<style>
  /* 匹配 slot 传入的 div 元素 */
  ::slotted(div) {
    color: blue;
    border: 1px solid blue;
  }
</style>
<slot></slot>
<my-component>
  <div>This will be blue and have blue border</div>
</my-component>

5.2 限制

/* ❌ 只能选中直接分配给该插槽的元素 */
/* 不能选中插槽内部 slot 的后代 */
::slotted(div p) { }  /* 无效 */

/* ❌ 不能使用组合器 */
::slotted(div + span) { }  /* 无效 */

/* ✅ 只能设置部分 CSS 属性 */
::slotted(div) {
  color: blue;      /* ✅ 有效 */
  background: red;   /* ✅ 有效 */
  font-size: 14px;  /* ✅ 有效 */
  display: block;   /* ❌ 无效 - 布局属性可能无效 */
}

六、assignedNodes 方法

6.1 获取分配的节点

const slot = this.shadowRoot.querySelector('slot[name="item"]');

// 获取分配给该插槽的所有节点
const nodes = slot.assignedNodes();

// 获取分配给该插槽的元素(不包括文本节点)
const elements = slot.assignedElements();

// 获取带选项的分配
const options = { flatten: true };
const flattened = slot.assignedNodes(options);

6.2 实际应用

// 访问分发内容进行操作
class Accordion extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = `
      <slot name="header"></slot>
      <slot name="content"></slot>
    `;

    this.headerSlot = this.shadowRoot.querySelector('slot[name="header"]');
    this.headerSlot.addEventListener('slotchange', () => {
      this.attachHeaderListeners();
    });
  }

  attachHeaderListeners() {
    const headers = this.headerSlot.assignedElements();
    headers.forEach((header, index) => {
      header.addEventListener('click', () => {
        this.toggle(index);
      });
    });
  }

  toggle(index) {
    const contentSlot = this.shadowRoot.querySelector('slot[name="content"]');
    const contents = contentSlot.assignedElements();
    // 切换第 index 个内容面板
    contents[index].classList.toggle('expanded');
  }
}

七、面试高频问题

Q: slot 和 ::slotted 有什么区别?

回答要点<slot> 是实际元素,作为内容分发的占位符;::slotted() 是 CSS 伪元素,用于样式化被分发到插槽的内容。Slot 定义在哪里显示外部内容,::slotted 定义如何样式化这些外部内容。

Q: 如何监听插槽内容的变化?

回答要点:通过监听 slot 元素的 slotchange 事件。但需要注意 slotchange 不会在属性变化时触发,只在节点增删时触发。如果需要监听属性变化,可能需要 MutationObserver。

Q: 备用内容和分发内容的关系是什么?

回答要点:备用内容是插槽标签内的默认内容,当没有外部内容分发到该插槽时显示。一旦有外部内容分发,备用内容被完全替换,不存在"合并"关系。


参考资料

延展阅读