使用自定义元素

Web Components 的主要特性之一是能够创建自定义元素:即 HTML 元素,其行为由 Web 开发者定义,扩展了浏览器中可用元素的集合。

本文介绍了自定义元素,并提供了一些示例。

自定义元素的类型

自定义元素有两种类型

  • 自主自定义元素继承自 HTML 元素基类 HTMLElement。您必须从头开始实现它们的行为。

  • 定制内置元素继承自标准 HTML 元素,例如 HTMLImageElementHTMLParagraphElement。它们的实现扩展了标准元素选定实例的行为。

    注意:Safari 不打算支持定制内置元素。有关更多信息,请参阅 is 属性

对于两种自定义元素,创建和使用的基本步骤是相同的

实现自定义元素

自定义元素被实现为一个,它扩展了 HTMLElement(对于自主元素)或您想要定制的接口(对于定制内置元素)。这个类不会由您调用,而是由浏览器调用。定义类后,您应该立即注册自定义元素,这样您就可以使用标准 DOM 实践创建它的实例,例如在 HTML 标记中编写元素,调用 document.createElement() 等。

这是一个自定义 <p> 元素的最小实现

js
class WordCount extends HTMLParagraphElement {
  constructor() {
    super();
  }
  // Element functionality written in here
}

这是一个最小的自主自定义元素的实现

js
class PopupInfo extends HTMLElement {
  constructor() {
    super();
  }
  // Element functionality written in here
}

在类的构造函数中,您可以设置初始状态和默认值,注册事件监听器,并可能创建影子根。此时,您不应该检查元素的属性或子元素,也不应该添加新的属性或子元素。有关完整的要求集,请参阅自定义元素构造函数和响应的要求

自定义元素生命周期回调

一旦您的自定义元素被注册,当页面中的代码以某种方式与您的自定义元素交互时,浏览器将调用您类的某些方法。通过提供这些方法的实现(规范称之为生命周期回调),您可以响应这些事件运行代码。

自定义元素生命周期回调包括

  • connectedCallback():每次将元素添加到文档时调用。规范建议,开发者应尽可能在此回调而不是构造函数中实现自定义元素设置。
  • disconnectedCallback():每次将元素从文档中移除时调用。
  • connectedMoveCallback():定义时,每次通过 Element.moveBefore() 将元素移动到 DOM 中的不同位置时,此方法会替代 connectedCallback()disconnectedCallback() 被调用。使用此方法可以避免在元素未实际添加到或从 DOM 中移除时,在 connectedCallback()disconnectedCallback() 回调中运行初始化/清理代码。有关更多详细信息,请参阅生命周期回调和状态保留移动
  • adoptedCallback():每次将元素移动到新文档时调用。
  • attributeChangedCallback():当属性更改、添加、移除或替换时调用。有关此回调的更多详细信息,请参阅响应属性更改

这是一个记录这些生命周期事件的最小自定义元素

js
// Create a class for the element
class MyCustomElement extends HTMLElement {
  static observedAttributes = ["color", "size"];

  constructor() {
    // Always call super first in constructor
    super();
  }

  connectedCallback() {
    console.log("Custom element added to page.");
  }

  disconnectedCallback() {
    console.log("Custom element removed from page.");
  }

  connectedMoveCallback() {
    console.log("Custom element moved with moveBefore()");
  }

  adoptedCallback() {
    console.log("Custom element moved to new page.");
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log(`Attribute ${name} has changed.`);
  }
}

customElements.define("my-custom-element", MyCustomElement);

生命周期回调和状态保留移动

自定义元素在 DOM 中的位置可以像任何常规 HTML 元素一样被操作,但需要考虑生命周期副作用。

每次移动自定义元素(通过 Element.moveBefore()Node.insertBefore() 等方法)时,都会触发 disconnectedCallback()connectedCallback() 生命周期回调,因为元素会从 DOM 中断开连接并重新连接。

这可能是您预期的行为。然而,由于这些回调通常用于实现在元素生命周期开始或结束时运行的任何必需的初始化或清理代码,因此在移动元素(而不是移除或插入)时运行它们可能会导致其状态出现问题。例如,您可能会删除元素仍然需要的一些存储数据。

