- 原文地址:Creating a Custom Element from Scratch
- 原文作者:Caleb Williams
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:ANFOUNNYSOUL
- 校對者:portandbridge, wznonstop
在上一篇文章,我們在文件中建立了 HTML 模板,希望它們在需要時才呈現,這讓我們開始接觸 Web 元件。
接下來,我們將繼續建立對話方塊元件的自定義元素版本,該自定義元素版本目前僅使用 HTMLTemplateElement
。
請在 CodePen 上檢視由 Caleb Williams (@calebdwilliams) 建立的帶有指令碼的模板對話方塊 Demo。
因此,下一步我們將建立一個自定義元素,該元素實時使用我們的 template#dialog-template
元素。
系列文章:
新增一個自定義元素
Web 元件的基礎元素是自定義元素。該 customElements
的 API 為我們提供了建立自定義 HTML 標籤的途徑,這些標籤可以在包含定義類的任何文件中使用。
可以把它想象成 React 或 Angular 元件(例如 <MyCard />
),但實際上它不依賴於 React 或 Angular。原生自定義元件是這樣的:<my-card></my-card>
。更重要的是,將它視為一個標準元素,可以在你的 React、Angular、Vue、[insert-framework-you’re-interested-in-this-week] 應用中使用,而不必大驚小怪。
從本質上講,一個自定義元素分為兩個部分組成:一個標籤名稱和一個 Class 類擴充套件內建 HTMLElement
類。我們自定義元素的簡易 demo 版本如下所示:
class OneDialog extends HTMLElement {
connectedCallback() {
this.innerHTML = `<h1>Hello, World!</h1>`;
}
}
customElements.define('one-dialog', OneDialog);
複製程式碼
注意:在整個自定義元素中,this 值是對自身自定義元素例項的引用。
在上面的示例中,我們定義了一個符合標準的新 HTML 元素,<one-dialog></one-dialog>
。它現在暫時還做不了什麼...,在任何 HTML 文件中使用 <one-dialog>
標籤將會建立一個帶著 <h1>
標籤顯示 “Hello, World!” 的新元素。
我們肯定想把它做的更 NB,很幸運。在上一篇文章中,我們為彈出框建立模板,並且能夠拿到模板,讓我們在自定義元素中使用它。我們在該示例中新增了一個 script 標籤來執行一些對話方塊魔術。我們暫時刪除它,因為我們將把邏輯從 HTML 模板移到自定義元素類中。
class OneDialog extends HTMLElement {
connectedCallback() {
const template = document.getElementById('one-dialog');
const node = document.importNode(template.content, true);
this.appendChild(node);
}
}
複製程式碼
現在,定義了自定義元素(<one-dialog>
)並指示瀏覽器呈現包含在呼叫自定義元素的 HTML 模板中的內容。
下一步是將我們的邏輯轉移到元件類中。
自定義元素生命週期方法
與 React 或 Angular 一樣,自定義元素具有生命週期方法。筆者已經向各位介紹過 connectedCallback
,當我們的元素被新增到 DOM 的時候呼叫它。
connectedCallback
與元素的 constructor
是分開的。函式用於設定元素的基本骨架,而 connectedCallback
通常用於向元素新增內容、設定事件監聽器或以其他方式初始化元件。
實際上,建構函式不能用於設計或修改或操作元素的屬性,如果我們要使用對話方塊建立新例項,document.createElement
則會呼叫建構函式。元素的使用者需要一個沒有插入屬性或內容的簡單節點。
該 createElement 函式沒有可以用於配置將返回的元素的選項。這是符合情理的,那麼話說回來了,既然這個函式沒有選項可以配置會返回的元素,那我們唯一的選擇就是 connectedCallback
。
在標準內建元素中,元素的狀態通常通過元素上存在的屬性和這些屬性的值來反映。對於我們的示例,我們將僅檢視一個屬性:[open]
。為此,我們需要觀察該屬性的更改,我們需要 attributeChangedCallback
來做到這一點。只要其中一個元素建構函式 observedAttributes
之一的屬性發生變化就會觸發第二個生命週期方法。
這可能聽起來難以實現,但語法非常簡單:
class OneDialog extends HTMLElement {
static get observedAttributes() {
return ['open'];
}
attributeChangedCallback(attrName, oldValue, newValue) {
if (newValue !== oldValue) {
this[attrName] = this.hasAttribute(attrName);
}
}
connectedCallback() {
const template = document.getElementById('one-dialog');
const node = document.importNode(template.content, true);
this.appendChild(node);
}
}
複製程式碼
在上面的例子中,我們只關心屬性是否設定,我們不關心具體的值(這類似於 HTML5 input 輸入框上的 required
屬性)。更新此屬性時,我們更新元素的 open
屬性。屬性(property)存在於 JavaScript 物件上,HTML Elements 也具有屬性(attribute);這個生命週期方法可以幫助我們讓兩種屬性保持同步。
我們將 updater 包含在 attributeChangedCallback
內部的條件檢查中,以檢視新值和舊值是否相等。我們這樣做是為了防止程式中出現無限迴圈,因為稍後我們將建立一個 getter 和 setter 屬性,它將通過在元素的屬性(property)更新時設定元素的屬性(attribute)來保持屬性(attribute)和屬性(property)的同步。attributeChangedCallback
反向執行:當屬性更改時更新屬性。
現在,開發者可以使用我們的元件,並且利用 open
屬性決定對話方塊是否預設開啟。為了使它更具動態性,我們可以在元素的 open
屬性中新增自定義 getter 和 setter:
class OneDialog extends HTMLElement {
static get boundAttributes() {
return ['open'];
}
attributeChangedCallback(attrName, oldValue, newValue) {
this[attrName] = this.hasAttribute(attrName);
}
connectedCallback() {
const template = document.getElementById('one-dialog');
const node = document.importNode(template.content, true);
this.appendChild(node);
}
get open() {
return this.hasAttribute('open');
}
set open(isOpen) {
if (isOpen) {
this.setAttribute('open', true);
} else {
this.removeAttribute('open');
}
}
}
複製程式碼
getter 和 setter 將保證(HTML 元素節點上)的 open
特性和屬性(在 DOM 物件上)的值同步。新增 open
特性會將 element.open
設定為 true
,同理,將 element.open
設定為 true
會新增 open
屬性。我們這樣做是為了確保元素的狀態由其屬性反映出來。雖然在技術層面上不一定需要,但被認為是建立自定義元素的最優辦法。
雖然這難免引入一些樣板檔案,但是通過迴圈觀察到的屬性列表並使用 Object.defineProperty
建立一個保持這些屬性同步的抽象類是一項相當簡單的任務。
class AbstractClass extends HTMLElement {
constructor() {
super();
// 檢查觀察到的屬性是否已定義並具有長度
if (this.constructor.observedAttributes && this.constructor.observedAttributes.length) {
// 通過觀察到的屬性進行迴圈
this.constructor.observedAttributes.forEach(attribute => {
// 動態定義 getter/setter 原型
Object.defineProperty(this, attribute, {
get() { return this.getAttribute(attribute); },
set(attrValue) {
if (attrValue) {
this.setAttribute(attribute, attrValue);
} else {
this.removeAttribute(attribute);
}
}
}
});
}
}
}
// 我們可以擴充套件抽象類,而不是直接擴充套件 HTMLElement
class SomeElement extends AbstractClass { /** 省略 **/ }
customElements.define('some-element', SomeElement);
複製程式碼
上面的例子並不完美,它沒有考慮實現像 open
這樣的屬性的可能性,這些屬性沒有被賦值,而僅僅依賴於屬性的存在。做一個完美的版本將超出本文的範圍。
現在我們已經知道我們的對話方塊是否開啟了,讓我們新增一些邏輯來實際地進行顯示和隱藏:
class OneDialog extends HTMLElement {
/** 省略 */
constructor() {
super();
this.close = this.close.bind(this);
}
set open(isOpen) {
this.querySelector('.wrapper').classList.toggle('open', isOpen);
this.querySelector('.wrapper').setAttribute('aria-hidden', !isOpen);
if (isOpen) {
this._wasFocused = document.activeElement;
this.setAttribute('open', '');
document.addEventListener('keydown', this._watchEscape);
this.focus();
this.querySelector('button').focus();
} else {
this._wasFocused && this._wasFocused.focus && this._wasFocused.focus();
this.removeAttribute('open');
document.removeEventListener('keydown', this._watchEscape);
this.close();
}
}
close() {
if (this.open !== false) {
this.open = false;
}
const closeEvent = new CustomEvent('dialog-closed');
this.dispatchEvent(closeEvent);
}
_watchEscape(event) {
if (event.key === 'Escape') {
this.close();
}
}
}
複製程式碼
這裡發生了很多事情,讓我們來梳理一下。我們要做的第一件事就是獲取我們的容器,在 isOpen
的基礎上切換 .open
類。為了使我們的元素可以訪問,我們還需要切換 aria-hidden
屬性。
如果對話方塊已經開啟了,那麼我們希望儲存對先前聚焦元素的引用。這是為了考慮可訪問性標準。我們還將一個 keydown 監聽器新增到名為 WatEscape
的文件中,該文件在建構函式中繫結元素的 this
,其模式類似於 React 處理類元件中的方法呼叫的方式。
我們這樣做不僅是為了確保正確繫結 this.close
,還因為 Function.prototype.bind
返回帶繫結呼叫棧的函式的例項。通過在建構函式中儲存對新繫結方法的引用,我們可以在對話方塊斷開時刪除事件(稍後將詳細介紹)。最後,我們將注意力集中在元素上,並將焦點設定在 shadow root 中的適當元素上。
我們還建立了一個很好的小實用工具方法來關閉我們的對話方塊,它分派一個自定義事件來通知某個監聽器對話方塊已經關閉。
如果元素是關閉的(即 !open
),我們檢查以確保 this._wasFocused
屬性已定義並具有 focus
方法並呼叫該方法以將使用者的焦點返回到常規 DOM。然後我們刪除我們的事件監聽器以避免任何記憶體洩漏。
說到為自己的程式碼做好清理善後,就自然也要說下我們採用了另一種生命週期方法:disconnectedCallback
。disconnectedCallback
與 connectedCallback
相反,因為一旦從 DOM 中刪除了元素,該方法就會被呼叫,它允許我們清理附加到元素的任何事件監聽器或 MutationObservers
。
碰巧的是,我們還有幾個事件偵聽器要連線起來:
class OneDialog extends HTMLElement {
/** Omitted */
connectedCallback() {
this.querySelector('button').addEventListener('click', this.close);
this.querySelector('.overlay').addEventListener('click', this.close);
}
disconnectedCallback() {
this.querySelector('button').removeEventListener('click', this.close);
this.querySelector('.overlay').removeEventListener('click', this.close);
}
}
複製程式碼
現在我們有一個執行良好,大部分可訪問的對話方塊元素。我們可以做一些修飾,比如將焦點集中在元素上,但這超出了我們在本文學習的範圍。
還有一個生命週期方法 adoptedCallback
。它不適用於我們的元素,其作用是元素被採用(插入)到 DOM 的另一部分時觸發。
在下面的示例中,您將看到我們的模板元素正被一個標準元素 <one-dialog>
所使用。
請在 CodePen 上檢視由 Caleb Williams (@calebdwilliams) 建立的對話方塊元件使用模板 Demo。
另一個概念:非演示元件
到目前為止,我們建立的 <one-template>
是一個典型的自定義元素,它包含了當元素包含在文件中時被插入到文件中的標記和行為。然而,並不是所有的元素都需要直觀地呈現。在 React 生態系統中,元件通常用於管理應用程式狀態或其他一些主要功能,像react-redux 裡的 <Provider />
。
讓我們想象一下,我們的元件是工作流中一系列對話方塊的一部分。當一個對話方塊關閉時,下一個對話方塊應該開啟。我們可以建立一個容器元件來監聽我們的 dialog-closed
事件並在整個工作流程中進行:
class DialogWorkflow extends HTMLElement {
connectedCallback() {
this._onDialogClosed = this._onDialogClosed.bind(this);
this.addEventListener('dialog-closed', this._onDialogClosed);
}
get dialogs() {
return Array.from(this.querySelectorAll('one-dialog'));
}
_onDialogClosed(event) {
const dialogClosed = event.target;
const nextIndex = this.dialogs.indexOf(dialogClosed);
if (nextIndex !== -1) {
this.dialogs[nextIndex].open = true;
}
}
}
複製程式碼
這個元素沒有任何表示邏輯,但它充當了應用程式狀態的控制器。只需稍加努力,我們就可以重新建立類似 Redux 的狀態管理系統,只使用一個自定義元素,可以在 React 的 Redux 容器元件所在的同一個應用程式中管理整個應用程式的狀態。
這是對自定義元素的深入瞭解
現在我們對自定義元素有了很好的理解,我們的對話方塊開始融合在一起。但它仍然存在一些問題。
請注意,我們必須新增一些 CSS 來重新設定對話方塊按鈕,因為元素的樣式會干擾頁面的其餘部分。雖然我們可以利用命名策略(如 BEM)來確保我們的樣式不會與其他元件產生衝突,但是有一種更友好的方式來隔離樣式。那就是 shadow DOM。本文系列 Web Components 專題的下一篇文章就會談到它。
我們需要做的另一件事是為每個元件定義一個新模板,或者為我們的對話方塊找到一些切換模板的方法。就目前而言,每頁只能有一個對話方塊型別,因為它使用的模板必須始終存在。因此,我們要麼需要注入動態內容的方法,要麼需要替換模板的方法。
在下一篇文章中,我們將研究如何通過使用 shadow DOM 合併樣式和內容封裝來提高我們剛剛建立的 <one-dialog>
元素的可用性。
系列文章:
如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。