自定義元素探祕及構建可複用元件最佳實踐

tristan發表於2019-03-01

原文請查閱這裡,略有刪減,本文采用知識共享署名 4.0 國際許可協議共享,BY Troland

這是 JavaScript 工作原理第十九章。

概述

前述文章中,我們介紹了 Shadow DOM 介面和一些其它概念,而這些都是網頁元件的組成部分。網頁元件背後的思想即通過建立顆粒化,模組化和可複用的元素來擴充套件 HTML 內建功能。這是一個已經被所有主流瀏覽器相容的相對嶄新的 W3C 標準且可以被用在生產環境之中,雖然不相容的瀏覽器需要使用墊片庫(將在隨後的章節中進行討論)。

正如開發者所知,瀏覽器為構建網站和網頁程式提供了一些重要的開發工具。我們所說的 HTML,CSS 和 JavaScript 即開發者使用 HTML 來構建結構,CSS 進行樣式化然後使用 JavaScript 來讓頁面動起來。然而,在網頁元件出現之前,把 JavaScript 指令碼和 HTML 結構組合起來並非易事。

本文將闡述網頁元件的基石-自定義元素。總之,開發者可以使用自定義元素介面來建立包含 JavaScript 邏輯和樣式的自定義元素(正如名稱的字面意思)。許多開發者會把自定義元素和 shadow DOM 混為一談。但是,他們是完全不同的概念且它們互補而不是可以相互替代的。

一些框架(比如 Angular,React) 試圖通過引進其自有概念來解決同樣的問題。開發者可以把自定義元素和 Angular 的指令或者 React 元件進行對比。然而,自定義元素是瀏覽器原生的且只需要原生 JavaScript,HTML 和 CSS。當然了,這並不意味著它可以取代一個典型的 JavaScript 框架。現代框架不僅僅為開發者提供模仿自定義元素行為的能力。因此,可以同時使用框架和自定義元素。

介面

在深入瞭解之前,讓我們先大概快速瀏覽一下介面的內容。全域性 customElements 物件為開發者提供了一些方法:

  • define(tagName, constructor, options) -建立一個新的自定義元素。

    包含三個引數:自定義元素的可用標籤名稱,自定義元素類定義及選項引數物件。目前僅支援一個選項引數:extends 指定想要擴充套件的 HTML 內建元素名稱的字串。用來建立定製化內建元素。

  • get(tagName) -若元素已經定義則返回自定義元素的建構函式否則返回 undefined。只有一個引數:自定義元素的可用標籤名稱。

  • whenDefined(tagName)-返回一個 promise 物件,當定義自定義元素即解析。若元素已定義則立即進行解析。若自定義元素標籤名稱不可用則摒棄 promise。只有一個引數:自定義元素的可用標籤名稱。

如何建立自定義元素

建立自定義元素實際上就是小菜一碟。開發者只需要做兩件事:建立擴充套件 HTMLElement 類元素的類定義,然後以合適的名稱註冊元素。

class MyCustomElement extends HTMLElement {
  constructor() {
    super();
    // …
  }

  // …
}

customElements.define('my-custom-element', MyCustomElement);
複製程式碼

或者如你所願,可以使用匿名類以防止弄亂當前作用域

customElements.define('my-custom-element', class extends HTMLElement {
  constructor() {
    super();
    // …
  }

  // …
});
複製程式碼

從以上例子可見,使用 customElements.define(...) 方法註冊自定義元素。

自定義元素所解決的問題

實際上,問題是啥?巢狀 DIV 是問題之一。巢狀 Div 是啥?在現代網頁程式中這是一個非常常見的現象,開發者會使用多個巢狀塊狀元素(div 互相巢狀之類)。

<div class="top-container">
  <div class="middle-container">
    <div class="inside-container">
      <div class="inside-inside-container">
        <div class="are-we-really-doing-this">
          <div class="mariana-trench">
            …
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
複製程式碼

因為瀏覽器可以在頁面上正常進行渲染,所以使用了這樣的巢狀結構。但是,這會使得 HTML 不具可讀性且難以維護。

因此,例如假設有如下元件:

