This article discusses APIs that are not yet fully standardized and still in flux. Be cautious when using experimental APIs in your own projects.
現在的 web 嚴重缺乏表達能力。你只要瞧一眼「現代」的 web 應用,比如 GMail,就會明白我的意思:
<div>
堆砌而成。
堆砌 <div>
一點都不現代。然而可悲的是,這就是我們構建 web 應用的方式。在現有基礎上我們不應該有更高的追求嗎?
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>
<p>Heard of it?</p>
</hangout-message>
</hangout-discussion>
</hangout-chat>
<hangout-chat>...</hangout-chat>
</hangout-module>
真是令人耳目一新!這個應用太合理了,既有意義,又容易理解。最妙的是,它是可維護的,只要查看聲明結構就可以清楚地知道它的作用。
自定義元素,救救我們!就指望你了!
自定義元素 允許開發者定義新的 HTML 元素類型。該規範只是 Web 組件模塊提供的眾多新 API 中的一個,但它也很可能是最重要的一個。沒有自定義元素帶來的以下特性,Web 組件都不會存在:
使用 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 規範定義的元素,同時確保了 HTML 增加新標籤時的向前兼容。
第二個參數是一個(可選的)對象,用於描述該元素的 prototype
。在這裡可以為元素添加自定義功能(例如:公開屬性和方法)。稍後詳述。
自定義元素默認繼承自 HTMLElement
,因此上一個示例等同於:
var XFoo = document.registerElement('x-foo', {
prototype: Object.create(HTMLElement.prototype)
});
調用 document.registerElement('x-foo')
向瀏覽器註冊了這個新元素,並返回一個可以用來創建 <x-foo>
元素實例的構造器。如果你不想使用構造器,也可以使用其他實例化元素的技術。
如果你不希望在 window
全局對象中創建元素構造器,還可以把它放進命名空間(var myapp = {}; myapp.XFoo = document.registerElement('x-foo');
)。
假設平淡無奇的原生 <button>
元素不能滿足你的需求,你想將其增強為一個「超級按鈕」,可以通過創建一個繼承 HTMLButtonElement.prototype
的新元素,來擴展 <button>
元素:
var MegaButton = document.registerElement('mega-button', {
prototype: Object.create(HTMLButtonElement.prototype)
});
要創建擴展自元素 B 的元素 A,元素 A 必須繼承元素 B 的 prototype
。
這類自定義元素被稱為類型擴展自定義元素。它們以繼承某個特定 HTMLElement
的方式表達了「元素 X 是一個 Y」。
示例:
<button is="mega-button">
你有沒有想過為什麼 HTML 解析器對非標準標籤不報錯?比如,我們在頁面中聲明一個 <randomtag>
,一切都很和諧。根據 HTML 規範的表述:
非規範定義的元素必須使用 HTMLUnknownElement
接口。
HTML 規範
<randomtag>
是非標準的,它會繼承 HTMLUnknownElement
。
對自定義元素來說,情況就不一樣了。擁有合法元素名的自定義元素將繼承 HTMLElement
。你可以按 Ctrl+Shift+J(Mac 系統為 Cmd+Opt+J)打開控制台,運行下面這段代碼,得到的結果將是 true
:
// 「tabs」不是一個合法的自定義元素名
document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype
// 「x-tabs」是一個合法的自定義元素名
document.createElement('x-tabs').__proto__ == HTMLElement.prototype
在不支持 document.registerElement()
的瀏覽器中,<x-tabs>
仍為 HTMLUnknownElement
。
由於自定義元素是通過腳本執行 document.registerElement()
註冊的,因此 它們可能在元素定義被註冊到瀏覽器之前就已經聲明或創建過了。例如:你可以先在頁面中聲明 <x-tabs>
,以後再調用 document.registerElement('x-tabs')
。
在被提升到其定義之前,這些元素被稱為 unresolved 元素。它們是擁有合法自定義元素名的 HTML 元素,只是還沒有註冊成為自定義元素。
下面這個表格看起來更直觀一些:
類型 | 繼承自 | 示例 |
---|---|---|
unresolved 元素 | HTMLElement | <x-tabs> 、<my-element> 、<my-awesome-app> |
未知元素 | HTMLUnknownElement | <tabs> 、<foo_bar>
|
把 unresolved 元素想像成尚處於中間狀態,它們都是等待被瀏覽器提升的潛在候選者。瀏覽器說:「你具備一個新元素的全部特徵,我保證會在賦予你定義的時候將你提升為一個元素」。
我們創建普通元素用到的一些技術也可以用於自定義元素。和所有標準定義的元素一樣,自定義元素既可以在 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> 「是一個」超級按鈕 -->
<button is="mega-button">
在 JS 中創建 DOM:
var megaButton = document.createElement('button', 'mega-button');
// megaButton instanceof MegaButton === true
看,這是接收第二個參數為 is=""
屬性的 document.createElement()
重載版本。
使用 new
操作符:
var megaButton = new MegaButton();
document.body.appendChild(megaButton);
現在,我們已經學習了如何使用 document.registerElement()
來向瀏覽器註冊一個新標籤。但這還不夠,接下來我們要向新標籤添加屬性和方法。
自定義元素最強大的地方在於,你可以在元素定義中加入屬性和方法,給元素綁定特定的功能。你可以把它想像成一種給你的元素創建公開 API 的方法。
這裡有一個完整的示例:
var XFooProto = Object.create(HTMLElement.prototype);
// 1. 為 x-foo 創建 foo() 方法
XFooProto.foo = function() {
alert('foo() called');
};
// 2. 定義一個只讀的「bar」屬性
Object.defineProperty(XFooProto, "bar", {value: 5});
// 3. 註冊 x-foo 的定義
var XFoo = document.registerElement('x-foo', {prototype: XFooProto});
// 4. 創建一個 x-foo 實例
var xfoo = document.createElement('x-foo');
// 5. 插入頁面
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 = "<b>I'm an x-foo-with-markup!</b>";
};
var XFoo = document.registerElement('x-foo-with-markup', {prototype: XFooProto});
實例化這個標籤並在 DevTools 中觀察(右擊,選擇「審查元素」),可以看到如下結構:
<x-foo-with-markup>
<b>I'm an x-foo-with-markup!</b>
</x-foo-with-markup>
Shadow DOM 本身是一個封裝內容的強大工具,配合使用自定義元素就更神奇了!
Shadow DOM 為自定義元素提供了:
從 Shadow DOM 創建元素,跟創建一個渲染基礎標記的元素非常類似,區別在於 createdCallback()
回調:
var XFooProto = Object.create(HTMLElement.prototype);
XFooProto.createdCallback = function() {
// 1. 為元素附加一個 shadow root。
var shadow = this.createShadowRoot();
// 2. 填入標記。
shadow.innerHTML = "<b>I'm in the element's Shadow DOM!</b>";
};
var XFoo = document.registerElement('x-foo-shadowdom', {prototype: XFooProto});
我們並沒有直接設置 <x-foo-shadowdom>
的 innerHTML
,而是為其創建了一個用於填充標記的 Shadow Root。在 DevTools 設置中勾選「Show Shadow DOM」,你就會看到一個可以展開的 #shadow-root
:
<x-foo-shadowdom>
?#shadow-root
<b>I'm in the element's Shadow DOM!</b>
</x-foo-shadowdom>
這就是 Shadow Root!
HTML 模板是另一組跟自定義元素完美融合的新 API。
<template> 元素可用於聲明 DOM 片段。它們可以被解析並在頁面加載後插入,以及延遲到運行時才進行實例化。模板是聲明自定義元素結構的理想方案。
示例:註冊一個由 <template>
和 Shadow DOM 創建的元素:
<template id="sdtemplate">
<style>
p { color: orange; }
</style>
<p>I'm in Shadow DOM. My markup was stamped from a <template>.</p>
</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>
短短幾行做了很多事情,我們挨個來看都發生了些什麼:
<x-foo-from-template>
<template>
創建的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 Root 中定義的樣式不會暴露到宿主外部或對頁面產生影響。對自定義元素來說,元素本身就是宿主。樣式封裝的屬性也使得自定義元素能夠為自己定義默認樣式。
Shadow DOM 的樣式是一個很大的話題!如果你想更多地瞭解它,推薦你閱讀我寫的其他文章:
為了緩解無樣式內容閃爍的影響,自定義元素規範提出了一個新的 CSS 偽類 :unresolved
。在瀏覽器調用你的 createdCallback()
(見生命週期回調方法一節)之前,這個偽類都可以匹配到 unresolved 元素。一旦產生調用,就意味著元素已經完成提升,成為它被定義的形態,該元素就不再是一個 unresolved 元素了。
Chrome 29 已經原生支持 CSS :unresolved
偽類。
示例:註冊後漸顯的 <x-foo>
標籤:
<style>
x-foo {
opacity: 1;
transition: opacity 300ms;
}
x-foo:unresolved {
opacity: 0;
}
</style>
請記住 :unresolved
偽類只能用於 unresolved 元素,而不能用於繼承自 HTMLUnkownElement
的元素(見元素如何提升一節)。
<style>
/* 給所有 unresolved 元素添加邊框 */
:unresolved {
border: 1px dashed red;
display: inline-block;
}
/* unresolved 元素 x-panel 的文本內容為紅色 */
x-panel:unresolved {
color: red;
}
/* 定義註冊後的 x-panel 文本內容為綠色 */
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>
瞭解更多 :unresolved
偽類的知識,請看 Polymer 文檔《元素樣式指南》。
特性檢測就是檢查 document.registerElement()
是否存在:
function supportsCustomElements() {
return 'registerElement' in document;
}
if (supportsCustomElements()) {
// Good to go!
} else {
// Use other libraries to create components.
}
Chrome 27 和 Firefox 23 都提供了對 document.registerElement()
的支持,不過之後規範又有一些演化。Chrome 31 將是第一個真正支持新規範的版本。
在 Chrome 31 中使用自定義元素,需要開啟 about:flags
中的「實驗性 web 平台特性(Experimental Web Platform features)」選項。
在瀏覽器支持穩定之前,也有一些很好的兼容方案:
一直關注標準的人都知道曾經有一個 <element>
標籤。它非常好用,你只要像下面這樣就可以聲明式地註冊一個新元素:
<element name="my-element">
...
</element>
然而很不幸,在它的提升過程、邊界案例,以及末日般的複雜場景中,需要處理大量的時序問題。<element>
因此被迫擱置。2013 年 8 月,Dimitri Glazkov 在 public-webapps 郵件組中宣告移除 <element>
。
值得注意的是,Polymer 實現了以 <polymer-element>
的形式聲明式地註冊元素。這是怎麼做到的?它用的正是 document.registerElement('polymer-element')
以及我在從模板創建元素一節介紹的技術。
自定義元素為我們提供了一個工具,通過它我們可以擴展 HTML 的詞彙,賦予它新的特性,並把不同的 web 平台連接在一起。結合其他新的基本平台,如 Shadow DOM 和 <template>
,我們領略了 web 組件的宏偉藍圖。標記語言將再次變得很時髦!
如果你對使用 web 組件感興趣,建議你看看 Polymer,就它已經夠你玩的了。
联系客服