Javascript Custom elements

2023-02-17 10:58 更新

我們可以通過描述帶有自己的方法、屬性和事件等的類來創(chuàng)建自定義 HTML 元素。

在 custom elements (自定義標簽)定義完成之后,我們可以將其和 HTML 的內(nèi)建標簽一同使用。

這是一件好事,因為雖然 HTML 有非常多的標簽,但仍然是有窮盡的。如果我們需要像 <easy-tabs>、<sliding-carousel>、<beautiful-upload>…… 這樣的標簽,內(nèi)建標簽并不能滿足我們。

我們可以把上述的標簽定義為特殊的類,然后使用它們,就好像它們本來就是 HTML 的一部分一樣。

Custom elements 有兩種:

  1. Autonomous custom elements (自主自定義標簽) —— “全新的” 元素, 繼承自 ?HTMLElement? 抽象類.
  2. Customized built-in elements (自定義內(nèi)建元素) —— 繼承內(nèi)建的 HTML 元素,比如自定義 ?HTMLButtonElement? 等。

我們將會先創(chuàng)建 autonomous 元素,然后再創(chuàng)建 customized built-in 元素。

在創(chuàng)建 custom elements 的時候,我們需要告訴瀏覽器一些細節(jié),包括:如何展示它,以及在添加元素到頁面和將其從頁面移除的時候需要做什么,等等。

通過創(chuàng)建一個帶有幾個特殊方法的類,我們可以完成這件事。這非常容易實現(xiàn),我們只需要添加幾個方法就行了,同時這些方法都不是必須的。

下面列出了這幾個方法的概述:

class MyElement extends HTMLElement {
  constructor() {
    super();
    // 元素在這里創(chuàng)建
  }

  connectedCallback() {
    // 在元素被添加到文檔之后,瀏覽器會調(diào)用這個方法
    //(如果一個元素被反復添加到文檔/移除文檔,那么這個方法會被多次調(diào)用)
  }

  disconnectedCallback() {
    // 在元素從文檔移除的時候,瀏覽器會調(diào)用這個方法
    // (如果一個元素被反復添加到文檔/移除文檔,那么這個方法會被多次調(diào)用)
  }

  static get observedAttributes() {
    return [/* 屬性數(shù)組,這些屬性的變化會被監(jiān)視 */];
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // 當上面數(shù)組中的屬性發(fā)生變化的時候,這個方法會被調(diào)用
  }

  adoptedCallback() {
    // 在元素被移動到新的文檔的時候,這個方法會被調(diào)用
    // (document.adoptNode 會用到, 非常少見)
  }

  // 還可以添加更多的元素方法和屬性
}

在申明了上面幾個方法之后,我們需要注冊元素:

// 讓瀏覽器知道我們新定義的類是為 <my-element> 服務的
customElements.define("my-element", MyElement);

現(xiàn)在當任何帶有 <my-element> 標簽的元素被創(chuàng)建的時候,一個 MyElement 的實例也會被創(chuàng)建,并且前面提到的方法也會被調(diào)用。我們同樣可以使用 document.createElement('my-element') 在 JavaScript 里創(chuàng)建元素。

Custom element 名稱必須包括一個短橫線 ?-?

Custom element 名稱必須包括一個短橫線 -, 比如 my-element 和 super-button 都是有效的元素名,但 myelement 并不是。

這是為了確保 custom element 和內(nèi)建 HTML 元素之間不會發(fā)生命名沖突。

例子: “time-formatted”

舉個例子,HTML 里面已經(jīng)有 <time> 元素了,用于顯示日期/時間。但是這個標簽本身并不會對時間進行任何格式化處理。

讓我們來創(chuàng)建一個可以展示適用于當前瀏覽器語言的時間格式的 <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>? 元素被添加到頁面的時候,瀏覽器會調(diào)用這個方法(或者當 HTML 解析器檢測到它的時候),它使用了內(nèi)建的時間格式化工具 Intl.DateTimeFormat,這個工具可以非常好地展示格式化之后的時間,在各瀏覽器中兼容性都非常好。
  2. 我們需要通過 ?customElements.define(tag, class)? 來注冊這個新元素。
  3. 接下來在任何地方我們都可以使用這個新元素了。

Custom elements 升級

如果瀏覽器在 customElements.define 之前的任何地方見到了 <time-formatted> 元素,并不會報錯。但會把這個元素當作未知元素,就像任何非標準標簽一樣。