如果您想保留元素的状态,可以通过在元素类中定义 connectedMoveCallback() 生命周期回调,然后使用 Element.moveBefore() 方法移动元素(而不是 Node.insertBefore() 等类似方法)来实现。这将导致 connectedMoveCallback() 运行,而不是 connectedCallback()disconnectedCallback()

您可以添加一个空的 connectedMoveCallback() 来阻止其他两个回调运行,或者包含一些自定义逻辑来处理移动

js
class MyComponent {
  // ...
  connectedMoveCallback() {
    console.log("Custom move-handling logic here.");
  }
  // ...
}

注册自定义元素

要使自定义元素在页面中可用,请调用 Window.customElementsdefine() 方法。

define() 方法接受以下参数

name

元素的名称。这必须以小写字母开头,包含连字符,并满足规范有效名称定义中列出的某些其他规则。

constructor

自定义元素的构造函数。

options

仅适用于定制内置元素,这是一个包含单个属性 extends 的对象,该属性是一个字符串,命名要扩展的内置元素。

例如,此代码注册了 WordCount 定制内置元素

js
customElements.define("word-count", WordCount, { extends: "p" });

此代码注册了 PopupInfo 自主自定义元素

js
customElements.define("popup-info", PopupInfo);

使用自定义元素

定义并注册自定义元素后,您就可以在代码中使用它了。

要使用定制内置元素,请使用内置元素,但将自定义名称作为 is 属性的值

html
<p is="word-count"></p>

要使用自主自定义元素,只需像内置 HTML 元素一样使用自定义名称

html
<popup-info>
  <!-- content of the element -->
</popup-info>

响应属性更改

与内置元素一样,自定义元素可以使用 HTML 属性来配置元素的行为。为了有效地使用属性,元素必须能够响应属性值的变化。为此,自定义元素需要将以下成员添加到实现自定义元素的类中

  • 一个名为 observedAttributes 的静态属性。这必须是一个数组,包含元素需要更改通知的所有属性的名称。
  • attributeChangedCallback() 生命周期回调的实现。

然后,当元素的 observedAttributes 属性中列出的属性被添加、修改、删除或替换时,将调用 attributeChangedCallback() 回调。

回调传递三个参数

  • 已更改属性的名称。
  • 属性的旧值。
  • 属性的新值。

例如,这个自主元素将观察一个 size 属性,并在它们更改时记录旧值和新值

js
// Create a class for the element
class MyCustomElement extends HTMLElement {
  static observedAttributes = ["size"];

  constructor() {
    super();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    console.log(
      `Attribute ${name} has changed from ${oldValue} to ${newValue}.`,
    );
  }
}

customElements.define("my-custom-element", MyCustomElement);

请注意,如果元素的 HTML 声明包含一个被观察的属性,那么在属性初始化后,当元素声明首次解析时,将调用 attributeChangedCallback()。因此,在以下示例中,即使属性从未再次更改,也会在解析 DOM 时调用 attributeChangedCallback()

html
<my-custom-element size="100"></my-custom-element>

有关显示 attributeChangedCallback() 用法的完整示例,请参见本页面中的生命周期回调

自定义状态和自定义状态伪类 CSS 选择器

内置 HTML 元素可以具有不同的状态,例如“悬停”、“禁用”和“只读”。其中一些状态可以使用 HTML 或 JavaScript 作为属性设置,而其他状态是内部的,无法设置。无论是外部还是内部,这些状态通常都有相应的 CSS 伪类,可用于在特定状态下选择和样式化元素。

自主自定义元素(但不基于内置元素的元素)也允许您定义状态并使用 :state() 伪类函数对它们进行选择。下面的代码展示了如何使用具有内部状态 "collapsed" 的自主自定义元素为例来工作。

collapsed 状态表示为一个布尔属性(具有 setter 和 getter 方法),在元素外部不可见。为了使此状态在 CSS 中可选择,自定义元素首先在其构造函数中调用 HTMLElement.attachInternals() 以附加一个 ElementInternals 对象,该对象又通过 ElementInternals.states 属性提供对 CustomStateSet 的访问。当状态为 true 时,(内部)collapsed 状态的 setter 会将标识符 hidden 添加到 CustomStateSet,当状态为 false 时将其移除。标识符只是一个字符串:在这种情况下我们称之为 hidden,但我们也可以同样容易地称之为 collapsed

