Template、slot 和 shadow

Web 组件的优点是可重用性:您可以创建一个 UI 小部件一次,并多次重复使用。虽然您确实需要 JavaScript 来创建 Web 组件,但您不需要 JavaScript 库。HTML 和相关的 API 提供了您所需的一切。

Web 组件标准由三部分组成:HTML 模板自定义元素Shadow DOM。结合使用,它们可以构建自定义的、自包含(封装)的、可重用的元素,这些元素可以像我们已经介绍过的所有其他 HTML 元素一样,无缝集成到现有应用程序中。

在本节中,我们将创建 <star-rating> 元素,这是一个 Web 组件,允许用户在一到五星的范围内对体验进行评分。在命名自定义元素时,建议使用全部小写字母。此外,还要包含一个短划线,因为这有助于区分常规元素和自定义元素。

我们将讨论如何使用 <template><slot> 元素、slot 属性以及 JavaScript 来创建具有封装 Shadow DOM 的模板。然后,我们将重用定义的元素,自定义一部分文本,就像您对任何元素或 Web 组件所做的那样。我们还将简要讨论如何从自定义元素的内部和外部使用 CSS。

<template> 元素

<template> 元素用于声明 HTML 片段,以便使用 JavaScript 克隆并插入到 DOM 中。默认情况下,元素的内容不会呈现。而是使用 JavaScript 实例化它们。

<template id="star-rating-template">
  <form>
    <fieldset>
      <legend>Rate your experience:</legend>
      <rating>
        <input type="radio" name="rating" value="1" aria-label="1 star" required />
        <input type="radio" name="rating" value="2" aria-label="2 stars" />
        <input type="radio" name="rating" value="3" aria-label="3 stars" />
        <input type="radio" name="rating" value="4" aria-label="4 stars" />
        <input type="radio" name="rating" value="5" aria-label="5 stars" />
      </rating>
    </fieldset>
    <button type="reset">Reset</button>
    <button type="submit">Submit</button>
  </form>
</template>

由于 <template> 元素的内容未写入屏幕,因此 <form> 及其内容不会呈现。是的,此 Codepen 是空白的,但如果您检查 HTML 选项卡,您将看到 <template> 标记。

在此示例中,<form> 不是 DOM 中 <template> 的子元素。相反,<template> 元素的内容是由 HTMLTemplateElement.content 属性返回的 DocumentFragment 的子元素。要使其可见,必须使用 JavaScript 来获取内容并将这些内容附加到 DOM。

这段简短的 JavaScript 没有创建自定义元素。相反,此示例已将 <template> 的内容附加到 <body> 中。内容已成为可见、可样式化的 DOM 的一部分。

A screenshot of the previous codepen as shown in the DOM.

仅为一个星级评分实现模板需要 JavaScript 并不是很有用,但是为一个重复使用的、可自定义的星级评分小部件创建 Web 组件是有用的。

<slot> 元素

我们包含一个 slot 以包含自定义的每次出现图例。HTML 提供了一个 <slot> 元素作为 <template> 内的占位符,如果提供名称,则创建一个“命名 slot”。命名 slot 可用于自定义 Web 组件中的内容。<slot> 元素使我们能够控制自定义元素的子元素应插入到其 shadow 树中的位置。

在我们的模板中,我们将 <legend> 更改为 <slot>

<template id="star-rating-template">
  <form>
    <fieldset>
      <slot name="star-rating-legend">
        <legend>Rate your experience:</legend>
      </slot>

如果元素具有 slot 属性且其值与命名 slot 的名称匹配,则 name 属性用于将 slot 分配给其他元素。如果自定义元素没有与 slot 匹配的内容,则会呈现 <slot> 的内容。因此,我们包含了一个带有通用内容的 <legend>,如果有人只是在其 HTML 中包含 <star-rating></star-rating>(不包含任何内容),则可以呈现该内容。

<star-rating>
  <legend slot="star-rating-legend">Blendan Smooth</legend>
</star-rating>
<star-rating>
  <legend slot="star-rating-legend">Hoover Sukhdeep</legend>
</star-rating>
<star-rating>
  <legend slot="star-rating-legend">Toasty McToastface</legend>
  <p>Is this text visible?</p>
