Javascript Custom elements

2023-02-17 10:58 更新

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

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

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

我們可以把上述的標(biāo)簽定義為特殊的類(lèi),然后使用它們,就好像它們本來(lái)就是 HTML 的一部分一樣。

Custom elements 有兩種:

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

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

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

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

下面列出了這幾個(gè)方法的概述:

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

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

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

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

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

  adoptedCallback() {
    // 在元素被移動(dòng)到新的文檔的時(shí)候,這個(gè)方法會(huì)被調(diào)用
    // (document.adoptNode 會(huì)用到, 非常少見(jiàn))
  }

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

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

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

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

Custom element 名稱(chēng)必須包括一個(gè)短橫線 ?-?

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

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

例子: “time-formatted”

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

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

Custom elements 升級(jí)

如果瀏覽器在 customElements.define 之前的任何地方見(jiàn)到了 <time-formatted> 元素,并不會(huì)報(bào)錯(cuò)。但會(huì)把這個(gè)元素當(dāng)作未知元素,就像任何非標(biāo)準(zhǔn)標(biāo)簽一樣。

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

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

我們可以通過(guò)這些方法來(lái)獲取更多的自定義標(biāo)簽的信息:

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

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

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

為什么不在 constructor 中渲染?

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

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

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

監(jiān)視屬性

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

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

以下是 <time-formatted> 的新版本,它會(huì)在屬性變化的時(shí)候自動(dòng)更新:

<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. 渲染邏輯被移動(dòng)到了 ?render()? 這個(gè)輔助方法里面。
  2. 這個(gè)方法在元素被插入到頁(yè)面的時(shí)候調(diào)用。
  3. ?attributeChangedCallback? 在 ?observedAttributes()? 里的屬性改變的時(shí)候被調(diào)用。
  4. …… 然后重渲染元素。
  5. 最終,一個(gè)計(jì)時(shí)器就這樣被我們輕松地實(shí)現(xiàn)了。

渲染順序

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

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

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

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

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

});
</script>

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

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

這正是因?yàn)樵谀莻€(gè)階段,子元素還不存在,DOM 還沒(méi)有完成構(gòu)建。HTML 解析器先連接 custom element <user-info>,然后再處理子元素,但是那時(shí)候子元素還并沒(méi)有加載上。

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

或者,如果我們需要子元素,我們可以使用延遲時(shí)間為零的 setTimeout 來(lái)推遲訪問(wèn)子元素。

這樣是可行的:

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

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

});
</script>

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

現(xiàn)在 alert 在 (*) 行展示了 「John」,因?yàn)槲覀兪窃?HTML 解析完成之后,才異步執(zhí)行了這段程序。我們?cè)谶@個(gè)時(shí)候處理必要的子元素并且結(jié)束初始化過(guò)程。

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

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

讓我們用一個(gè)例子來(lái)說(shuō)明:

<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 初始化完成。

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

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

Customized built-in elements

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

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

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

比如,按鈕是 HTMLButtonElement 的實(shí)例,讓我們?cè)谶@個(gè)基礎(chǔ)上創(chuàng)建元素。

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

    這一步是必要的,因?yàn)椴煌臉?biāo)簽會(huì)共享同一個(gè)類(lèi)。

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

下面是一個(gè)完整的例子:

<script>
// 這個(gè)按鈕在被點(diǎn)擊的時(shí)候說(shuō) "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)建按鈕相同的樣式和標(biāo)準(zhǔn)特性,比如 disabled 屬性。

引用參考

總結(jié)

有兩種 custom element:

  1. “Autonomous” —— 全新的標(biāo)簽,繼承 ?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” —— 已有元素的擴(kuò)展。
  4. 需要多一個(gè) .define 參數(shù),同時(shí) is="..." 在 HTML 中:

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

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

任務(wù)


計(jì)時(shí)器元素實(shí)例

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

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

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

使用方式:

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

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

打開(kāi)一個(gè)任務(wù)沙箱。


解決方案

請(qǐng)注意:

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

使用沙箱打開(kāi)解決方案。


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

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)