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

上一篇文章,咱們在文檔中建立了 HTML 模板,但願它們在須要時才呈現,這讓咱們開始接觸 Web 組件。css

接下來,咱們將繼續建立對話框組件的自定義元素版本,該自定義元素版本目前僅使用 HTMLTemplateElementhtml

請在 CodePen 上查看由 Caleb Williams (@calebdwilliams) 建立的帶有腳本的模板對話框 Demo。前端

所以,下一步咱們將建立一個自定義元素,該元素實時使用咱們的 template#dialog-template 元素。node

系列文章:

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

添加一個自定義元素

Web 組件的基礎元素是自定義元素。該 customElements 的 API 爲咱們提供了建立自定義 HTML 標籤的途徑,這些標籤能夠在包含定義類的任何文檔中使用。react

能夠把它想象成 React 或 Angular 組件(例如 <MyCard />),但實際上它不依賴於 React 或 Angular。原生自定義組件是這樣的:<my-card></my-card>。更重要的是,將它視爲一個標準元素,能夠在你的 React、Angular、Vue、[insert-framework-you’re-interested-in-this-week] 應用中使用,而沒必要大驚小怪。android

從本質上講,一個自定義元素分爲兩個部分組成:一個標籤名稱和一個 Class 類擴展內置 HTMLElement 類。咱們自定義元素的簡易 demo 版本以下所示:ios

class OneDialog extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `<h1>Hello, World!</h1>`;
  }
}

customElements.define('one-dialog', OneDialog);
複製代碼

注意:在整個自定義元素中,this 值是對自身自定義元素實例的引用。git

在上面的示例中,咱們定義了一個符合標準的新 HTML 元素,<one-dialog></one-dialog>。它如今暫時還作不了什麼...,在任何 HTML 文檔中使用 <one-dialog> 標籤將會建立一個帶着 <h1> 標籤顯示 「Hello, World!」 的新元素。github

咱們確定想把它作的更 NB,很幸運。在上一篇文章中,咱們爲彈出框建立模板,而且可以拿到模板,讓咱們在自定義元素中使用它。咱們在該示例中添加了一個 script 標籤來執行一些對話框魔術。咱們暫時刪除它,由於咱們將把邏輯從 HTML 模板移到自定義元素類中。web

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前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索