自定義元素探祕及構建可複用元件最佳實踐

那麼傳統 HTML 結構類似如下:

<div class="primary-toolbar toolbar">
  <div class="toolbar">
    <div class="toolbar-button">
      <div class="toolbar-button-outer-box">
        <div class="toolbar-button-inner-box">
          <div class="icon">
            <div class="icon-undo">&nbsp;</div>
          </div>
        </div>
      </div>
    </div>
    <div class="toolbar-button">
      <div class="toolbar-button-outer-box">
        <div class="toolbar-button-inner-box">
          <div class="icon">
            <div class="icon-redo">&nbsp;</div>
          </div>
        </div>
      </div>
    </div>
    <div class="toolbar-button">
      <div class="toolbar-button-outer-box">
        <div class="toolbar-button-inner-box">
          <div class="icon">
            <div class="icon-print">&nbsp;</div>
          </div>
        </div>
      </div>
    </div>
    <div class="toolbar-toggle-button toolbar-button">
      <div class="toolbar-button-outer-box">
        <div class="toolbar-button-inner-box">
          <div class="icon">
            <div class="icon-paint-format">&nbsp;</div>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>
複製程式碼

但想象下如果可以使用類似如下程式碼:

<primary-toolbar>
  <toolbar-group>
    <toolbar-button class="icon-undo"></toolbar-button>
    <toolbar-button class="icon-redo"></toolbar-button>
    <toolbar-button class="icon-print"></toolbar-button>
    <toolbar-toggle-button class="icon-paint-format"></toolbar-toggle-button>
  </toolbar-group>
</primary-toolbar>
複製程式碼

要我說,第二個示例清爽多了。第二個示例更具可維護性,可讀性且對於瀏覽器和開發者更加合理。更加簡潔。

另一個問題即可複用性。作為開發者,不僅僅要書寫可執行的程式碼還得寫出可維護程式碼。書寫可維護程式碼即能夠輕易地複用程式碼片段而不是重複地複製貼上。

我將會給出一個簡單的示例而你就會明白。假設有如下元素:

<div class="my-custom-element">
  <input type="text" class="email" />
  <button class="submit"></button>
</div>
複製程式碼

若需要在其它地方使用這段程式碼,開發者需要再次書寫相同的 HTML 結構。現在,想象 一下需要稍微修改一下這些元素。開發者需要找出每個程式碼需要修改的地方,然後一遍遍地做出同樣的修改。太噁心了。。。

若使用如下碼豈不會更好?

<my-custom-element></my-custom-element>
複製程式碼

現代網頁程式不僅僅只有靜態 HTML。開發者需要做互動。這就需要 JavaScript。一般來說,開發者需要做的即建立一些元素然後在上面監聽事件以響應使用者輸入。點選,拖拽或者懸浮事件等等。

var myDiv = document.querySelector('.my-custom-element');

myDiv.addEventListener('click', () => {
  myDiv.innerHTML = '<b> I have been clicked </b>';
});
複製程式碼
<div class="my-custom-element">
  I have not been clicked yet.
</div>
複製程式碼

使用自定義元素介面可以把所有的邏輯封裝進元素自身。以下程式碼可以實現和上面程式碼一樣的功能:

class MyCustomElement extends HTMLElement {
  constructor() {
    super();

    var self = this;

    self.addEventListener('click', () => {
      self.innerHTML = '<b> I have been clicked </b>';
    });
  }
}

customElements.define('my-custom-element', MyCustomElement);
複製程式碼
<my-custom-element>
  I have not been clicked yet
</my-custom-element>
複製程式碼

咋一看上去,自定義元素技術需要書寫更多的 JavaScript 程式碼。但是在實際程式中,建立不需複用的單一元件的情況是很少見的。一個典型的現代網頁程式的重要特徵即大多數元素都是動態建立的。那麼,開發者就需要分別處理使用 JavaScript 動態新增元素或者使用 HTML 結構中預定義內容。那麼可以使用自定義元素來實現這些功能。

總之,自定義元素讓開發者的程式碼更易理解和維護,並分割為小型,可複用及可封裝的模組。

要求

