使用自訂元素

Boris Smus
Boris Smus

簡介

網際網路嚴重缺乏表達方式。如要瞭解我的意思,請看看 GMail 這類「現代」網路應用程式:

Gmail

<div> 湯沒有什麼現代感,不過,這就是我們建構網頁應用程式的方式。很遺憾。 我們不應該要求平台提供更多功能嗎?

性感的標記。讓我們來做這件事

HTML 是用來建構文件的絕佳工具,但其詞彙僅限於 HTML 標準定義的元素。

如果 GMail 的標記不是糟透了,假設它很美麗:

<hangout-module>
    <hangout-chat from="Paul, Addy">
    <hangout-discussion>
        <hangout-message from="Paul" profile="profile.png"
            profile="118075919496626375791" datetime="2013-07-17T12:02">
        <p>Feelin' this Web Components thing.
        <p>Heard of it?
        </hangout-message>
    </hangout-discussion>
    </hangout-chat>
    <hangout-chat>...</hangout-chat>
</hangout-module>

真是令人耳目一新!這個應用程式也非常實用。這類程式碼不僅有意義易於理解,最重要的是可維護。未來的我/您只要檢查宣告式主軸,就能確切瞭解該函式的作用。

開始使用

自訂元素 可讓網頁開發人員定義新類型的 HTML 元素。這個規格是 Web Components 中幾個新 API 基本元素之一,但很可能是最重要的。如果沒有自訂元素解鎖的功能,網頁元件就不會存在:

  1. 定義新的 HTML/DOM 元素
  2. 建立可從其他元素擴充的元素
  3. 將自訂功能邏輯上整合為單一代碼
  4. 擴充現有 DOM 元素的 API

註冊新元素

使用 document.registerElement() 建立自訂元素:

var XFoo = document.registerElement('x-foo');
document.body.appendChild(new XFoo());

document.registerElement() 的第一個引數是元素的標記名稱。名稱必須包含破折號 (-)。舉例來說,<x-tags><my-element><my-awesome-app> 都是有效的名稱,但 <tabs><foo_bar> 則不是。這項限制可讓剖析器區分自訂元素和一般元素,同時確保在 HTML 中加入新標記時,能向前相容。

第二個引數是 (選用) 物件,可用來說明元素的 prototype。您可以在這個位置為元素新增自訂功能 (例如公開屬性和方法)。詳情請參閱後續內容

根據預設,自訂元素會繼承 HTMLElement。因此,上述範例等同於:

var XFoo = document.registerElement('x-foo', {
    prototype: Object.create(HTMLElement.prototype)
});

呼叫 document.registerElement('x-foo') 會向瀏覽器說明新元素,並傳回可用來建立 <x-foo> 例項的建構函式。如果您不想使用建構函式,也可以使用其他將元素例項化的技巧

擴充元素

您可以使用自訂元素擴充現有的 (原生) HTML 元素,以及其他自訂元素。如要擴充元素,您必須將要繼承的元素名稱和 prototype 傳遞至 registerElement()

擴充原生元素

假設您對 Regular Joe <button> 不滿意,您想將其功能強化為「超級按鈕」。如要擴充 <button> 元素,請建立新的元素,繼承 HTMLButtonElementprototype 和元素名稱 extends。在本例中,"button":

var MegaButton = document.registerElement('mega-button', {
    prototype: Object.create(HTMLButtonElement.prototype),
    extends: 'button'
});

從原生元素繼承的自訂元素稱為類型擴充功能自訂元素。這些類別會繼承 HTMLElement 的專屬版本,以便表示「元素 X 是 Y」。

範例:

<button is="mega-button">

擴充自訂元素

如要建立可擴充 <x-foo> 自訂元素的 <x-foo-extended> 元素,只要繼承其原型,並說明您要從哪個標記繼承即可:

var XFooProto = Object.create(HTMLElement.prototype);
...

var XFooExtended = document.registerElement('x-foo-extended', {
    prototype: XFooProto,
    extends: 'x-foo'
});

如要進一步瞭解如何建立元素原型,請參閱下方的「新增 JS 屬性和方法」。

元素升級方式

您是否曾經想過,為什麼 HTML 剖析器不會對非標準標記做出反應?舉例來說,如果我們在網頁上宣告 <randomtag>,系統會完全滿意。根據 HTML 規格