</star-rating>

slot 属性是一个全局属性,用于替换 <template><slot> 的内容。在我们的自定义元素中,具有 slot 属性的元素是 <legend>。它不需要是。<slot name="star-rating-legend"> 将被替换为 <anyElement slot="star-rating-legend">,其中 <anyElement> 可以是任何元素,甚至是另一个自定义元素。

未定义的元素

在我们的 <template> 中,我们使用了 <rating> 元素。这不是自定义元素。相反,它是一个未知元素。浏览器在无法识别元素时不会失败。浏览器将无法识别的 HTML 元素视为可以使用 CSS 进行样式设置的匿名内联元素。与 <span> 类似,<rating><star-rating> 元素没有用户代理应用的样式或语义。

请注意,<template> 及其内容未呈现。<template> 是一个已知元素,其中包含不呈现的内容。<star-rating> 元素尚未定义。在我们定义元素之前,浏览器会像显示所有无法识别的元素一样显示它。目前,无法识别的 <star-rating> 被视为匿名内联元素,因此内容(包括图例和第三个 <star-rating> 中的 <p>)的显示方式与它们在 <span> 中时相同。

让我们定义我们的元素,以将此无法识别的元素转换为自定义元素。

自定义元素

定义自定义元素需要 JavaScript。定义后,<star-rating> 元素的内容将被 shadow root 替换,该 shadow root 包含我们与之关联的模板的所有内容。模板中的 <slot> 元素将被 <star-rating> 中元素的内容替换,该元素的 slot 属性值与 <slot> 的 name 值匹配(如果有)。否则,将显示模板 slot 的内容。

自定义元素中未与 slot 关联的内容(例如,我们的第三个 <star-rating> 中的 <p>Is this text visible?</p>)不包含在 shadow root 中,因此不会显示。

我们通过扩展 HTMLElement 定义名为 star-rating 的自定义元素

customElements.define('star-rating',
  class extends HTMLElement {
    constructor() {
      super(); // Always call super first in constructor
      const starRating = document.getElementById('star-rating-template').content;
      const shadowRoot = this.attachShadow({
        mode: 'open'
      });
      shadowRoot.appendChild(starRating.cloneNode(true));
    }
  });

现在元素已定义,每次浏览器遇到 <star-rating> 元素时,它都将按照具有 #star-rating-template 的元素(即我们的模板)的定义进行呈现。浏览器会将 shadow DOM 树附加到节点,并将模板内容的 克隆 附加到该 shadow DOM。请注意,您可以 attachShadow() 的元素是有限的

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(starRating.cloneNode(true));

如果您查看开发者工具,您会注意到 <template> 中的 <form> 是每个自定义元素的 shadow root 的一部分。在开发者工具和浏览器中,每个自定义元素中都显示了 <template> 内容的克隆,但自定义元素本身的内容不会呈现到屏幕上。

DevTools screenshot showing the cloned template contents in each custom element.

<template> 示例中,我们将模板内容附加到文档正文,从而将内容添加到常规 DOM。在 customElements 定义中,我们使用了相同的 appendChild(),但克隆的模板内容已附加到封装的 shadow DOM。

注意到星星如何变回未设置样式的单选按钮了吗?由于是 shadow DOM 而不是标准 DOM 的一部分,因此 Codepen 的 CSS 选项卡中的样式不适用。该选项卡的 CSS 样式作用域限定为文档,而不是 shadow DOM,因此样式未应用。我们必须创建封装的样式来设置封装的 Shadow DOM 内容的样式。

Shadow DOM

Shadow DOM 将 CSS 样式作用域限定为每个 shadow 树,使其与文档的其余部分隔离。这意味着外部 CSS 不适用于您的组件,并且组件样式对文档的其余部分没有影响,除非我们有意将其定向到文档的其余部分。

由于我们已将内容附加到 shadow DOM,因此我们可以包含一个 <style> 元素,为自定义元素提供封装的 CSS。