在建立自定義元素之前,開發者需要遵守如下特殊規則:

  • 名稱必須包含一個破折號 - 。這樣 HTML 解析器就可以把自定義元素和內建元素區分開來。這樣可以保證不會和內建元素出現命名衝突的問題(不管是現在或者將來當新增其它元素的時候)。比如,<my-custom-element> 是正確的而 myCustomElement<my_custom_element> 則不然。
  • 不允許重複註冊標籤名稱。重複註冊標籤名稱會導致瀏覽器丟擲 DOMException 錯誤。不可以覆蓋已註冊自定義元素。
  • 自定義元素不可以自關閉。HTML 解析器只允許一小撮內建元素可以自關閉(比如 <img><link><br>)。

功能

那麼究竟自定義元素可以實現哪些功能?答案是很多。

最好用的功能之一即元素的類定義可以引用 DOM 元素自身。這意味著開發者可以直接使用 this 來直接監聽事件,訪問 DOM 屬性,訪問 DOM 元素子節點等等。

class MyCustomElement extends HTMLElement {
  // ...

  constructor() {
    super();

    this.addEventListener('mouseover', () => {
      console.log('I have been hovered');
    });
  }

  // ...
}
複製程式碼

當然,這樣開發者就可以使用新內容來覆蓋元素的子節點。但一般不推薦這樣做,因為這可能會導致意外的行為。作為自定義元素的使用者,因為不是使用者開發的,當元素裡面的標記被其它內容所取代,使用者會覺得很奇怪。

在元素生命週期的特定階段,開發者可以在一些生命週期鉤子中執行程式碼。

constructor

每當建立或者更新元素會觸發建構函式(隨後再詳細講解下)。一般情況會在該階段初始化狀態,監聽事件,建立 shadow DOM 等等。需要記住的是必須總是在建構函式中呼叫 super()

connectedCallback

每當在 DOM 中新增元素的時候會呼叫 connectedCallback 方法。可以用來(推薦)延遲執行某些程式碼直到元素完全渲染於頁面上時候呼叫(比如獲取一個資源)。

disconnectedCallback

connectedCallback相反,當元素被從 DOM 刪除時呼叫 disconnectedCallback 方法。一般用於釋放資源的時候呼叫。需要注意的是若使用者關閉選項卡不會呼叫 disconnectedCallback 方法。因此,首先開發者需要注意初始化程式碼。

attributeChangedCallback

每當新增,刪除,更新或者替換元素的某一屬性的時候呼叫。當解析器建立的時候也會呼叫。但是,請注意只有在 observedAttributes 屬性白名單中的屬性才會觸發。

addoptedCallback

當使用 document.adoptNode(...) 來把元素移動到另一個文件的時候會觸發 addoptedCallback方法。

請注意以上所有的回撥都是同步。例如,當把元素新增進 DOM 的時候只會觸發連線回撥。

屬性反射

內建 HTML 元素提供了一個非常方便的功能:屬性反射。這意味著直接修改某些屬性值會直接反射到 DOM 的屬性中。例如 id 屬性:

myDiv.id = 'new-id';

將會更新 DOM 為

<div id="new-id"> ... </div>

反之亦然。這是非常有用的因為這樣就使得開發者可以宣告式書寫元素。

自定義元素自身沒有該功能,但是有辦法可以實現。為了在自定義元素中實現該相同的功能,開發者需要定義屬性的 getters 和 setters 方法。

class MyCustomElement extends HTMLElement {
  // ...

  get myProperty() {
    return this.hasAttribute('my-property');
  }

  set myProperty(newValue) {
    if (newValue) {
      this.setAttribute('my-property', newValue);
    } else {
      this.removeAttribute('my-property');
    }
  }

  // ...
}
複製程式碼

擴充套件元素

開發者不僅僅可以使用自定義元素介面建立新的 HTML 元素還可以用來擴充套件現有的 HTML 元素。而且該介面在內建元素和其它自定義元素中工作得很好。僅僅只需要擴充套件元素的類定義即可。

class MyAwesomeButton extends MyButton {
  // ...
}

customElements.define('my-awesome-button', MyAwesomeButton);
複製程式碼

