使用自訂元素

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 元素,以及其他自訂元素。如要擴充元素,您必須傳遞該元素繼承的來源元素名稱和 prototyperegisterElement() 才能加以傳遞。

擴充原生元素

假設你對「常規喬<button>」不太滿意,您想要增強其功能成「Mega Button」,如要擴充 <button> 元素,請建立一個沿用 HTMLButtonElementprototypeextends 元素名稱的新元素。在本例中,「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!');
  });
};

新增標記

我們已針對 JavaScript API 建立 <x-foo>,但它空白!我們能否提供 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>

那是暗影根!

從範本建立元素

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 會為元素注入樣式封裝。在陰影根層級中定義的樣式不會從主機外流,也不會從網頁上流出。如果是自訂元素,則元素本身就是主機。樣式封裝的屬性也允許自訂元素為自己定義預設樣式。

陰影 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>

遺憾的是,升級程序、邊緣案例和類 Armageddon 類似情境有很多時間問題才能解決。<element>一定要避難。2013 年 8 月,Dimitri Glazkov 在 public-webapps 發布公告,宣布至少目前已移除這項功能。

值得一提的是,Polymer 會使用 <polymer-element> 實作宣告式元素註冊表單。該怎麼做呢?其中包含 document.registerElement('polymer-element')使用範本建立元素中所述的技巧。

結論

自訂元素可讓我們擴充 HTML 詞彙、教導新技巧,並快速體驗網路平台中的蟲子。將這些元素與其他新的平台基本元素 (例如 Shadow DOM 和 <template>) 結合,我們就能開始實現 Web 元件的圖像。標記再好不過!

如果想要開始使用網頁元件,建議您參考 Polymer。總值足以讓你繼續前進。