[譯] 從 0 建立自定義元素

ANFOUNNYSOUL發表於2019-04-14

上一篇文章,我們在文件中建立了 HTML 模板,希望它們在需要時才呈現,這讓我們開始接觸 Web 元件。

接下來,我們將繼續建立對話方塊元件的自定義元素版本,該自定義元素版本目前僅使用 HTMLTemplateElement

請在 CodePen 上檢視由 Caleb Williams (@calebdwilliams) 建立的帶有指令碼的模板對話方塊 Demo。

因此,下一步我們將建立一個自定義元素,該元素實時使用我們的 template#dialog-template 元素。

系列文章:

  1. Web Components 簡介
  2. 編寫可複用的 HTML 模板
  3. 從 0 開始建立自定義元素(本文
  4. 使用 Shadow DOM 封裝樣式和結構
  5. Web 元件的高階工具

新增一個自定義元素

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。然後我們刪除我們的事件監聽器以避免任何記憶體洩漏。

說到為自己的程式碼做好清理善後,就自然也要說下我們採用了另一種生命週期方法:disconnectedCallbackdisconnectedCallbackconnectedCallback 相反,因為一旦從 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> 元素的可用性。

系列文章:

  1. Web Components 簡介
  2. 編寫可重複使用的 HTML 模板
  3. 從 0 開始建立自定義元素(本文
  4. 使用 Shadow DOM 封裝樣式和結構
  5. Web 元件的高階工具

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章