或者當擴充套件內建元素時,開發者需要為 customElements.define(...) 函式新增第三個 extends 的引數,引數值為需要擴充套件的元素標籤名稱。由於許多內建元素共享相同的 DOM 介面,extends 引數會告訴瀏覽器需要擴充套件的目標元素。若沒有指定需要擴充套件的元素,瀏覽器將不會知道需要擴充套件的功能類別 。

class MyButton extends HTMLButtonElement {
  // ...
}

customElements.define('my-button', MyButton, {extends: 'button'});
複製程式碼

一個可擴充套件原生元素也被稱為可定製化內建元素。

開發者需要記住的經驗法則即總是擴充套件存在的 HTML 元素。然後,一點點往裡新增功能。這樣就可以保留元素之前的功能(屬性,函式)。

請注意現在只有 Chrome 67+ 才支援定製化內建元素。以後,其它瀏覽器也會實現,但是 Safari 完全沒有實現該功能。

更新元素

如上所述,可以使用 customElements.define(...) 方法註冊自定義元素。但這並不意味著,開發者必須首先註冊元素。可以推遲在之後某個時間註冊自定義元素。甚至可以在往 DOM 中新增元素後再註冊元素也是可以的。這一過程稱為更新元素。開發者可以使用 customElements.whenDefined(...) 方法獲取元素的定義時間。開發者傳入元素標籤名,返回一個 promise 物件,然後當元素註冊的時候解析。

customElements.whenDefined('my-custom-element').then(_ => {
  console.log('My custom element is defined');
});
複製程式碼

例如,開發者也許想要延遲執行程式碼直到定義元素內所有子元素。若內嵌自定義元素,這將會非常有用。

有時候,父元素有可能會依賴於其子元素的實現。在這種情況下,開發者需要確保子元素在其父元素之前定義。

Shadow DOM

如前所述,需要把自定義元素和 shadow DOM 一起使用。前者用來把 JavaScript 邏輯封裝進元素而後者用來為一小段 DOM 建立一個不為外部影響的隔絕環境。建議檢視之前專門介紹 shadow DOM 的文章以便更好地理解 shadow DOM 概念。

只需呼叫 this.attachShadow 就可以在自定義元素內使用 shadow DOM

class MyCustomElement extends HTMLElement {
  // ...

  constructor() {
    super();

    let shadowRoot = this.attachShadow({mode: 'open'});
    let elementContent = document.createElement('div');
    shadowRoot.appendChild(elementContent);
  }

  // ...
});
複製程式碼

模板

我們在之前的文章中簡單介紹了下模板,需要單獨一篇文章來專門介紹模板。這裡,我們將會給出一個簡單的示例來介紹如何在自定義元素中使用模板。

通過宣告一個 DOM 片段來使用 <template>,該標籤內容只會被解析而不會在頁面上渲染。

<template id="my-custom-element-template">
  <div class="my-custom-element">
    <input type="text" class="email" />
    <button class="submit"></button>
  </div>
</template>
複製程式碼
let myCustomElementTemplate = document.querySelector('#my-custom-element-template');

class MyCustomElement extends HTMLElement {
  // ...

  constructor() {
    super();

    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.appendChild(myCustomElementTemplate.content.cloneNode(true));
  }

  // ...
});
複製程式碼

那麼現在,我們在自定義元素裡面使用了 shadow DOM 和 模板,建立了一個元素,該元素作用域和其它元素隔絕且把 HTML 結構和 JavaScript 邏輯完美地隔離開來。

樣式化

那麼,我們講解了 HTML 和 JavaScript,現在還剩下 CSS。顯然,需要樣式化元素。開發者可以在 shadow DOM 中新增樣式但是使用者如何從外部樣式化元素呢?答案很簡單-只需要和一般的內建元素一樣寫樣式即可。

my-custom-element {
  border-radius: 5px;
  width: 30%;
  height: 50%;
  // ...
}
複製程式碼

請注意外部定義的樣式比元素內部定義的樣式優先順序高,外部樣式會覆蓋掉元素內定義的樣式。

