2021 年 3 月 26 日

自訂元素

我們可以使用自己的類別,搭配其方法、屬性、事件等,來建立自訂的 HTML 元素。

定義自訂元素後,我們就可以像內建的 HTML 元素一樣使用它。

這很棒,因為 HTML 字典很豐富,但並非無限。沒有 <easy-tabs><sliding-carousel><beautiful-upload>…只要想想我們可能需要的任何其他標籤。

我們可以用一個特殊類別來定義它們,然後像它們一直是 HTML 的一部分一樣使用它們。

自訂元素有兩種

  1. 自訂元素 – 「全新」元素,擴充抽象的 HTMLElement 類別。
  2. 自訂內建元素 – 擴充內建元素,例如自訂按鈕,基於 HTMLButtonElement 等。

我們將先介紹自訂元素,然後再介紹自訂內建元素。

要建立自訂元素,我們需要告訴瀏覽器一些關於它的細節:如何顯示它、在元素新增或從頁面移除時該做什麼等。

這可以透過建立一個具有特殊方法的類別來完成。這很容易,因為只有少數方法,而且它們都是可選的。

以下是完整清單的草圖

class MyElement extends HTMLElement {
  constructor() {
    super();
    // element created
  }

  connectedCallback() {
    // browser calls this method when the element is added to the document
    // (can be called many times if an element is repeatedly added/removed)
  }

  disconnectedCallback() {
    // browser calls this method when the element is removed from the document
    // (can be called many times if an element is repeatedly added/removed)
  }

  static get observedAttributes() {
    return [/* array of attribute names to monitor for changes */];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // called when one of attributes listed above is modified
  }

  adoptedCallback() {
    // called when the element is moved to a new document
    // (happens in document.adoptNode, very rarely used)
  }

  // there can be other element methods and properties
}

在那之後,我們需要註冊元素

// let the browser know that <my-element> is served by our new class
customElements.define("my-element", MyElement);

現在對於任何具有標籤 <my-element> 的 HTML 元素,都會建立 MyElement 的執行個體,並呼叫上述方法。我們也可以在 JavaScript 中使用 document.createElement('my-element')

自訂元素名稱必須包含連字號 -

自訂元素名稱必須有連字號 -,例如 my-elementsuper-button 是有效的名稱,但 myelement 不是。

這是為了確保內建和自訂 HTML 元素之間沒有名稱衝突。

範例:「time-formatted」

例如,HTML 中已經存在 <time> 元素,用於日期/時間。但它本身不會做任何格式化。

讓我們建立 <time-formatted> 元素,以漂亮的、有語言意識的格式顯示時間

<script>
class TimeFormatted extends HTMLElement { // (1)

  connectedCallback() {
    let date = new Date(this.getAttribute('datetime') || Date.now());

    this.innerHTML = new Intl.DateTimeFormat("default", {
      year: this.getAttribute('year') || undefined,
      month: this.getAttribute('month') || undefined,
      day: this.getAttribute('day') || undefined,
      hour: this.getAttribute('hour') || undefined,
      minute: this.getAttribute('minute') || undefined,
      second: this.getAttribute('second') || undefined,
      timeZoneName: this.getAttribute('time-zone-name') || undefined,
    }).format(date);
  }

}

customElements.define("time-formatted", TimeFormatted); // (2)
</script>

<!-- (3) -->
<time-formatted datetime="2019-12-01"
  year="numeric" month="long" day="numeric"
  hour="numeric" minute="numeric" second="numeric"
  time-zone-name="short"
></time-formatted>
  1. 這個類別只有一個方法 connectedCallback() – 當 <time-formatted> 元素新增到頁面(或 HTML 解析器偵測到它)時,瀏覽器會呼叫它,並使用內建的 Intl.DateTimeFormat 資料格式化器,在瀏覽器中獲得良好的支援,以顯示格式良好的時間。
  2. 我們需要透過 customElements.define(tag, class) 註冊我們的新元素。
  3. 然後我們就可以在任何地方使用它了。
自訂元素升級

如果瀏覽器在 customElements.define 之前遇到任何 <time-formatted> 元素,這並非錯誤。但元素尚未得知,就像任何非標準標籤一樣。

此類「未定義」元素可以用 CSS 選擇器 :not(:defined) 設定樣式。

當呼叫 customElement.define 時,它們會「升級」:為每個元素建立一個新的 TimeFormatted 執行個體,並呼叫 connectedCallback。它們會變成 :defined

若要取得自訂元素的資訊,可以使用下列方法

  • customElements.get(name) – 傳回具有指定 name 的自訂元素的類別,
  • customElements.whenDefined(name) – 傳回一個 Promise,當具有指定 name 的自訂元素定義完成時,會解析(無值)。