:not(:defined) CSS 選擇器可以對這樣「未定義」的元素加上樣式。

當 customElement.define 被調(diào)用的時候,它們被「升級」了:一個新的 TimeFormatted 元素為每一個標簽創(chuàng)建了,并且 connectedCallback 被調(diào)用。它們變成了 :defined。

我們可以通過這些方法來獲取更多的自定義標簽的信息:

  • ?customElements.get(name)? —— 返回指定 custom element ?name? 的類。
  • ?customElements.whenDefined(name)? – 返回一個 promise,將會在這個具有給定 ?name? 的 custom element 變?yōu)橐讯x狀態(tài)的時候 resolve(不帶值)。

在 ?connectedCallback? 中渲染,而不是 ?constructor? 中

在上面的例子中,元素里面的內(nèi)容是在 connectedCallback 中渲染(創(chuàng)建)的。

為什么不在 constructor 中渲染?

原因很簡單:在 constructor 被調(diào)用的時候,還為時過早。雖然這個元素實例已經(jīng)被創(chuàng)建了,但還沒有插入頁面。在這個階段,瀏覽器還沒有處理/創(chuàng)建元素屬性:調(diào)用 getAttribute 將會得到 null。所以我們并不能在那里渲染元素。

而且,如果你仔細考慮,這樣作對于性能更好 —— 推遲渲染直到真正需要的時候。

在元素被添加到文檔的時候,它的 connectedCallback 方法會被調(diào)用。這個元素不僅僅是被添加為了另一個元素的子元素,同樣也成為了頁面的一部分。因此我們可以構建分離的 DOM,創(chuàng)建元素并且讓它們?yōu)橹蟮氖褂脺蕚浜?。它們只有在插入頁面的時候才會真的被渲染。

監(jiān)視屬性

我們目前的 <time-formatted> 實現(xiàn)中,在元素渲染以后,后續(xù)的屬性變化并不會帶來任何影響。這對于 HTML 元素來說有點奇怪。通常當我們改變一個屬性的時候,比如 a.href,我們會預期立即看到變化。我們將會在下面修正這一點。

為了監(jiān)視這些屬性,我們可以在 observedAttributes() static getter 中提供屬性列表。當這些屬性發(fā)生變化的時候,attributeChangedCallback 會被調(diào)用。出于性能優(yōu)化的考慮,其他屬性變化的時候并不會觸發(fā)這個回調(diào)方法。

以下是 <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. 這個方法在元素被插入到頁面的時候調(diào)用。
  3. ?attributeChangedCallback? 在 ?observedAttributes()? 里的屬性改變的時候被調(diào)用。
  4. …… 然后重渲染元素。
  5. 最終,一個計時器就這樣被我們輕松地實現(xiàn)了。

渲染順序

在 HTML 解析器構建 DOM 的時候,會按照先后順序處理元素,先處理父級元素再處理子元素。例如,如果我們有 <outer><inner></inner></outer>,那么 <outer> 元素會首先被創(chuàng)建并接入到 DOM,然后才是 <inner>

這對 custom elements 產(chǎn)生了重要影響。

比如,如果一個 custom element 想要在 connectedCallback 內(nèi)訪問 innerHTML,它什么也拿不到:

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

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

});
</script>

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

如果你運行上面的代碼,alert 出來的內(nèi)容是空的。

這正是因為在那個階段,子元素還不存在,DOM 還沒有完成構建。HTML 解析器先連接 custom element <user-info>,然后再處理子元素,但是那時候子元素還并沒有加載上。

如果我們要給 custom element 傳入信息,我們可以使用元素屬性。它們是即時生效的。

或者,如果我們需要子元素,我們可以使用延遲時間為零的 setTimeout 來推遲訪問子元素。

這樣是可行的:

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

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

});
</script>

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

現(xiàn)在 alert 在 (*) 行展示了 「John」,因為我們是在 HTML 解析完成之后,才異步執(zhí)行了這段程序。我們在這個時候處理必要的子元素并且結束初始化過程。

另一方面,這個方案并不是完美的。如果嵌套的 custom element 同樣使用了 setTimeout 來初始化自身,那么它們會按照先后順序執(zhí)行:外層的 setTimeout 首先觸發(fā),然后才是內(nèi)層的。

這樣外層元素還是早于內(nèi)層元素結束初始化。