js
class MyCustomElement extends HTMLElement {
  constructor() {
    super();
    this._internals = this.attachInternals();
  }

  get collapsed() {
    return this._internals.states.has("hidden");
  }

  set collapsed(flag) {
    if (flag) {
      // Existence of identifier corresponds to "true"
      this._internals.states.add("hidden");
    } else {
      // Absence of identifier corresponds to "false"
      this._internals.states.delete("hidden");
    }
  }
}

// Register the custom element
customElements.define("my-custom-element", MyCustomElement);

我们可以使用添加到自定义元素的 CustomStateSet (this._internals.states) 的标识符来匹配元素的自定义状态。这通过将标识符传递给 :state() 伪类来匹配。例如,下面我们使用 :hidden 选择器选择 hidden 状态为 true(因此元素的 collapsed 状态),并移除边框。

css
my-custom-element {
  border: dashed red;
}
my-custom-element:state(hidden) {
  border: none;
}

:state() 伪类也可以在 :host() 伪类函数中使用,以匹配自定义元素影子 DOM 中的自定义状态。此外,:state() 伪类可以在 ::part() 伪元素之后使用,以匹配处于特定状态的自定义元素的影子部分

CustomStateSet 中有几个实时示例展示了这是如何工作的。

示例

在本指南的其余部分,我们将看一些自定义元素示例。您可以在 web-components-examples 存储库中找到所有这些示例的源代码以及更多内容,您可以在 https://mdn.github.io/web-components-examples/ 上查看它们的实时演示。

一个自主自定义元素

首先,我们将看一个自主自定义元素。<popup-info> 自定义元素将一个图像图标和一段文本字符串作为属性,并将图标嵌入页面。当图标获得焦点时,它会在弹出信息框中显示文本,以提供进一步的上下文信息。

首先,JavaScript 文件定义了一个名为 PopupInfo 的类,它扩展了 HTMLElement 类。

js
// Create a class for the element
class PopupInfo extends HTMLElement {
  constructor() {
    // Always call super first in constructor
    super();
  }

  connectedCallback() {
    // Create a shadow root
    const shadow = this.attachShadow({ mode: "open" });

    // Create spans
    const wrapper = document.createElement("span");
    wrapper.setAttribute("class", "wrapper");

    const icon = document.createElement("span");
    icon.setAttribute("class", "icon");
    icon.setAttribute("tabindex", 0);

    const info = document.createElement("span");
    info.setAttribute("class", "info");

    // Take attribute content and put it inside the info span
    const text = this.getAttribute("data-text");
    info.textContent = text;

    // Insert icon
    let imgUrl;
    if (this.hasAttribute("img")) {
      imgUrl = this.getAttribute("img");
    } else {
      imgUrl = "img/default.png";
    }

    const img = document.createElement("img");
    img.src = imgUrl;
    icon.appendChild(img);

    // Create some CSS to apply to the shadow dom
    const style = document.createElement("style");
    console.log(style.isConnected);

    style.textContent = `
      .wrapper {
        position: relative;
      }

      .info {
        font-size: 0.8rem;
        width: 200px;
        display: inline-block;
        border: 1px solid black;
        padding: 10px;
        background: white;
        border-radius: 10px;
        opacity: 0;
        transition: 0.6s all;
        position: absolute;
        bottom: 20px;
        left: 10px;
        z-index: 3;
      }

      img {
        width: 1.2rem;
      }

      .icon:hover + .info, .icon:focus + .info {
        opacity: 1;
      }
    `;

    // Attach the created elements to the shadow dom
    shadow.appendChild(style);
    console.log(style.isConnected);
    shadow.appendChild(wrapper);
    wrapper.appendChild(icon);
    wrapper.appendChild(info);
  }
}

类定义包含类的 constructor(),它总是通过调用 super() 来开始,以便建立正确的原型链。

connectedCallback() 方法中,我们定义了元素连接到 DOM 时将具有的所有功能。在这种情况下,我们将一个影子根附加到自定义元素,使用一些 DOM 操作来创建元素的内部影子 DOM 结构——然后将其附加到影子根——最后将一些 CSS 附加到影子根以对其进行样式设置。我们不在构造函数中执行此工作,因为元素的属性在连接到 DOM 之前不可用。