connectedCallback 中呈現,而非 constructor

在上述範例中,元素內容會在 connectedCallback 中呈現(建立)。

為何不在 constructor 中呈現?

原因很簡單:當呼叫 constructor 時,還為時過早。元素已建立,但瀏覽器在此階段尚未處理/指定屬性:呼叫 getAttribute 會傳回 null。因此我們無法在那裡呈現。

此外,如果你仔細想想,這樣在效能方面會更好 – 將工作延遲到真正需要時再進行。

當元素新增至文件時,會觸發 connectedCallback。不僅僅是附加到另一個元素作為子元素,而是實際成為頁面的一部分。因此,我們可以建立分離的 DOM,建立元素並準備它們以供稍後使用。它們只會在進入頁面時實際呈現。

觀察屬性

<time-formatted> 的目前實作中,元素呈現後,進一步的屬性變更不會有任何效果。這對 HTML 元素來說很奇怪。通常,當我們變更屬性(例如 a.href)時,我們預期變更會立即顯示。因此,我們來修正這個問題。

我們可以透過在 observedAttributes() 靜態 getter 中提供屬性清單來觀察屬性。對於此類屬性,當它們被修改時,會呼叫 attributeChangedCallback。它不會觸發其他未列出的屬性(這是為了效能考量)。

以下是一個新的 <time-formatted>,它會在屬性變更時自動更新

<script>
class TimeFormatted extends HTMLElement {

  render() { // (1)
    let date = new Date(this.getAttribute('datetime') || Date.now());

    this.innerHTML = new Intl.DateTimeFormat("default", {
      year: this.getAttribute('year') || undefined,
      month: this.getAttribute('month') || undefined,
      day: this.getAttribute('day') || undefined,
      hour: this.getAttribute('hour') || undefined,
      minute: this.getAttribute('minute') || undefined,
      second: this.getAttribute('second') || undefined,
      timeZoneName: this.getAttribute('time-zone-name') || undefined,
    }).format(date);
  }

  connectedCallback() { // (2)
    if (!this.rendered) {
      this.render();
      this.rendered = true;
    }
  }

  static get observedAttributes() { // (3)
    return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name'];
  }

  attributeChangedCallback(name, oldValue, newValue) { // (4)
    this.render();
  }

}

customElements.define("time-formatted", TimeFormatted);
</script>

<time-formatted id="elem" hour="numeric" minute="numeric" second="numeric"></time-formatted>

<script>
setInterval(() => elem.setAttribute('datetime', new Date()), 1000); // (5)
</script>
  1. 呈現邏輯已移至 render() 輔助方法。
  2. 當元素插入頁面時,我們呼叫它一次。
  3. 對於在 observedAttributes() 中列出的屬性變更,會觸發 attributeChangedCallback
  4. …並重新渲染元素。
  5. 最後,我們可以輕鬆地製作一個即時計時器。

渲染順序

當 HTML 解析器建立 DOM 時,元素會一個接著一個處理,父元素在子元素之前。例如,如果我們有 <outer><inner></inner></outer>,則會先建立 <outer> 元素並將其連接到 DOM,然後再建立 <inner>

這會對自訂元素造成重要的影響。

例如,如果自訂元素嘗試在 connectedCallback 中存取 innerHTML,則會一無所獲

<script>
customElements.define('user-info', class extends HTMLElement {

  connectedCallback() {
    alert(this.innerHTML); // empty (*)
  }

});
</script>

<user-info>John</user-info>

如果你執行它,alert 會是空的。

這正是因為在那個階段沒有子元素,DOM 尚未完成。HTML 解析器已連接自訂元素 <user-info>,並將繼續處理其子元素,但尚未執行。

如果我們想要將資訊傳遞給自訂元素,可以使用屬性。它們會立即可用。

或者,如果我們真的需要子元素,可以使用零延遲 setTimeout 延後存取它們。

這會起作用

<script>
customElements.define('user-info', class extends HTMLElement {

  connectedCallback() {
    setTimeout(() => alert(this.innerHTML)); // John (*)
  }

});
</script>

<user-info>John</user-info>

現在,第 (*) 行的 alert 會顯示「John」,因為我們在 HTML 解析完成後非同步執行它。我們可以在需要時處理子元素並完成初始化。

另一方面,這個解決方案也不是完美的。如果巢狀自訂元素也使用 setTimeout 來初始化自己,則它們會排隊:外層的 setTimeout 會先觸發,然後才是內層的。

因此,外層元素會在內層元素之前完成初始化。

讓我們示範一下