抱歉,<randomtag>!您是非標準類型,並繼承自 HTMLUnknownElement

但自訂元素則不然。含有有效自訂元素名稱的元素會繼承自 HTMLElement您可以啟動主控台:Ctrl + Shift + J (在 Mac 上為 Cmd + Opt + J),然後貼上以下程式碼行,這些程式碼會傳回 true

// "tabs" is not a valid custom element name
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype

// "x-tabs" is a valid custom element name
document.createElement('x-tabs').__proto__ == HTMLElement.prototype

未解析的元素

由於自訂元素是由使用 document.registerElement() 的程式碼註冊,因此可以在瀏覽器註冊定義之前宣告或建立這些元素。舉例來說,您可以在頁面上宣告 <x-tabs>,但最後在較晚的時間才呼叫 document.registerElement('x-tabs')

在元素升級至定義之前,稱為未解析元素。這些 HTML 元素具有有效的自訂元素名稱,但尚未註冊。

下表或許有助於釐清相關資訊:

名稱 沿用自 範例
未解析的元素 HTMLElement <x-tabs><my-element>
不明元素 HTMLUnknownElement <tabs><foo_bar>

例項化元素

建立元素的常用技巧仍適用於自訂元素。如同任何標準元素,您可以在 HTML 中宣告這些元素,也可以使用 JavaScript 在 DOM 中建立這些元素。

例項化自訂代碼

宣告

<x-foo></x-foo>

在 JS 中建立 DOM

var xFoo = document.createElement('x-foo');
xFoo.addEventListener('click', function(e) {
    alert('Thanks!');
});

使用 new 運算子

var xFoo = new XFoo();
document.body.appendChild(xFoo);

例項化類型擴充功能元素

類型擴充功能式自訂元素的例項化作業與自訂標記非常相似。

宣告

<!-- <button> "is a" mega button -->
<button is="mega-button">

在 JS 中建立 DOM

var megaButton = document.createElement('button', 'mega-button');
// megaButton instanceof MegaButton === true

如您所見,現在有一個超載的 document.createElement() 版本,會將 is="" 屬性做為第二個參數。

使用 new 運算子

var megaButton = new MegaButton();
document.body.appendChild(megaButton);

到目前為止,我們已瞭解如何使用 document.registerElement() 向瀏覽器說明新的標記,但這並沒有太大用處。我們來新增屬性和方法。

新增 JS 屬性和方法

自訂元素的強大之處在於,您可以在元素定義中定義屬性和方法,藉此將客製化功能與元素捆綁在一起。您可以將這項功能視為為元素建立公開 API 的方式。

以下是完整範例:

var XFooProto = Object.create(HTMLElement.prototype);

// 1. Give x-foo a foo() method.
XFooProto.foo = function() {
    alert('foo() called');
};

// 2. Define a property read-only "bar".
Object.defineProperty(XFooProto, "bar", {value: 5});

// 3. Register x-foo's definition.
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});

// 4. Instantiate an x-foo.
var xfoo = document.createElement('x-foo');

// 5. Add it to the page.
document.body.appendChild(xfoo);

當然,有無數種方法可以建構 prototype。如果您不喜歡建立這類原型,以下是同樣功能的較精簡版本:

var XFoo = document.registerElement('x-foo', {
  prototype: Object.create(HTMLElement.prototype, {
    bar: {
      get: function () {
        return 5;
      }
    },
    foo: {
      value: function () {
        alert('foo() called');
      }
    }
  })
});

第一種格式可使用 ES5 Object.defineProperty。第二個方法則允許使用 get/set

生命週期回呼方法

元素可以定義特殊方法,用於擷取有趣的時間點。這些方法適當地命名為「生命週期回呼」。每個類別都有特定名稱和用途:

回呼名稱 在下列情況下呼叫:
createdCallback 建立元素的例項
attachedCallback 已將執行個體插入文件中
detachedCallback 從文件中移除執行個體
attributeChangedCallback(attrName, oldVal, newVal) 新增、移除或更新屬性

範例:<x-foo> 上定義 createdCallback()attachedCallback()

var proto = Object.create(HTMLElement.prototype);