最后,我们使用前面提到的 define() 方法在 CustomElementRegistry 中注册我们的自定义元素——在参数中,我们指定元素名称,然后指定定义其功能的类名称

js
customElements.define("popup-info", PopupInfo);

现在它可以在我们的页面上使用。在我们的 HTML 中,我们像这样使用它

html
<popup-info
  img="img/alt.png"
  data-text="Your card validation code (CVC)
  is an extra security feature — it is the last 3 or 4 numbers on the
  back of your card."></popup-info>

引用外部样式

在上面的示例中,我们使用 <style> 元素将样式应用于影子 DOM,但您可以改为从 <link> 元素引用外部样式表。在此示例中,我们将修改 <popup-info> 自定义元素以使用外部样式表。

这是类定义

js
// Create a class for the element
class PopupInfo extends HTMLElement {
  constructor() {
    // Always call super first in constructor
    super();
  }

  connectedCallback() {
    // Create a shadow root
    const shadow = this.attachShadow({ mode: "open" });

    // Create spans
    const wrapper = document.createElement("span");
    wrapper.setAttribute("class", "wrapper");

    const icon = document.createElement("span");
    icon.setAttribute("class", "icon");
    icon.setAttribute("tabindex", 0);

    const info = document.createElement("span");
    info.setAttribute("class", "info");

    // Take attribute content and put it inside the info span
    const text = this.getAttribute("data-text");
    info.textContent = text;

    // Insert icon
    let imgUrl;
    if (this.hasAttribute("img")) {
      imgUrl = this.getAttribute("img");
    } else {
      imgUrl = "img/default.png";
    }

    const img = document.createElement("img");
    img.src = imgUrl;
    icon.appendChild(img);

    // Apply external styles to the shadow dom
    const linkElem = document.createElement("link");
    linkElem.setAttribute("rel", "stylesheet");
    linkElem.setAttribute("href", "style.css");

    // Attach the created elements to the shadow dom
    shadow.appendChild(linkElem);
    shadow.appendChild(wrapper);
    wrapper.appendChild(icon);
    wrapper.appendChild(info);
  }
}

它就像最初的 <popup-info> 示例一样,只是我们使用一个 <link> 元素链接到外部样式表,并将其添加到影子 DOM 中。

请注意,<link> 元素不会阻止影子根的渲染,因此在样式表加载时可能会出现无样式内容闪烁 (FOUC)。

许多现代浏览器对从共同节点克隆或具有相同文本的 <style> 标签实现了优化,允许它们共享一个单一的后端样式表。有了这种优化,外部和内部样式的性能应该相似。

定制内置元素

现在我们来看一个定制内置元素的例子。这个例子扩展了内置的 <ul> 元素,以支持展开和折叠列表项。

注意:请参阅 is 属性参考,了解定制内置元素实现现实中的注意事项。

首先,我们定义元素的类

js
// Create a class for the element
class ExpandingList extends HTMLUListElement {
  constructor() {
    // Always call super first in constructor
    // Return value from super() is a reference to this element
    self = super();
  }

  connectedCallback() {
    // Get ul and li elements that are a child of this custom ul element
    // li elements can be containers if they have uls within them
    const uls = Array.from(self.querySelectorAll("ul"));
    const lis = Array.from(self.querySelectorAll("li"));
    // Hide all child uls
    // These lists will be shown when the user clicks a higher level container
    uls.forEach((ul) => {
      ul.style.display = "none";
    });

    // Look through each li element in the ul
    lis.forEach((li) => {
      // If this li has a ul as a child, decorate it and add a click handler
      if (li.querySelectorAll("ul").length > 0) {
        // Add an attribute which can be used  by the style
        // to show an open or closed icon
        li.setAttribute("class", "closed");

        // Wrap the li element's text in a new span element
        // so we can assign style and event handlers to the span
        const childText = li.childNodes[0];
        const newSpan = document.createElement("span");

        // Copy text from li to span, set cursor style
        newSpan.textContent = childText.textContent;
        newSpan.style.cursor = "pointer";

        // Add click handler to this span
        newSpan.addEventListener("click", (e) => {
          // next sibling to the span should be the ul
          const nextUl = e.target.nextElementSibling;

          // Toggle visible state and update class attribute on ul
          if (nextUl.style.display === "block") {
            nextUl.style.display = "none";
            nextUl.parentNode.setAttribute("class", "closed");
          } else {
            nextUl.style.display = "block";
            nextUl.parentNode.setAttribute("class", "open");
          }
        });
        // Add the span and remove the bare text node from the li
        childText.parentNode.insertBefore(newSpan, childText);
        childText.parentNode.removeChild(childText);
      }
    });
  }
}

