我們可以使用自己的類別,搭配其方法、屬性、事件等,來建立自訂的 HTML 元素。
定義自訂元素後,我們就可以像內建的 HTML 元素一樣使用它。
這很棒,因為 HTML 字典很豐富,但並非無限。沒有 <easy-tabs>
、<sliding-carousel>
、<beautiful-upload>
…只要想想我們可能需要的任何其他標籤。
我們可以用一個特殊類別來定義它們,然後像它們一直是 HTML 的一部分一樣使用它們。
自訂元素有兩種
- 自訂元素 – 「全新」元素,擴充抽象的
HTMLElement
類別。 - 自訂內建元素 – 擴充內建元素,例如自訂按鈕,基於
HTMLButtonElement
等。
我們將先介紹自訂元素,然後再介紹自訂內建元素。
要建立自訂元素,我們需要告訴瀏覽器一些關於它的細節:如何顯示它、在元素新增或從頁面移除時該做什麼等。
這可以透過建立一個具有特殊方法的類別來完成。這很容易,因為只有少數方法,而且它們都是可選的。
以下是完整清單的草圖
class MyElement extends HTMLElement {
constructor() {
super();
// element created
}
connectedCallback() {
// browser calls this method when the element is added to the document
// (can be called many times if an element is repeatedly added/removed)
}
disconnectedCallback() {
// browser calls this method when the element is removed from the document
// (can be called many times if an element is repeatedly added/removed)
}
static get observedAttributes() {
return [/* array of attribute names to monitor for changes */];
}
attributeChangedCallback(name, oldValue, newValue) {
// called when one of attributes listed above is modified
}
adoptedCallback() {
// called when the element is moved to a new document
// (happens in document.adoptNode, very rarely used)
}
// there can be other element methods and properties
}
在那之後,我們需要註冊元素
// let the browser know that <my-element> is served by our new class
customElements.define("my-element", MyElement);
現在對於任何具有標籤 <my-element>
的 HTML 元素,都會建立 MyElement
的執行個體,並呼叫上述方法。我們也可以在 JavaScript 中使用 document.createElement('my-element')
。
-
自訂元素名稱必須有連字號 -
,例如 my-element
和 super-button
是有效的名稱,但 myelement
不是。
這是為了確保內建和自訂 HTML 元素之間沒有名稱衝突。
範例:「time-formatted」
例如,HTML 中已經存在 <time>
元素,用於日期/時間。但它本身不會做任何格式化。
讓我們建立 <time-formatted>
元素,以漂亮的、有語言意識的格式顯示時間
<script>
class TimeFormatted extends HTMLElement { // (1)
connectedCallback() {
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
}
customElements.define("time-formatted", TimeFormatted); // (2)
</script>
<!-- (3) -->
<time-formatted datetime="2019-12-01"
year="numeric" month="long" day="numeric"
hour="numeric" minute="numeric" second="numeric"
time-zone-name="short"
></time-formatted>
- 這個類別只有一個方法
connectedCallback()
– 當<time-formatted>
元素新增到頁面(或 HTML 解析器偵測到它)時,瀏覽器會呼叫它,並使用內建的 Intl.DateTimeFormat 資料格式化器,在瀏覽器中獲得良好的支援,以顯示格式良好的時間。 - 我們需要透過
customElements.define(tag, class)
註冊我們的新元素。 - 然後我們就可以在任何地方使用它了。
如果瀏覽器在 customElements.define
之前遇到任何 <time-formatted>
元素,這並非錯誤。但元素尚未得知,就像任何非標準標籤一樣。
此類「未定義」元素可以用 CSS 選擇器 :not(:defined)
設定樣式。
當呼叫 customElement.define
時,它們會「升級」:為每個元素建立一個新的 TimeFormatted
執行個體,並呼叫 connectedCallback
。它們會變成 :defined
。
若要取得自訂元素的資訊,可以使用下列方法
customElements.get(name)
– 傳回具有指定name
的自訂元素的類別,customElements.whenDefined(name)
– 傳回一個 Promise,當具有指定name
的自訂元素定義完成時,會解析(無值)。
connectedCallback
中呈現,而非 constructor
在上述範例中,元素內容會在 connectedCallback
中呈現(建立)。
為何不在 constructor
中呈現?
原因很簡單:當呼叫 constructor
時,還為時過早。元素已建立,但瀏覽器在此階段尚未處理/指定屬性:呼叫 getAttribute
會傳回 null
。因此我們無法在那裡呈現。
此外,如果你仔細想想,這樣在效能方面會更好 – 將工作延遲到真正需要時再進行。
當元素新增至文件時,會觸發 connectedCallback
。不僅僅是附加到另一個元素作為子元素,而是實際成為頁面的一部分。因此,我們可以建立分離的 DOM,建立元素並準備它們以供稍後使用。它們只會在進入頁面時實際呈現。
觀察屬性
在 <time-formatted>
的目前實作中,元素呈現後,進一步的屬性變更不會有任何效果。這對 HTML 元素來說很奇怪。通常,當我們變更屬性(例如 a.href
)時,我們預期變更會立即顯示。因此,我們來修正這個問題。
我們可以透過在 observedAttributes()
靜態 getter 中提供屬性清單來觀察屬性。對於此類屬性,當它們被修改時,會呼叫 attributeChangedCallback
。它不會觸發其他未列出的屬性(這是為了效能考量)。
以下是一個新的 <time-formatted>
,它會在屬性變更時自動更新
<script>
class TimeFormatted extends HTMLElement {
render() { // (1)
let date = new Date(this.getAttribute('datetime') || Date.now());
this.innerHTML = new Intl.DateTimeFormat("default", {
year: this.getAttribute('year') || undefined,
month: this.getAttribute('month') || undefined,
day: this.getAttribute('day') || undefined,
hour: this.getAttribute('hour') || undefined,
minute: this.getAttribute('minute') || undefined,
second: this.getAttribute('second') || undefined,
timeZoneName: this.getAttribute('time-zone-name') || undefined,
}).format(date);
}
connectedCallback() { // (2)
if (!this.rendered) {
this.render();
this.rendered = true;
}
}
static get observedAttributes() { // (3)
return ['datetime', 'year', 'month', 'day', 'hour', 'minute', 'second', 'time-zone-name'];
}
attributeChangedCallback(name, oldValue, newValue) { // (4)
this.render();
}
}
customElements.define("time-formatted", TimeFormatted);
</script>
<time-formatted id="elem" hour="numeric" minute="numeric" second="numeric"></time-formatted>
<script>
setInterval(() => elem.setAttribute('datetime', new Date()), 1000); // (5)
</script>
- 呈現邏輯已移至
render()
輔助方法。 - 當元素插入頁面時,我們呼叫它一次。
- 對於在
observedAttributes()
中列出的屬性變更,會觸發attributeChangedCallback
。 - …並重新渲染元素。
- 最後,我們可以輕鬆地製作一個即時計時器。
渲染順序
當 HTML 解析器建立 DOM 時,元素會一個接著一個處理,父元素在子元素之前。例如,如果我們有 <outer><inner></inner></outer>
,則會先建立 <outer>
元素並將其連接到 DOM,然後再建立 <inner>
。
這會對自訂元素造成重要的影響。
例如,如果自訂元素嘗試在 connectedCallback
中存取 innerHTML
,則會一無所獲
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
alert(this.innerHTML); // empty (*)
}
});
</script>
<user-info>John</user-info>
如果你執行它,alert
會是空的。
這正是因為在那個階段沒有子元素,DOM 尚未完成。HTML 解析器已連接自訂元素 <user-info>
,並將繼續處理其子元素,但尚未執行。
如果我們想要將資訊傳遞給自訂元素,可以使用屬性。它們會立即可用。
或者,如果我們真的需要子元素,可以使用零延遲 setTimeout
延後存取它們。
這會起作用
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
setTimeout(() => alert(this.innerHTML)); // John (*)
}
});
</script>
<user-info>John</user-info>
現在,第 (*)
行的 alert
會顯示「John」,因為我們在 HTML 解析完成後非同步執行它。我們可以在需要時處理子元素並完成初始化。
另一方面,這個解決方案也不是完美的。如果巢狀自訂元素也使用 setTimeout
來初始化自己,則它們會排隊:外層的 setTimeout
會先觸發,然後才是內層的。
因此,外層元素會在內層元素之前完成初始化。
讓我們示範一下
<script>
customElements.define('user-info', class extends HTMLElement {
connectedCallback() {
alert(`${this.id} connected.`);
setTimeout(() => alert(`${this.id} initialized.`));
}
});
</script>
<user-info id="outer">
<user-info id="inner"></user-info>
</user-info>
輸出順序
- outer connected。
- inner connected。
- outer initialized。
- inner initialized。
我們可以清楚地看到外層元素在內層元素 (4)
之前完成初始化 (3)
。
沒有內建的回呼會在巢狀元素準備好後觸發。如果需要,我們可以自行實作這種機制。例如,內層元素可以發送 initialized
等事件,而外層元素可以偵聽並對其做出反應。
自訂內建元素
我們建立的新元素,例如 <time-formatted>
,沒有任何關聯的語意。搜尋引擎不知道它們,輔助裝置也無法處理它們。
但這些事情可能很重要。例如,搜尋引擎會想知道我們實際上顯示的是時間。如果我們製作一種特殊類型的按鈕,為什麼不重複使用現有的 <button>
功能呢?
我們可以透過繼承內建 HTML 元素的類別來擴充和自訂它們。
例如,按鈕是 HTMLButtonElement
的執行個體,讓我們以此為基礎。
-
使用我們的類別擴充
HTMLButtonElement
class HelloButton extends HTMLButtonElement { /* custom element methods */ }
-
提供第三個參數給
customElements.define
,用於指定標籤customElements.define('hello-button', HelloButton, {extends: 'button'});
可能有多個標籤共用同一個 DOM 類別,因此需要指定
extends
。 -
最後,要使用自訂元素,請插入一個一般的
<button>
標籤,但加上is="hello-button"
<button is="hello-button">...</button>
以下是完整的範例
<script>
// The button that says "hello" on click
class HelloButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', () => alert("Hello!"));
}
}
customElements.define('hello-button', HelloButton, {extends: 'button'});
</script>
<button is="hello-button">Click me</button>
<button is="hello-button" disabled>Disabled</button>
我們的新按鈕會延伸內建的按鈕。因此它會保留相同的樣式和標準功能,例如 disabled
屬性。
參考資料
- HTML Living Standard:https://html.spec.whatwg.org/#custom-elements。
- 相容性:https://caniuse.dev.org.tw/#feat=custom-elementsv1。
摘要
自訂元素可以分為兩種
-
「自主」-新的標籤,延伸
HTMLElement
。定義架構
class MyElement extends HTMLElement { constructor() { super(); /* ... */ } connectedCallback() { /* ... */ } disconnectedCallback() { /* ... */ } static get observedAttributes() { return [/* ... */]; } attributeChangedCallback(name, oldValue, newValue) { /* ... */ } adoptedCallback() { /* ... */ } } customElements.define('my-element', MyElement); /* <my-element> */
-
「自訂內建元素」-現有元素的延伸。
需要多一個
.define
參數,以及 HTML 中的is="..."
class MyButton extends HTMLButtonElement { /*...*/ } customElements.define('my-button', MyElement, {extends: 'button'}); /* <button is="my-button"> */
自訂元素在瀏覽器中獲得良好的支援。有一個多重填充 https://github.com/webcomponents/polyfills/tree/master/packages/webcomponentsjs。
留言
<code>
標籤,對於多行程式碼 - 請將它們包裝在<pre>
標籤中,對於超過 10 行的程式碼 - 請使用沙盒 (plnkr、jsbin、codepen…)