proto.createdCallback = function() {...};
proto.attachedCallback = function() {...};

var XFoo = document.registerElement('x-foo', {prototype: proto});

所有生命週期回呼都是選用的,但請在適當的時機定義這些回呼。舉例來說,假設您的元素相當複雜,並在 createdCallback() 中開啟至 IndexedDB 的連線。在從 DOM 移除之前,請在 detachedCallback() 中執行必要的清理工作。注意:您不應依賴這項功能,例如使用者關閉分頁時,但請將其視為可能的最佳化鉤子。

生命週期回呼的另一個用途,是用於在元素上設定預設事件監聽器:

proto.createdCallback = function() {
  this.addEventListener('click', function(e) {
    alert('Thanks!');
  });
};

新增標記

我們已建立 <x-foo>,並將其設為 JavaScript API,但它是空白的!我們要提供一些 HTML 來算繪嗎?

這時就需要用到生命週期回呼。特別是,我們可以使用 createdCallback() 為元素提供一些預設 HTML:

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    this.innerHTML = "**I'm an x-foo-with-markup!**";
};

var XFoo = document.registerElement('x-foo-with-markup', {prototype: XFooProto});

例項化這個標記並在開發人員工具中檢查 (按一下滑鼠右鍵,選取「檢查元素」) 後,應會顯示以下內容:

▾<x-foo-with-markup>
  **I'm an x-foo-with-markup!**
</x-foo-with-markup>

在 Shadow DOM 中封裝內部元素

Shadow DOM 本身就是用於封裝內容的強大工具。搭配自訂元素使用,效果更佳!

Shadow DOM 可為自訂元素提供:

  1. 一種隱藏內部結構的方式,可避免使用者看到繁雜的實作細節。
  2. 樣式封裝…免費。

透過 Shadow DOM 建立元素,就像是建立可轉譯基本標記的元素。差異在於 createdCallback()

var XFooProto = Object.create(HTMLElement.prototype);

XFooProto.createdCallback = function() {
    // 1. Attach a shadow root on the element.
    var shadow = this.createShadowRoot();

    // 2. Fill it with markup goodness.
    shadow.innerHTML = "**I'm in the element's Shadow DOM!**";
};

var XFoo = document.registerElement('x-foo-shadowdom', {prototype: XFooProto});

我沒有設定元素的 .innerHTML,而是為 <x-foo-shadowdom> 建立了陰影根目錄,然後使用標記填入。在開發人員工具中啟用「Show Shadow DOM」設定後,您會看到可展開的 #shadow-root

<x-foo-shadowdom>
  #shadow-root
    **I'm in the element's Shadow DOM!**
</x-foo-shadowdom>

那就是 Shadow Root!

使用範本建立元素

HTML 範本是另一種新的 API 基本元素,可完美融入自訂元素的世界。

範例:註冊由 <template> 和 Shadow DOM 建立的元素:

<template id="sdtemplate">
  <style>
    p { color: orange; }
  </style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<script>
  var proto = Object.create(HTMLElement.prototype, {
    createdCallback: {
      value: function() {
        var t = document.querySelector('#sdtemplate');
        var clone = document.importNode(t.content, true);
        this.createShadowRoot().appendChild(clone);
      }
    }
  });
  document.registerElement('x-foo-from-template', {prototype: proto});
</script>

<template id="sdtemplate">
  <style>:host p { color: orange; }</style>
  <p>I'm in Shadow DOM. My markup was stamped from a <template&gt;.
</template>

<div class="demoarea">
  <x-foo-from-template></x-foo-from-template>
</div>

這幾行程式碼的威力十足,讓我們瞭解發生的所有情況:

  1. 我們已在 HTML 中註冊新元素:<x-foo-from-template>
  2. 元素的 DOM 是從 <template> 建立
  3. 使用 Shadow DOM 隱藏元素的恐怖細節
  4. Shadow DOM 可提供元素樣式封裝 (例如 p {color: orange;} 不會將整個網頁轉為橘色)

太好了!

設定自訂元素的樣式

如同任何 HTML 標記,自訂標記的使用者可以使用選取器設定樣式:

<style>
  app-panel {
    display: flex;
  }
  [is="x-item"] {
    transition: opacity 400ms ease-in-out;
    opacity: 0.3;
    flex: 1;
    text-align: center;
    border-radius: 50%;
  }
  [is="x-item"]:hover {
    opacity: 1.0;
    background: rgb(255, 0, 255);
    color: white;
  }
  app-panel > [is="x-item"] {
    padding: 5px;
    list-style: none;
    margin: 0 7px;
  }
</style>

<app-panel>
    <li is="x-item">Do</li>
    <li is="x-item">Re</li>
    <li is="x-item">Mi</li>
</app-panel>

設定使用 Shadow DOM 的元素樣式

將 Shadow DOM 納入考量後,這個兔子洞會變得更深使用 Shadow DOM 的自訂元素可繼承其優點。

Shadow DOM 會為元素注入樣式封裝。在陰影根目錄中定義的樣式不會從主機外洩,也不會從網頁流入。如果是自訂元素,則元素本身就是主機。樣式封裝的屬性也允許自訂元素為自己定義預設樣式。

Shadow DOM 樣式是一個龐大的主題!如要進一步瞭解這項功能,建議您參閱以下幾篇文章:

使用 :unresolved 防範 FOUC

為減輕 FOUC 的影響,自訂元素會指定新的 CSS 虛擬類別 :unresolved。您可以使用它指定未解析的元素,直到瀏覽器叫用 createdCallback() 為止 (請參閱生命週期方法)。一旦發生這種情況,該元素就不再是未解析的元素。升級程序已完成,元素已轉換為其定義。

範例:在註冊「x-foo」標記時淡入:

<style>
  x-foo {
    opacity: 1;
    transition: opacity 300ms;
  }
  x-foo:unresolved {
    opacity: 0;
  }
</style>

請注意,:unresolved 僅適用於未解析的元素,不適用於繼承自 HTMLUnknownElement 的元素 (請參閱「元素的升級方式」)。

<style>
  /* apply a dashed border to all unresolved elements */
  :unresolved {
    border: 1px dashed red;
    display: inline-block;
  }
  /* x-panel's that are unresolved are red */
  x-panel:unresolved {
    color: red;
  }
  /* once the definition of x-panel is registered, it becomes green */
  x-panel {
    color: green;
    display: block;
    padding: 5px;
    display: block;
  }
</style>

<panel>
    I'm black because :unresolved doesn't apply to "panel".
    It's not a valid custom element name.
</panel>

<x-panel>I'm red because I match x-panel:unresolved.</x-panel>

歷史記錄和瀏覽器支援

特徵偵測

功能偵測功能會檢查 document.registerElement() 是否存在:

function supportsCustomElements() {
    return 'registerElement' in document;
}

if (supportsCustomElements()) {
    // Good to go!
} else {
    // Use other libraries to create components.
}

瀏覽器支援

document.registerElement() 最初是在 Chrome 27 和 Firefox 23 左右開始出現在標記後方。不過,自那時起,規格已大幅演進。Chrome 31 是第一個真正支援更新規格的版本。

在瀏覽器支援度提升之前,Google 的 Polymer 和 Mozilla 的 X-Tag 會使用 polyfill

HTMLElementElement 怎麼了?

對於已完成標準化工作的人來說,<element> 曾經存在過。這部影片實在太棒了!您可以使用它以宣告式方式註冊新元素:

<element name="my-element">
    ...
</element>

很遺憾,升級程序、特殊情況和末日般的情境中,有太多時間問題需要解決。<element> 必須擱置。2013 年 8 月,Dimitri Glazkov 在 public-webapps 發布公告,宣布至少目前已移除這項功能。

值得一提的是,Polymer 會使用 <polymer-element> 實作宣告式元素註冊作業。該怎麼做呢?它使用 document.registerElement('polymer-element') 和「從範本建立元素」一文中所述的技術。

結論

自訂元素可讓我們擴充 HTML 的字彙,教導它新的技巧,並穿越網站平台的蟲洞。將這些元素與其他新的平台基本元素 (例如 Shadow DOM 和 <template>) 結合,我們就能開始實現 Web 元件的圖像。標記又能讓人愛不釋手了!

如果您有興趣開始使用 Web 元件,建議您查看 Polymer。這項功能提供的資訊足以讓你順利完成工作。