由于作用域限定为自定义元素,因此我们不必担心样式会渗出到文档的其余部分。我们可以大大降低选择器的特异性。例如,由于自定义元素中使用的唯一输入是单选按钮,因此我们可以使用 input 而不是 input[type="radio"] 作为选择器。

 <template id="star-rating-template">
  <style>
    rating {
      display: inline-flex;
    }
    input {
      appearance: none;
      margin: 0;
      box-shadow: none;
    }
    input::after {
      content: '\2605'; /* solid star */
      font-size: 32px;
    }
    rating:hover input:invalid::after,
    rating:focus-within input:invalid::after {
      color: #888;
    }
    input:invalid::after,
      rating:hover input:hover ~ input:invalid::after,
      input:focus ~ input:invalid::after  {
      color: #ddd;
    }
    input:valid {
      color: orange;
    }
    input:checked ~ input:not(:checked)::after {
      color: #ccc;
      content: '\2606'; /* hollow star */
    }
  </style>
  <form>
    <fieldset>
      <slot name="star-rating-legend">
        <legend>Rate your experience:</legend>
      </slot>
      <rating>
        <input type="radio" name="rating" value="1" aria-label="1 star" required/>
        <input type="radio" name="rating" value="2" aria-label="2 stars"/>
        <input type="radio" name="rating" value="3" aria-label="3 stars"/>
        <input type="radio" name="rating" value="4" aria-label="4 stars"/>
        <input type="radio" name="rating" value="5" aria-label="5 stars"/>
      </rating>
    </fieldset>
    <button type="reset">Reset</button>
    <button type="submit">Submit</button>
  </form>
</template>

虽然 Web 组件使用内部 <template> 标记进行封装,并且 CSS 样式作用域限定为 shadow DOM 并对组件外部的所有内容隐藏,但呈现的 slot 内容(即 <star-rating><anyElement slot="star-rating-legend"> 部分)未封装。

当前作用域之外的样式设置

可以(但并不简单)从 shadow DOM 内设置文档样式,以及从全局样式设置 shadow DOM 内容的样式。shadow 边界(shadow DOM 在此边界处结束,而常规 DOM 在此边界处开始)可以被遍历,但只能非常有意识地进行遍历。

shadow 树是 shadow DOM 内的 DOM 树。shadow root 是 shadow 树的根节点。

:host 伪类选择 <star-rating>(shadow host 元素)。shadow host 是 shadow DOM 附加到的 DOM 节点。要仅定位主机的特定版本,请使用 :host()。这将仅选择与传递的参数(如类或属性选择器)匹配的 shadow host 元素。要选择所有自定义元素,您可以在全局 CSS 中使用 star-rating { /* styles */ },或者在模板样式中使用 :host(:not(#nonExistantId))。在 特异性方面,全局 CSS 获胜。

::slotted() 伪元素从 shadow DOM 内跨越 shadow DOM 边界。如果 slot 元素与选择器匹配,则会选择该元素。在我们的示例中,::slotted(legend) 与我们的三个图例匹配。

要从全局作用域中的 CSS 定位 shadow DOM,需要编辑模板。可以将 part 属性添加到要设置样式的任何元素。然后使用 ::part() 伪元素来匹配 shadow 树中与传递的参数匹配的元素。伪元素的锚点或原始元素是主机或自定义元素名称,在本例中为 star-rating。参数是 part 属性的值。

如果我们的模板标记像这样开始

<template id="star-rating-template">
  <form part="formPart">
    <fieldset part="fieldsetPart">

我们可以使用以下代码定位 <form><fieldset>

star-rating::part(formPart) { /* styles */ }
star-rating::part(fieldsetPart) { /* styles */ }

部件名称的作用类似于类:一个元素可以有多个以空格分隔的部件名称,并且多个元素可以具有相同的部件名称。

Google 提供了一个很棒的 创建自定义元素的核对清单。您可能还想了解 声明式 shadow DOM

检查您的理解情况

测试您对 template、slot 和 shadow 的知识。

默认情况下,来自 shadow DOM 外部的样式将设置内部元素的样式。

正确。
再试一次。
错误。
正确!

哪个答案是对 <template> 元素的正确描述?

用于在页面中显示任何内容的通用元素。
再试一次。
占位符元素。
再试一次。
用于声明 HTML 片段的元素,默认情况下不会呈现。
正确!