2022 年 9 月 27 日

Shadow DOM 插槽,组合

许多类型的组件,例如选项卡、菜单、图片库等,都需要内容才能渲染。

就像内置浏览器 <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>
  1. `<span slot="title">` 进入 `<slot name="title">`。
  2. 在 `<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事件

  1. 在初始化时

    slotchange: title立即触发,因为来自 light DOM 的slot="title"进入相应的插槽。

  2. 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 的元素,让我们看看如何正确地为它们设置样式。基本规则是,阴影元素在内部设置样式,而轻量级元素在外部设置样式,但有一些值得注意的例外。

我们将在下一章中详细介绍。

教程地图

评论

在评论之前阅读...
  • 如果您有改进建议,请 提交 GitHub 问题 或拉取请求,而不是评论。
  • 如果您无法理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用 <code> 标签,对于多行代码,请将它们包装在 <pre> 标签中,对于超过 10 行的代码,请使用沙盒 (plnkrjsbincodepen…)