開發者需要明白有時候頁面渲染,然後會在某些時刻會發現無樣式內容閃爍(FOUC)。開發者可以通過為未定義元件定義樣式及當元素已定義的時候使用一些動畫過渡效果。使用 :defined 選擇器來達成這一效果。

my-button:not(:defined) {
  height: 20px;
  width: 50px;
  opacity: 0;
}
複製程式碼

未知元素對比未定義自定義元素

HTML 規範非常靈活且允許開發者任意宣告標籤。若不被瀏覽器解析則會解析為 HTMLUnknownElement

var element = document.createElement('thisElementIsUnknown');

if (element instanceof HTMLUnknownElement) {
  console.log('The selected element is unknown');
}
複製程式碼

但是這並不適用於自定義元素。還記得討論定義自定義元素時候的特殊命名規則嗎?原因是因為當瀏覽器發現一個自定義元素的名稱有效的時候,瀏覽器會把它解析為 HTMLElement ,然後瀏覽器會把它看作一個未定義的自定義元素。

var element = document.createElement('this-element-is-undefined');

if (element instanceof HTMLElement) {
  console.log('The selected element is undefined but not unknown');
}
複製程式碼

在視覺上, HTMLElement 和 HTMLUnknownElement 可能沒啥不同,但是需要注意其它地方。解析器會區別對待這兩種元素。具有有效自定義名稱的元素會被看作擁有自定義元素實現。在定義實現細節之前該自定義元素會被看成一個空 div 元素。而一個未定義元素沒有實現任何內建元素的任何方法或屬性。

瀏覽器相容

custom elements 第一版是在 Chrome 36+ 中引入的。被稱為自定義元素介面 v0,雖然現在仍然可用,但是已經被棄用並被認為是糟糕的實現。若想要學習 v0 版,可以閱讀這篇文章。從 Chrome 54 和 Safari 10.1(雖然只有部分支援) 開始支援自定義元素介面 v1,微軟 Edge 還處於其原型設計階段而 Mozilla 從 v50 開始支援,但預設不支援需要顯式啟用。目前只有 webkit 瀏覽器完全支援。然而,如上所述,可以使用墊片庫相容到包括 IE11 在內的所有瀏覽器。

檢測可用性

通過檢查 window 物件中的 customElements 屬性是否可用來檢查瀏覽器是否支援自定義元素。

const supportsCustomElements = 'customElements' in window;

if (supportsCustomElements) {
  // 可以使用自定義元素介面
}
複製程式碼

否則需要使用墊片庫:

function loadScript(src) {
  return new Promise(function(resolve, reject) {
    const script = document.createElement('script');

    script.src = src;
    script.onload = resolve;
    script.onerror = reject;

    document.head.appendChild(script);
  });
}

// Lazy load the polyfill if necessary.
if (supportsCustomElements) {
  // 瀏覽器原生支援自定義元素
} else {
  loadScript('path/to/custom-elements.min.js').then(_ => {
    // 載入自定義元素墊片
  });
}
複製程式碼

總之,網頁元件標準中的自定義元素為開發者提供瞭如下功能:

  • 把 JavaScript 和 CSS 樣式整合入 HTML 元素
  • 允許開發者擴充套件已有的 HTML 元素(內建和其它自定義元素)
  • 不需要其它庫或者框架的支援。只需要原生 JavaScript,HTML 和 CSS 還有可選的墊片庫來支援舊瀏覽器。
  • 可以和其它網頁元件功能無縫銜接(shadow DOM,模板,插槽等)。
  • 和瀏覽器開發者工具緊密整合在一起。
  • 使用已知的可訪問功能

總之,自定義元素和開發者已經使用過的元件技術並沒有什麼大的不同。它只讓開發網頁程式過程更加便攜的另一種方式。那麼,它讓更快地構建非常複雜的程式成為可能。

參考資料:

招賢納士

今日頭條招人啦!傳送簡歷到 likun.liyuk@bytedance.com ,即可走快速內推通道,長期有效!國際化PGC部門的JD如下:c.xiumi.us/board/v5/2H…,也可內推其他部門!

本系列持續更新中,Github 地址請查閱這裡

相關文章