自訂元素可讓網頁開發人員定義新的 HTML 標記、擴充現有標記,以及建立可重複使用的網頁元件。
透過自訂元素,網頁開發人員可以建立新的 HTML 標記、強化現有的 HTML 標記,或擴充其他開發人員編寫的元件。API 是網頁元件的基礎。它提供以網路標準為基礎的方法,只需使用一般 JS/HTML/CSS 即可建立可重複使用的元件。因此,應用程式中的程式碼會變少,程式碼會模組化,且可重複使用。
簡介
瀏覽器提供了一項絕佳的工具,可用於建構網頁應用程式。稱為 HTML你可能聽過這個詞!它是宣告式、可移植、支援良好且易於操作。很好,像 HTML 一樣 詞彙量和擴充性都有限一直以來,HTML 有效標準都缺乏自動將 JS 行為與標記建立關聯的方法。
自訂元素是讓 HTML 現代化的最佳解方,可填補缺少的部分,並將結構與行為整合在一起。如果 HTML 無法解決問題,我們可以建立可解決問題的自訂元素。自訂元素可教導瀏覽器新的技巧,同時保留 HTML 的優點。
定義新元素
如要定義新的 HTML 元素,就需要 JavaScript 的強大功能!
customElements
全域用於定義自訂元素,以及教導瀏覽器關於新標記。使用您要建立的標記名稱和可延伸基本 HTMLElement
的 JavaScript class
呼叫 customElements.define()
。
範例 - 定義行動導覽匣面板 <app-drawer>
:
class AppDrawer extends HTMLElement {...}
window.customElements.define('app-drawer', AppDrawer);
// Or use an anonymous class if you don't want a named constructor in current scope.
window.customElements.define('app-drawer', class extends HTMLElement {...});
使用範例:
<app-drawer></app-drawer>
請注意,自訂元素與使用 <div>
或任何其他元素並無不同。您可以在網頁上宣告例項、在 JavaScript 中動態建立、附加事件監聽器等等。請繼續閱讀,瞭解更多範例。
定義元素的 JavaScript API
自訂元素的功能是使用 ES2015 class
定義,該函式會擴充 HTMLElement
。擴充 HTMLElement
可確保自訂元素繼承整個 DOM API,也代表您新增至類別的任何屬性/方法都會成為元素的 DOM 介面。基本上,您可以使用這個類別為標記建立 公開 JavaScript API。
範例 - 定義 <app-drawer>
的 DOM 介面:
class AppDrawer extends HTMLElement {
// A getter/setter for an open property.
get open() {
return this.hasAttribute('open');
}
set open(val) {
// Reflect the value of the open property as an HTML attribute.
if (val) {
this.setAttribute('open', '');
} else {
this.removeAttribute('open');
}
this.toggleDrawer();
}
// A getter/setter for a disabled property.
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(val) {
// Reflect the value of the disabled property as an HTML attribute.
if (val) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
// Can define constructor arguments if you wish.
constructor() {
// If you define a constructor, always call super() first!
// This is specific to CE and required by the spec.
super();
// Setup a click listener on <app-drawer> itself.
this.addEventListener('click', e => {
// Don't toggle the drawer if it's disabled.
if (this.disabled) {
return;
}
this.toggleDrawer();
});
}
toggleDrawer() {
// ...
}
}
customElements.define('app-drawer', AppDrawer);
在本例中,我們會建立具有 open
屬性、disabled
屬性和 toggleDrawer()
方法的抽屜。它也會將屬性反映為 HTML 屬性。
自訂元素的一個簡單功能是,類別定義中的 this
會參照 DOM 元素本身,也就是類別的例項。在本範例中,this
是指 <app-drawer>
。這樣 (😉?) 就是元素如何附加 click
事件監聽器!而且不限於事件監聽器。整個 DOM API 都可在元素程式碼中使用。使用 this
存取元素的屬性、檢查子項 (this.children
)、查詢節點 (this.querySelectorAll('.items')
) 等。
建立自訂元素的規則
- 自訂元素的名稱必須包含破折號 (-)。因此,
<x-tags>
、<my-element>
和<my-awesome-app>
都是有效名稱,而<tabs>
和<foo_bar>
則無效。這項規定可讓 HTML 剖析器區分自訂元素和一般元素。在 HTML 中加入新標記時,也能確保後續相容。 - 您無法註冊相同的標記。嘗試這樣做將會擲回
DOMException
。只要您告知瀏覽器新的代碼,恕不退貨。 - 自訂元素無法自行關閉,因為 HTML 只允許少數元素自行關閉。請一律編寫結尾標記 (
<app-drawer></app-drawer>
)。
自訂元素回應
自訂元素可定義特殊的生命週期鉤子,在元素存在的特定時間點執行程式碼。這些稱為自訂元素反應。
名稱 | 在下列情況下呼叫 |
---|---|
constructor |
系統會建立或升級元素的例項。可用於初始化狀態、設定事件監聽器,或建立陰影 DOM。如要瞭解 constructor 中的限制,請參閱規格。 |
connectedCallback |
每次元素插入 DOM 時都會呼叫。可用於執行設定程式碼,例如擷取資源或轉譯。一般來說,您應盡量將工作延後至這個時間。 |
disconnectedCallback |
每次從 DOM 移除元素時都會呼叫。用於執行清理程式碼。 |
attributeChangedCallback(attrName, oldVal, newVal) |
在新增、移除、更新或取代觀察屬性時呼叫。當剖析器建立元素或升級時,也會為初始值呼叫此方法。注意:只有 observedAttributes 屬性中列出的屬性會收到這個回呼。 |
adoptedCallback |
自訂元素已移至新的 document (例如名為 document.adoptNode(el) 的某人)。 |
Reaction 回呼為同步。如果有人在元素上呼叫 el.setAttribute()
,瀏覽器會立即呼叫 attributeChangedCallback()
。同樣地,當元素從 DOM 中移除 (例如使用者呼叫 el.remove()
) 後,您也會收到 disconnectedCallback()
。
範例:為 <app-drawer>
新增自訂元素回應:
class AppDrawer extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
// ...
}
connectedCallback() {
// ...
}
disconnectedCallback() {
// ...
}
attributeChangedCallback(attrName, oldVal, newVal) {
// ...
}
}
在適當情況下定義回應。如果元素相當複雜,且會在 connectedCallback()
中開啟與 IndexedDB 的連線,請在 disconnectedCallback()
中執行必要的清理工作。不過請注意,您無法在所有情況下依賴元素從 DOM 中移除。舉例來說,如果使用者關閉分頁,系統就不會呼叫 disconnectedCallback()
。
資源和屬性
將屬性反應至屬性
HTML 屬性以 HTML 屬性的形式將值反轉回 DOM 是很常見的情況。例如,當 JS 中的 hidden
或 id
值變更時:
div.id = 'my-id';
div.hidden = true;
這些值會以屬性的形式套用至即時 DOM:
<div id="my-id" hidden>
這就是所謂的「將屬性反應至屬性」。幾乎所有 HTML 屬性都會這樣做。這是因為屬性也適用於以宣告方式設定元素,而某些 API (例如無障礙和 CSS 選取器) 則需要使用屬性才能運作。
反應屬性在您想讓元素的 DOM 表示法與 JavaScript 狀態保持同步的任何地方都很實用。您可能會想反映屬性的原因之一,是為了在 JS 狀態變更時套用使用者定義的樣式。
喚回我們的 <app-drawer>
。這個元件的使用者可能會在元件停用時,將其淡出和/或防止使用者互動:
app-drawer[disabled] {
opacity: 0.5;
pointer-events: none;
}
變更 JS 中的 disabled
屬性時,我們希望在 DOM 加入該屬性,以便比對使用者的選取器。這個元素可將值反映到相同名稱的屬性,藉此提供該行為:
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(val) {
// Reflect the value of `disabled` as an attribute.
if (val) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
this.toggleDrawer();
}
觀察屬性的變化
HTML 屬性是使用者宣告初始狀態的便利方式:
<app-drawer open disabled></app-drawer>
元素可透過定義 attributeChangedCallback
回應屬性變更。瀏覽器會在每次變更 observedAttributes
陣列中列出的屬性時呼叫這個方法。
class AppDrawer extends HTMLElement {
// ...
static get observedAttributes() {
return ['disabled', 'open'];
}
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(val) {
if (val) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
// Only called for the disabled and open attributes due to observedAttributes
attributeChangedCallback(name, oldValue, newValue) {
// When the drawer is disabled, update keyboard/screen reader behavior.
if (this.disabled) {
this.setAttribute('tabindex', '-1');
this.setAttribute('aria-disabled', 'true');
} else {
this.setAttribute('tabindex', '0');
this.setAttribute('aria-disabled', 'false');
}
// TODO: also react to the open attribute changing.
}
}
在這個範例中,當 disabled
屬性發生變更時,我們會在 <app-drawer>
上設定其他屬性。雖然我們在這裡沒有這麼做,但您也可以使用 attributeChangedCallback
讓 JS 屬性與其屬性保持同步。
元素升級
漸進式增強 HTML
我們已經瞭解,自訂元素的定義方式是呼叫 customElements.define()
。但這不代表您必須一次定義 + 註冊自訂元素。
自訂元素可在前註冊定義後使用。
漸進式改善是自訂元素的功能。也就是說,您可以在網頁上宣告許多 <app-drawer>
元素,並在稍後叫用 customElements.define('app-drawer', ...)
。這是因為瀏覽器會根據不明標記,以不同方式處理潛在的自訂元素。呼叫 define()
並為現有元素提供類別定義的程序稱為「元素升級」。
若要得知標記名稱何時定義,可以使用 window.customElements.whenDefined()
。它會傳回 Promise,在元素定義時解析。
customElements.whenDefined('app-drawer').then(() => {
console.log('app-drawer defined');
});
範例:延遲工作,直到一組子項元素升級為止
<share-buttons>
<social-button type="twitter"><a href="...">Twitter</a></social-button>
<social-button type="fb"><a href="...">Facebook</a></social-button>
<social-button type="plus"><a href="...">G+</a></social-button>
</share-buttons>
// Fetch all the children of <share-buttons> that are not defined yet.
let undefinedButtons = buttons.querySelectorAll(':not(:defined)');
let promises = [...undefinedButtons].map((socialButton) => {
return customElements.whenDefined(socialButton.localName);
});
// Wait for all the social-buttons to be upgraded.
Promise.all(promises).then(() => {
// All social-button children are ready.
});
元素定義的內容
自訂元素可透過元素程式碼內的 DOM API 管理自己的內容。這時可以使用回應功能。
範例 - 建立含有部分預設 HTML 的元素:
customElements.define('x-foo-with-markup', class extends HTMLElement {
connectedCallback() {
this.innerHTML = "<b>I'm an x-foo-with-markup!</b>";
}
// ...
});
宣告此標記會產生:
<x-foo-with-markup>
<b>I'm an x-foo-with-markup!</b>
</x-foo-with-markup>
// TODO:DevSite - 已移除使用內嵌事件處理常式的程式碼範例
建立使用 Shadow DOM 的元素
Shadow DOM 提供一種方法,可讓元素擁有、轉譯及設定樣式,以便與網頁的其他部分分開的 DOM 片段。您甚至可以將整個應用程式隱藏在單一標記中:
<!-- chat-app's implementation details are hidden away in Shadow DOM. -->
<chat-app></chat-app>
如要在自訂元素中使用 Shadow DOM,請在 constructor
中呼叫 this.attachShadow
:
let tmpl = document.createElement('template');
tmpl.innerHTML = `
<style>:host { ... }</style> <!-- look ma, scoped styles -->
<b>I'm in shadow dom!</b>
<slot></slot>
`;
customElements.define('x-foo-shadowdom', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
// Attach a shadow root to the element.
let shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(tmpl.content.cloneNode(true));
}
// ...
});
使用範例:
<x-foo-shadowdom>
<p><b>User's</b> custom text</p>
</x-foo-shadowdom>
<!-- renders as -->
<x-foo-shadowdom>
#shadow-root
<b>I'm in shadow dom!</b>
<slot></slot> <!-- slotted content appears here -->
</x-foo-shadowdom>
使用者自訂文字
// TODO:DevSite - 已移除使用內嵌事件處理常式的程式碼範例
從 <template>
建立元素
對於不熟悉的使用者,<template>
元素可讓您宣告 DOM 片段,這些片段會在解析時處於無效狀態,並可在稍後的執行階段啟用。這是網頁元件系列中的另一個 API 原始元素。範本是宣告自訂元素結構的理想預留位置。
範例:使用由 <template>
建立的 Shadow DOM 內容註冊元素:
<template id="x-foo-from-template">
<style>
p { color: green; }
</style>
<p>I'm in Shadow DOM. My markup was stamped from a <template>.</p>
</template>
<script>
let tmpl = document.querySelector('#x-foo-from-template');
// If your code is inside of an HTML Import you'll need to change the above line to:
// let tmpl = document.currentScript.ownerDocument.querySelector('#x-foo-from-template');
customElements.define('x-foo-from-template', class extends HTMLElement {
constructor() {
super(); // always call super() first in the constructor.
let shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.appendChild(tmpl.content.cloneNode(true));
}
// ...
});
</script>
這幾行程式碼的威力不容小覷。以下說明主要工作:
- 我們在 HTML 中定義了新元素:
<x-foo-from-template>
- 元素的 Shadow DOM 是從
<template>
建立 - 因為 Shadow DOM 的關係,元素的 DOM 與元素區域相近
- 元素的內部 CSS 會受到 Shadow DOM 的範圍限制
我在 Shadow DOM 中我的標記引用自 <template>。
// TODO:DevSite - 已移除使用內嵌事件處理常式的程式碼範例
設定自訂元素的樣式
即使元素使用 Shadow DOM 定義自己的樣式,使用者仍可在自己的網頁中為自訂元素設定樣式。這就是所謂的「使用者定義樣式」。
<!-- user-defined styling -->
<style>
app-drawer {
display: flex;
}
panel-item {
transition: opacity 400ms ease-in-out;
opacity: 0.3;
flex: 1;
text-align: center;
border-radius: 50%;
}
panel-item:hover {
opacity: 1.0;
background: rgb(255, 0, 255);
color: white;
}
app-panel > panel-item {
padding: 5px;
list-style: none;
margin: 0 7px;
}
</style>
<app-drawer>
<panel-item>Do</panel-item>
<panel-item>Re</panel-item>
<panel-item>Mi</panel-item>
</app-drawer>
您可能會想知道,如果元素在 Shadow DOM 中定義了樣式,CSS 特異性如何運作。就具體性而言,使用者樣式勝出。它們一律會覆寫元素定義的樣式。請參閱「建立使用 Shadow DOM 的元素」一節。
為未註冊的元素預先設定樣式
在元素升級前,您可以使用 :defined
擬類別在 CSS 中指定元素。這對於預先設定元件樣式相當實用。舉例來說,您可能想隱藏未定義的元件,並在定義時淡入,藉此避免版面配置或其他視覺 FOUC。
範例:在定義 <app-drawer>
之前隱藏:
app-drawer:not(:defined) {
/* Pre-style, give layout, replicate app-drawer's eventual styles, etc. */
display: inline-block;
height: 100vh;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
定義 <app-drawer>
後,選取器 (app-drawer:not(:defined)
) 就不再相符。
擴充元素
自訂元素 API 可用於建立新的 HTML 元素,但也能用於擴充其他自訂元素,甚至是瀏覽器內建的 HTML。
擴充自訂元素
如要擴充其他自訂元素,請擴充其類別定義。
範例:建立擴充 <app-drawer>
的 <fancy-app-drawer>
:
class FancyDrawer extends AppDrawer {
constructor() {
super(); // always call super() first in the constructor. This also calls the extended class' constructor.
// ...
}
toggleDrawer() {
// Possibly different toggle implementation?
// Use ES2015 if you need to call the parent method.
// super.toggleDrawer()
}
anotherMethod() {
// ...
}
}
customElements.define('fancy-app-drawer', FancyDrawer);
擴充原生 HTML 元素
假設您想建立更精緻的 <button>
。與其複製 <button>
的行為和功能,不如使用自訂元素逐步強化現有元素,這會是更好的做法。
自訂內建元素是一種自訂元素,可擴充瀏覽器的其中一個內建 HTML 標記。擴充現有元素的主要優點是取得其所有功能 (DOM 屬性、方法、無障礙功能)。想要編寫漸進式網頁應用程式,最好的方法就是逐步強化現有 HTML 元素。
如要擴充元素,您必須建立從正確 DOM 介面繼承的類別定義。舉例來說,擴充 <button>
的自訂元素需要沿用 HTMLButtonElement
,而非 HTMLElement
。同樣地,擴充 <img>
的元素也需要擴充 HTMLImageElement
。
範例 - 擴充 <button>
:
// See https://html.spec.whatwg.org/multipage/indices.html#element-interfaces
// for the list of other DOM interfaces.
class FancyButton extends HTMLButtonElement {
constructor() {
super(); // always call super() first in the constructor.
this.addEventListener('click', e => this.drawRipple(e.offsetX, e.offsetY));
}
// Material design ripple animation.
drawRipple(x, y) {
let div = document.createElement('div');
div.classList.add('ripple');
this.appendChild(div);
div.style.top = `${y - div.clientHeight/2}px`;
div.style.left = `${x - div.clientWidth/2}px`;
div.style.backgroundColor = 'currentColor';
div.classList.add('run');
div.addEventListener('transitionend', (e) => div.remove());
}
}
customElements.define('fancy-button', FancyButton, {extends: 'button'});
請注意,在擴充原生元素時,對 define()
的呼叫會略有變動。必要的第三個參數會告訴瀏覽器您要擴充的代碼。這是必要的,因為許多 HTML 標記都共用相同的 DOM 介面。<section>
、<address>
和 <em>
(以及其他類似項目) 都會共用 HTMLElement
;<q>
和 <blockquote>
都會共用 HTMLQuoteElement
;等等。指定 {extends: 'blockquote'}
可讓瀏覽器知道您要建立的是強化版 <blockquote>
,而非 <q>
。如需 HTML 的 DOM 介面完整清單,請參閱 HTML 規格。
自訂內建元素的使用者可以透過多種方式使用該元素。只要在原生標記中加入 is=""
屬性,即可宣告:
<!-- This <button> is a fancy button. -->
<button is="fancy-button" disabled>Fancy button!</button>
在 JavaScript 中建立例項:
// Custom elements overload createElement() to support the is="" attribute.
let button = document.createElement('button', {is: 'fancy-button'});
button.textContent = 'Fancy button!';
button.disabled = true;
document.body.appendChild(button);
或使用 new
運算子:
let button = new FancyButton();
button.textContent = 'Fancy button!';
button.disabled = true;
以下是另一個擴充 <img>
的範例。
範例 - 擴充 <img>
:
customElements.define('bigger-img', class extends Image {
// Give img default size if users don't specify.
constructor(width=50, height=50) {
super(width * 10, height * 10);
}
}, {extends: 'img'});
使用者宣告此元件為:
<!-- This <img> is a bigger img. -->
<img is="bigger-img" width="15" height="20">
或在 JavaScript 中建立例項:
const BiggerImage = customElements.get('bigger-img');
const image = new BiggerImage(15, 20); // pass constructor values like so.
console.assert(image.width === 150);
console.assert(image.height === 200);
其他詳細資料
不明元素與未定義的自訂元素
HTML 的使用方式靈活且不嚴格。舉例來說,在網頁上宣告 <randomtagthatdoesntexist>
,瀏覽器會樂意接受它。為什麼非標準代碼可以運作?答案是,HTML 規格允許這麼做。未在規格中定義的元素會解析為 HTMLUnknownElement
。
但自訂元素則不然。如果您使用有效名稱 (包含「-」) 建立潛在的自訂元素,系統會將其剖析為 HTMLElement
。您可以在支援自訂元素的瀏覽器中查看這項資訊。啟動控制台:Ctrl + Shift + J (或在 Mac 上按 Cmd + Opt + J),然後貼上以下程式碼行:
// "tabs" is not a valid custom element name
document.createElement('tabs') instanceof HTMLUnknownElement === true
// "x-tabs" is a valid custom element name
document.createElement('x-tabs') instanceof HTMLElement === true
API 參考資料
customElements
全域定義了使用自訂元素的實用方法。
define(tagName, constructor, options)
在瀏覽器中定義新的自訂元素。
範例
customElements.define('my-app', class extends HTMLElement { ... });
customElements.define(
'fancy-button', class extends HTMLButtonElement { ... }, {extends: 'button'});
get(tagName)
請使用有效的自訂元素標記名稱,傳回元素的建構函式。如果未註冊元素定義,則會傳回 undefined
。
範例
let Drawer = customElements.get('app-drawer');
let drawer = new Drawer();
whenDefined(tagName)
傳回在定義自訂元素時解析的 Promise。如果已定義該元素,請立即解決這個問題。如果標記名稱不是有效的自訂元素名稱,則會遭到拒絕。
範例
customElements.whenDefined('app-drawer').then(() => {
console.log('ready!');
});
記錄和瀏覽器支援
如果您過去幾年一直關注網路元件,就會知道 Chrome 36 以上版本實作了採用 document.registerElement()
而非 customElements.define()
的 Custom Elements API 版本。這項標準現已淘汰,並稱為 v0。customElements.define()
是新的熱門度,以及瀏覽器廠商即將開始實作的內容。這就是所謂的「自訂元素 v1」。
如果您對舊版 v0 規格感興趣,請參閱 html5rocks 文章。
瀏覽器支援
Chrome 54 (狀態)、Safari 10.1 (狀態) 和 Firefox 63 (狀態) 提供自訂元素 1.0。Edge 已開始開發。
如要讓功能偵測自訂元素,請檢查 window.customElements
是否存在:
const supportsCustomElementsV1 = 'customElements' in window;
聚酯纖維
在瀏覽器廣泛支援之前,自訂元素 v1 提供獨立的 polyfill。不過,我們建議您使用 webcomponents.js 載入器,以最佳方式載入網頁元件 polyfill。載入器會使用功能偵測功能,僅以非同步方式載入瀏覽器所需的必要輪詢填入項目。
安裝方式如下:
npm install --save @webcomponents/webcomponentsjs
使用方式:
<!-- Use the custom element on the page. -->
<my-element></my-element>
<!-- Load polyfills; note that "loader" will load these async -->
<script src="node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js" defer></script>
<!-- Load a custom element definitions in `waitFor` and return a promise -->
<script type="module">
function loadScript(src) {
return new Promise(function(resolve, reject) {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
WebComponents.waitFor(() => {
// At this point we are guaranteed that all required polyfills have
// loaded, and can use web components APIs.
// Next, load element definitions that call `customElements.define`.
// Note: returning a promise causes the custom elements
// polyfill to wait until all definitions are loaded and then upgrade
// the document in one batch, for better performance.
return loadScript('my-element.js');
});
</script>
結論
自訂元素提供新工具,可以在瀏覽器中定義新的 HTML 標記,並建立可重複使用的元件。將這些元素與其他新平台基本功能 (例如 Shadow DOM 和 <template>
) 結合後,我們就會開始實現網頁元件的全方位概念:
- 跨瀏覽器 (網路標準),可用於建立及擴充可重複使用的元件。
- 無需程式庫或架構即可開始使用。Vanilla JS/HTML FTW!
- 提供熟悉的程式設計模式。這只是 DOM/CSS/HTML。
- 可與其他新的網路平台功能 (Shadow DOM、
<template>
、CSS 自訂屬性等) 搭配使用 - 與瀏覽器的開發人員工具緊密整合。
- 善用現有的無障礙功能。