本文翻譯自 Custom Elements: defining new elements in HTML,在保證技術要點表達准確的前提下,行文風格有少量改編和瞎搞。
本文目錄
注意!這篇文章介紹的 API 尚未完全標准化,並且仍在變動中,在項目中使用這些實驗性 API 時請務必謹慎。
引言
現在的 web 嚴重缺乏表達能力。你只要瞄一眼“現代”的 web 應用,比如 GMail,就會明白我的意思。
看看這一坨 DIV,這也叫現代?然而可悲的是,這就是我們構建 web 應用的方式。難道 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 組件根本玩不轉:
- 定義新的 HTML/DOM 元素
- 基於其他元素創建擴展元素
- 給一個標簽綁定一組自定義功能
- 擴展已有 DOM 元素的 API
注冊新元素
使用 document.register()
可以創建一個自定義元素
var XFoo = document.register('x-foo'); document.body.appendChild(new XFoo());
document.register()
的第一個參數是標簽名,這個標簽名必須包括一個連字符(-)。因此,諸如 <x-tags>
、<my-element>
、 <my-awesome-app>
都是合法的標簽名,而 <tabs>
和 <foo_bar>
則不是。這個限定使解析器能很容易的區分自定義元素和 HTML 規范定義的元素,同時確保了 HTML 增加新標簽時的向前兼容。
第二個參數是一個可選(譯注:經測試,Chrome 29 中不能省略第二個參數)的對象,用於描述該元素的原型。在這里可以為元素添加自定義功能(公開屬性和方法)。這個到 添加 JS 屬性和方法 一節再細說。
自定義元素默認會繼承 HTMLElement
的原型,因此上一個示例等同於:
var XFoo = document.register('x-foo', { prototype: Object.create(HTMLElement.prototype) });
調用 document.register('x-foo')
向瀏覽器注冊了這個新元素,並返回一個可以用來創建 <x-foo>
元素實例的構造器。如果你不想使用構造器,也可以使用其他實例化元素的技術。
提示:如果你不希望在 window
全局對象中創建元素構造器,還可以把它放進命名空間:
var myapp = {}; myapp.XFoo = document.register('x-foo');
擴展原生元素
假設原生 <button>
元素不能滿足你的需求,你想將其增強為一個“超級按鈕”,可以通過創建一個繼承 HTMLButtonElement
原型的新元素,來擴展 <button>
元素:
var MegaButton = document.register('mega-button', { prototype: Object.create(HTMLButtonElement.prototype) });
這類自定義元素被稱為類型擴展自定義元素。它們以繼承一個特定的 HTMLElement
的方式表達了“元素 X 是一個 Y”。
示例:
<button is="mega-button">
元素如何提升
你有沒有想過為什么 HTML 解析器不會對不是規范定義的標簽報錯?比如我們在頁面中聲明一個 <randomtag>
,一切都很和諧。根據 HTML 規范的表述,非規范定義的元素將使用 HTMLUnknownElement
接口。<randomtag>
不是規范定義的,它會繼承自 HTMLUnknownElement
。
對自定義元素來說,情況就不一樣了。擁有合法元素名的自定義元素將繼承 HTMLElement
。你可以打開控制台(不知道快捷鍵的都滾粗……),運行下面這段代碼,看看結果是不是 true
:
// “tabs”不是一個合法的自定義元素名 document.createElement('tabs').__proto__ === HTMLUnknownElement.prototype // “x-tabs”是一個合法的自定義元素名 document.createElement('x-tabs').__proto__ == HTMLElement.prototype
注意:在不支持 document.register()
的瀏覽器中,<x-tabs>
仍為 HTMLUnknownElement
。
unresolved(未提升)元素
由於自定義元素是由 JavaScript 代碼 document.register()
注冊的,因此它們可能在元素定義被注冊到瀏覽器之前就已經聲明或創建過了。比如你可以先在頁面中聲明 <x-tabs>
,再調用 document.register('x-tabs')
。
在被提升到其定義之前,這些元素被稱為“unresolved 元素”。它們是擁有合法自定義元素名的 HTML 元素,只是還沒有注冊成為自定義元素。
下面這個表格看起來更直觀一些:
類型 | 繼承自 | 示例 |
---|---|---|
unresolved 元素 | HTMLElement |
<x-tabs> 、<my-element> 、<my-awesome-app> |
未知元素 | 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> “是一個”超級按鈕 --> <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.register()
來向瀏覽器注冊一個新標簽。但這還不夠,接下來我們要向新標簽添加屬性和方法。
添加 JS 屬性和方法
自定義元素最強大的地方在於,你可以在元素定義中加入屬性和方法,給元素綁定特定的功能。你可以把它想象成一種給你的元素創建公開 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.register('x-foo', {prototype: XFooProto}); // 4. 創建一個 x-foo 實例. var xfoo = document.createElement('x-foo'); // 5. 插入頁面 document.body.appendChild(xfoo);
構造原型的方法多種多樣,如果你不喜歡上面這種方式,還有一個更簡潔的例子:
var XFoo = document.register('x-foo', { prototype: Object.create(HTMLElement.prototype, { bar: { get: function() { return 5; } }, foo: { value: function() { alert('foo() called'); } } }) });
以上兩種方式,第一種使用了 ES5 的 Object.defineProperty,第二種則使用了 get/set。
生命周期回調方法
元素可以定義特殊的方法,來注入其生存期內關鍵的時間點。這些方法各自有特定的名稱和用途,它們被恰如其分地命名為生命周期回調:
回調方法名稱 | 調用時間點 |
---|---|
createdCallback | 創建元素實例 |
enteredDocumentCallback | 向文檔插入實例 |
leftDocumentCallback | 從文檔中移除實例 |
attributeChangedCallback(attrName, oldVal, newVal) | 添加,移除,或修改一個屬性 |
示例:為 <x-foo>
定義 createdCallback()
和 enteredDocumentCallback()
var proto = Object.create(HTMLElement.prototype); proto.createdCallback = function() {...}; proto.enteredDocumentCallback = function() {...}; var XFoo = document.register('x-foo', {prototype: proto});
所有生命周期回調都是可選的,你可以只在需要關注的時間點定義它們。舉個例子,你有一個很復雜的元素,它會在 createdCallback()
打開一個 indexedDB 連接。在將其從 DOM 移除時,leftDocumentCallback()
會做一些必要的清理工作。注意:不要過於依賴這些生命周期方法(如果用戶直接關閉瀏覽器標簽,生命周期方法是沒有機會執行的),僅將其作為可能的優化點。
另一個生命周期回調的例子是為元素設置默認的事件監聽器:
proto.createdCallback = function() { this.addEventListener('click', function(e) { alert('Thanks!'); }); };
添加標記
我們已經創建好 <x-foo>
並添加了 JavaScript API,但它還沒有任何內容。要不我們給它整點?
生命周期回調在這個時候就派上用場了。我們甚至可以用 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.register('x-foo-with-markup', {prototype: XFooProto});
實例化這個標簽並在 DevTools 中觀察,可以看到如下結構:
用 Shadow DOM 封裝內部實現
Shadow DOM 是一個封裝內容的強大工具,配合使用自定義元素就更神奇了!
Shadow DOM 為自定義元素提供了:
- 一種隱藏內部實現的方法,從而將用戶與血淋淋的實現細節隔離開。
- 簡單有效的樣式隔離。
從 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 = "<b>I'm in the element's Shadow DOM!</b>"; };
var XFoo = document.register('x-foo-shadowdom', {prototype: XFooProto});
我們並沒有直接設置 <x-foo-shadowdom>
的 innerHTML
,而是為其創建了一個用於填充標記的 Shadow Root。在 DevTools 中選中“顯示 Shadow DOM”,你就會看到一個可以展開的 #document-fragment:
這就是 Shadow Root!
從模板創建元素
HTML Template 是另一組跟自定義元素完美融合的新 API。
模板元素可用於聲明 DOM 片段。它們可以被解析並在頁面加載后插入,以及延遲到運行時才進行實例化。模板是聲明自定義元素結構的理想方案。
示例:注冊一個由模板和 Shadow DOM 創建的元素:
<template id="sdtemplate"> <style> p { color: orange; } </style> <p>I'm in Shadow DOM. My markup was stamped from a &lt;template&gt;.</p> </template> <script> var proto = Object.create(HTMLElement.prototype, { createdCallback: { value: function() { var t = document.querySelector('#sdtemplate'); this.createShadowRoot().appendChild(t.content.cloneNode(true)); } } }); document.register('x-foo-from-template', {prototype: proto}); </script>
短短幾行做了很多事情,我們挨個來看都發生了些什么:
- 我們在 HTML 中注冊了一個新元素:
<x-foo-from-template>
- 這個元素的 DOM 是從一個模板創建的
- Shadow DOM 隱藏了該元素的實現細節
- 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 Root 中定義的樣式不會暴露到宿主外部或對頁面產生影響。對自定義元素來說,元素本身是宿主。樣式封裝的屬性也使得自定義元素能夠為自己定義默認樣式。
Shadow DOM 的樣式是一個很大的話題!如果你想更多地了解它,推薦你閱讀我寫的其他文章:
- Polymer 文檔:《元素樣式指南》。
- 發表於 html5rocks.com 的《Shadow DOM 201:CSS 和樣式》
使用 :unresolved 偽類避免無樣式內容閃爍(FOUC)
為了緩解無樣式內容閃爍的影響,自定義元素規范提出了一個新的 CSS 偽類 :unresolved
。在瀏覽器調用你的createdCallback()
(請看生命周期回調方法一節)之前,這個偽類都可以匹配到 unresolved 元素。一旦產生調用,就意味着元素已經完成提升,成為它被定義的形態,該元素就不再是一個 unresolved 元素。
Chrome 29 已經原生支持 :unresolved
偽類。
示例:注冊后漸顯的 <x-foo>
標簽:
x-foo { opacity: 1; transition: opacity 300ms; } x-foo:unresolved { opacity: 0; }
請記住 :unresolved
偽類只能用於 unresolved 元素,而不能用於繼承自 HTMLUnkownElement
的元素(請看元素如何提升一節)。
<style> /* 給所有未提升元素添加邊框 */ :unresolved { border: 1px dashed red; display: inline-block; } /* 未提升的 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.register()
接口:
function supportsCustomElements() { return 'register' in document; } if (supportsCustomElements()) { // 使用自定義元素 API } else { // 使用其他類庫創建組件 }
瀏覽器支持
Chrome 27 和 Firefox 23 都提供了對 document.register()
的支持,不過之后規范又有一些演化。Chrome 31 將是第一個支持新規范的版本。提示:在 Chrome 31 中使用自定義元素,需要開啟 about:flags 中的“實驗性 web 平台特性(Experimental Web Platform features)”選項。
在瀏覽器支持穩定之前,也有一些很好的過渡方案:
HTMLElementElement 怎么了?
緊跟過標准的人都知道曾經有一個 <element>
標簽。它非常好用,你只要像下面這樣就可以聲明式的注冊一個新元素:
<element name="my-element"> ... </element>
不幸的是,在它的提升過程、邊界案例,以及末日般的復雜場景中,需要處理大量的時序問題。<element>
因此被迫擱置。2013 年 8 月,Dimitri Glazkov 在 public-webapps 郵件組中宣告廢棄 <element>
,至少目前看來是廢掉了。
值得注意的是,Polymer 實現了用
document.register('polymer-element')
以及
從模板創建元素一節介紹的技術。
結語
自定義元素為我們提供了一個工具,通過它我們可以擴展 HTML 的詞匯,賦予它新的特性,並把不同的 web 平台連接在一起。結合其他新的基本平台,如 Shadow DOM 和模板,我們領略了 web 組件的宏偉藍圖。標記語言將再次變得很時髦!
如果你對使用 web 組件感興趣,建議你去看看 Polymer 框架,從它開始玩吧。