模板、槽和阴影

Web 组件的优势在于其可重用性:您可以创建一次界面 widget,然后多次重复使用。虽然您确实需要 JavaScript 来创建 Web 组件,但您不需要 JavaScript 库。HTML 和相关 API 提供了您所需的一切。

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

在本部分中,我们将创建 <star-rating> 元素,这是一个 Web 组件,可让用户以 1 到 5 星的等级对体验进行评分。为自定义元素命名时,建议全部使用小写字母。请添加短划线,因为这有助于区分常规元素和自定义元素。

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

<template> 元素

<template> 元素用于声明要克隆并使用 JavaScript 插入到 DOM 中的 HTML 片段。默认情况下,系统不会呈现该元素的内容。而是使用 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 的一部分。

DOM 中显示的 CodePen。

如果只是为了实现一个星级评分模板,那么要求使用 JavaScript 并没有太大用处,但如果为了创建一个可重复使用、可自定义的星级评分 widget,那么创建 Web 组件就很有用。

<slot> 元素

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

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

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

如果元素具有 slot 属性,且其值与具名槽的名称匹配,则 name 属性用于将槽分配给其他元素。如果自定义元素没有与槽匹配的内容,则会呈现 <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> 元素没有用户代理应用的样式或语义。

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

我们来定义一个元素,将这个无法识别的元素转换为自定义元素。

自定义元素

需要使用 JavaScript 来定义自定义元素。定义后,<star-rating> 元素的内容会被一个影子根替换,该影子根包含我们与之关联的模板的所有内容。模板中的 <slot> 元素会被 <star-rating> 中 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。

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

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

DevTools 屏幕截图,显示每个自定义元素中克隆的模板内容。

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

您是否注意到,星星又变回了未设置样式的单选按钮?由于它是 shadow DOM(而非标准 DOM)的一部分,因此 CodePen 的 CSS 标签页中的样式不适用。该标签页的 CSS 样式的作用域是文档,而不是 Shadow DOM,因此系统不会应用这些样式。我们必须创建封装样式,才能为封装的 Shadow DOM 内容设置样式。

Shadow DOM

Shadow DOM 将 CSS 样式限定在每个 shadow tree 中,使其与文档的其余部分隔离开来。这意味着外部 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,并对组件外部的所有内容隐藏,但呈现的槽内容(<star-rating> 的 <anyElement slot="star-rating-legend"> 部分)并未封装。

当前范围之外的样式

虽然可以从 shadow DOM 内部设置文档样式,以及从全局样式设置 shadow DOM 内容的样式,但并不简单。影子边界(即影子 DOM 结束和常规 DOM 开始的位置)可以遍历,但只能非常有意地遍历。

shadow 树是 shadow DOM 内的 DOM 树。影子根是影子树的根节点。

: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 边界。如果槽位元素与选择器匹配,它会选择该槽位元素。在我们的示例中,::slotted(legend) 与我们的三个图例匹配。

如需从全局范围内的 CSS 定位到 Shadow DOM,需要修改模板。part 属性可以添加到您要设置样式的任何元素中。然后,使用 ::part() 伪元素来匹配影子树中与所传递参数匹配的元素。伪元素的锚点或源元素是宿主或自定义元素名称,在本例中为 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 */ }

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

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

检查掌握情况

测试您对 template、slot 和 shadow 的知识掌握情况。

默认情况下,Shadow DOM 外部的样式会影响内部元素的样式。

正确。
请重试。
错误。
正确!

以下哪项回答正确描述了 <template> 元素?

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