<script>
customElements.define('user-info', class extends HTMLElement {
  connectedCallback() {
    alert(`${this.id} connected.`);
    setTimeout(() => alert(`${this.id} initialized.`));
  }
});
</script>

<user-info id="outer">
  <user-info id="inner"></user-info>
</user-info>

輸出順序

  1. outer connected。
  2. inner connected。
  3. outer initialized。
  4. inner initialized。

我們可以清楚地看到外層元素在內層元素 (4) 之前完成初始化 (3)

沒有內建的回呼會在巢狀元素準備好後觸發。如果需要,我們可以自行實作這種機制。例如,內層元素可以發送 initialized 等事件,而外層元素可以偵聽並對其做出反應。

自訂內建元素

我們建立的新元素,例如 <time-formatted>,沒有任何關聯的語意。搜尋引擎不知道它們,輔助裝置也無法處理它們。

但這些事情可能很重要。例如,搜尋引擎會想知道我們實際上顯示的是時間。如果我們製作一種特殊類型的按鈕,為什麼不重複使用現有的 <button> 功能呢?

我們可以透過繼承內建 HTML 元素的類別來擴充和自訂它們。

例如,按鈕是 HTMLButtonElement 的執行個體,讓我們以此為基礎。

  1. 使用我們的類別擴充 HTMLButtonElement

    class HelloButton extends HTMLButtonElement { /* custom element methods */ }
  2. 提供第三個參數給 customElements.define,用於指定標籤

    customElements.define('hello-button', HelloButton, {extends: 'button'});

    可能有多個標籤共用同一個 DOM 類別,因此需要指定 extends

  3. 最後,要使用自訂元素,請插入一個一般的 <button> 標籤,但加上 is="hello-button"

    <button is="hello-button">...</button>

以下是完整的範例

<script>
// The button that says "hello" on click
class HelloButton extends HTMLButtonElement {
  constructor() {
    super();
    this.addEventListener('click', () => alert("Hello!"));
  }
}

customElements.define('hello-button', HelloButton, {extends: 'button'});
</script>

<button is="hello-button">Click me</button>

<button is="hello-button" disabled>Disabled</button>

我們的新按鈕會延伸內建的按鈕。因此它會保留相同的樣式和標準功能,例如 disabled 屬性。

參考資料

摘要

自訂元素可以分為兩種

  1. 「自主」-新的標籤,延伸 HTMLElement

    定義架構

    class MyElement extends HTMLElement {
      constructor() { super(); /* ... */ }
      connectedCallback() { /* ... */ }
      disconnectedCallback() { /* ... */  }
      static get observedAttributes() { return [/* ... */]; }
      attributeChangedCallback(name, oldValue, newValue) { /* ... */ }
      adoptedCallback() { /* ... */ }
     }
    customElements.define('my-element', MyElement);
    /* <my-element> */
  2. 「自訂內建元素」-現有元素的延伸。

    需要多一個 .define 參數,以及 HTML 中的 is="..."

    class MyButton extends HTMLButtonElement { /*...*/ }
    customElements.define('my-button', MyElement, {extends: 'button'});
    /* <button is="my-button"> */

自訂元素在瀏覽器中獲得良好的支援。有一個多重填充 https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs

任務

我們已經有 <time-formatted> 元素來顯示格式良好的時間。

建立 <live-timer> 元素來顯示目前時間

  1. 它應該在內部使用 <time-formatted>,而不是重複其功能。
  2. 每秒滴答(更新)一次。
  3. 對於每個滴答,都應該產生一個名為 tick 的自訂事件,其中包含 event.detail 中的目前日期(請參閱章節 傳送自訂事件)。

用法

<live-timer id="elem"></live-timer>

<script>
  elem.addEventListener('tick', event => console.log(event.detail));
</script>

示範

為任務開啟沙盒。

請注意

  1. 當元素從文件中移除時,我們會清除 setInterval 計時器。這很重要,否則即使不再需要,它也會繼續滴答。而且瀏覽器無法清除此元素及其引用的記憶體。
  2. 我們可以將目前日期存取為 elem.date 屬性。所有類別方法和屬性自然都是元素方法和屬性。

在沙盒中開啟解決方案。

教學地圖

留言

在留言之前請先閱讀…
  • 如果您有改進建議 - 請 提交 GitHub 議題 或提交拉取請求,而不是留言。
  • 如果您無法理解文章中的某個部分 - 請詳細說明。
  • 若要插入少許程式碼,請使用 <code> 標籤,對於多行程式碼 - 請將它們包裝在 <pre> 標籤中,對於超過 10 行的程式碼 - 請使用沙盒 (plnkrjsbincodepen…)