请注意,这次我们扩展了 HTMLUListElement,而不是 HTMLElement。这意味着我们获得了列表的默认行为,只需实现我们自己的自定义。

和以前一样,大部分代码都在 connectedCallback() 生命周期回调中。

接下来,我们像以前一样使用 define() 方法注册元素,但这次它还包含一个选项对象,详细说明了我们的自定义元素继承自哪个元素

js
customElements.define("expanding-list", ExpandingList, { extends: "ul" });

在 Web 文档中使用内置元素看起来也有所不同

html
<ul is="expanding-list">
  …
</ul>

您像平常一样使用 <ul> 元素,但在 is 属性中指定自定义元素的名称。

请注意,在这种情况下,我们必须确保定义自定义元素的脚本在 DOM 完全解析后执行,因为 connectedCallback() 会在可扩展列表添加到 DOM 后立即调用,而此时其子元素尚未添加,因此 querySelectorAll() 调用将找不到任何项目。确保这一点的一种方法是在包含脚本的行中添加 defer 属性

html
<script src="main.js" defer></script>

生命周期回调

到目前为止,我们只看到了一个生命周期回调:connectedCallback()。在最后一个示例 <custom-square> 中,我们将看到其他一些回调。<custom-square> 自主自定义元素绘制一个正方形,其大小和颜色由名为 "size""color" 的两个属性决定。

在类构造函数中,我们将一个影子 DOM 附加到元素,然后将空的 <div><style> 元素附加到影子根

js
class Square extends HTMLElement {
  // …
  constructor() {
    // Always call super first in constructor
    super();

    const shadow = this.attachShadow({ mode: "open" });

    const div = document.createElement("div");
    const style = document.createElement("style");
    shadow.appendChild(style);
    shadow.appendChild(div);
  }
  // …
}

这个示例中的关键函数是 updateStyle()——它接受一个元素,获取其影子根,找到其 <style> 元素,并向样式添加 widthheightbackground-color

js
function updateStyle(elem) {
  const shadow = elem.shadowRoot;
  shadow.querySelector("style").textContent = `
    div {
      width: ${elem.getAttribute("size")}px;
      height: ${elem.getAttribute("size")}px;
      background-color: ${elem.getAttribute("color")};
    }
  `;
}

实际的更新都由生命周期回调处理。connectedCallback() 在元素每次添加到 DOM 时运行——在这里我们运行 updateStyle() 函数以确保正方形的样式与属性中定义的样式一致

js
class Square extends HTMLElement {
  // …
  connectedCallback() {
    console.log("Custom square element added to page.");
    updateStyle(this);
  }
  // …
}

disconnectedCallback()adoptedCallback() 回调将消息记录到控制台,以告知我们元素何时从 DOM 中移除或移动到不同的页面

js
class Square extends HTMLElement {
  // …
  disconnectedCallback() {
    console.log("Custom square element removed from page.");
  }

  adoptedCallback() {
    console.log("Custom square element moved to new page.");
  }
  // …
}

每当元素的属性以某种方式更改时,都会运行 attributeChangedCallback() 回调。正如您从其参数中看到的那样,可以单独对属性进行操作,查看它们的名称、旧属性值和新属性值。但是,在这种情况下,我们只是再次运行 updateStyle() 函数,以确保正方形的样式根据新值进行更新

js
class Square extends HTMLElement {
  // …
  attributeChangedCallback(name, oldValue, newValue) {
    console.log("Custom square element attributes changed.");
    updateStyle(this);
  }
  // …
}

请注意,要使 attributeChangedCallback() 回调在属性更改时触发,您必须观察属性。这通过在自定义元素类中指定 static get observedAttributes() 方法来完成——该方法应返回一个包含您要观察的属性名称的数组

js
class Square extends HTMLElement {
  // …
  static get observedAttributes() {
    return ["color", "size"];
  }
  // …
}