讓我們用一個例子來說明:

<script>
customElements.define('user-info', class extends HTMLElement {
  connectedCallback() {
    alert(`${this.id} 已連接。`);
    setTimeout(() => alert(`${this.id} 初始化完成。`));
  }
});
</script>

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

輸出順序:

  1. outer 已連接。
  2. inner 已連接。
  3. outer 初始化完成。
  4. inner 初始化完成。

我們可以很明顯地看到外層元素并沒有等待內(nèi)層元素。

并沒有任何內(nèi)建的回調(diào)方法可以在嵌套元素渲染好之后通知我們。但我們可以自己實現(xiàn)這樣的回調(diào)。比如,內(nèi)層元素可以分派像 initialized 這樣的事件,同時外層的元素監(jiān)聽這樣的事件并做出響應。

Customized built-in elements

我們創(chuàng)建的 <time-formatted> 這些新元素,并沒有任何相關的語義。搜索引擎并不知曉它們的存在,同時無障礙設備也無法處理它們。

但上述兩點同樣是非常重要的。比如,搜索引擎會對這些事情感興趣,比如我們真的展示了時間?;蛘呷绻覀儎?chuàng)建了一個特別的按鈕,為什么不復用已有的 <button> 功能呢?

我們可以通過繼承內(nèi)建元素的類來擴展和定制它們。

比如,按鈕是 HTMLButtonElement 的實例,讓我們在這個基礎上創(chuàng)建元素。

  1. 我們的類繼承自 HTMLButtonElement
  2. class HelloButton extends HTMLButtonElement { /* custom element 方法 */ }
    
  3. 給 customElements.define 提供定義標簽的第三個參數(shù):
  4. customElements.define('hello-button', HelloButton, {extends: 'button'});
    

    這一步是必要的,因為不同的標簽會共享同一個類。

  5. 最后,插入一個普通的 <button> 標簽,但添加 is="hello-button" 到這個元素,這樣就可以使用我們的 custom element:
  6. <button is="hello-button">...</button>
    

下面是一個完整的例子:

<script>
// 這個按鈕在被點擊的時候說 "hello"
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>

我們新定義的按鈕繼承了內(nèi)建按鈕,所以它擁有和內(nèi)建按鈕相同的樣式和標準特性,比如 disabled 屬性。

引用參考

總結

有兩種 custom element:

  1. “Autonomous” —— 全新的標簽,繼承 ?HTMLElement?。
  2. 定義方式:

    class MyElement extends HTMLElement {
      constructor() { super(); /* ... */ }
      connectedCallback() { /* ... */ }
      disconnectedCallback() { /* ... */  }
      static get observedAttributes() { return [/* ... */]; }
      attributeChangedCallback(name, oldValue, newValue) { /* ... */ }
      adoptedCallback() { /* ... */ }
     }
    customElements.define('my-element', MyElement);
    /* <my-element> */
  3. “Customized built-in elements” —— 已有元素的擴展。
  4. 需要多一個 .define 參數(shù),同時 is="..." 在 HTML 中:

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

Custom element 在各瀏覽器中的兼容性已經(jīng)非常好了。Edge 支持地相對較差,但是我們可以使用 polyfill https://github.com/webcomponents/webcomponentsjs

任務


計時器元素實例

我們已經(jīng)創(chuàng)建了 <time-formatted> 元素用于展示格式化好的時間。

創(chuàng)建一個 <live-timer> 元素用于展示當前時間:

  1. 這個元素應該在內(nèi)部使用 ?<time-formatted>?,不要重復實現(xiàn)這個元素的功能。
  2. 每秒鐘更新。
  3. 每一秒鐘都應該有一個自定義事件 ?tick? 被生成,這個事件的 ?event.detail? 屬性帶有當前日期。(參考章節(jié) 創(chuàng)建自定義事件 )。

使用方式:

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

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

打開一個任務沙箱。


解決方案

請注意:

  1. 在元素被從文檔移除的時候,我們會清除 ?setInterval? 的 timer。這非常重要,否則即使我們不再需要它了,它仍然會繼續(xù)計時。這樣瀏覽器就不能清除這個元素占用和被這個元素引用的內(nèi)存了。
  2. 我們可以通過 ?elem.date? 屬性得到當前時間。類所有的方法和屬性天生就是元素的方法和屬性。

使用沙箱打開解決方案。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號