使用自定義元素

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"];
  }
  // …
}