WebComponent魔法堂:深究Custom Element 之 標準構建

前言

 經過《WebComponent魔法堂:深究Custom Element 之 面向痛點編程》,咱們明白到其實Custom Element並非什麼新東西,咱們甚至能夠在IE5.5上定義本身的alert元素。但這種簡單粗暴的自定義元素並非咱們須要的,咱們須要的是具備如下特色的自定義元素:html

  1. 自定義元素可經過原有的方式實例化(<custom-element></custom-element>,new CustomElement()document.createElement('CUSTOM-ELEMENT'))html5

  2. 可經過原有的方法操做自定義元素實例(如document.body.appendChild,可被CSS樣式所修飾等)python

  3. 能監聽元素的生命週期
     而Google爲首提出的H5 Custom Element讓咱們能夠在原有標準元素的基礎上向瀏覽器注入各類抽象層次更高的自定義元素,而且在元素CRUD操做上與原生API無縫對接,編程體驗更平滑。下面咱們一塊兒來經過H5 Custom Element來從新定義alert元素吧!web

命名這件「小事」

 在正式擼代碼前我想讓各位最頭痛的事應該就是如何命名元素了,下面3個因素將影響咱們的命名:chrome

  1. 命名衝突。自定義組件如同各類第三方類庫同樣存在命名衝突的問題,那麼很天然地會想到引入命名空間來解決,但因爲組件的名稱並不涉及組件資源加載的問題,所以咱們這裏簡化一下——爲元素命名添加前綴便可,譬如採用很JAVA的com-cnblogs-fsjohnhuang-alertdjango

  2. 語義化。語義化咱們理解就是元素名稱達到望文生義的境界,譬如x-alert一看上去就是知道x是前綴而已跟元素的功能無關,alert纔是元素的功能。編程

  3. 足夠的吊:)高大上的名稱總讓人賞心悅目,就像咱們項目組以前開玩笑說要把預警系統更名爲"超級無敵全球定位來料品質不間斷跟蹤預警綜合平臺",呵呵!
     除了上述3點外,H5規範中還有這條規定:自定義元素必須至少包含一個連字符,即最簡形式也要這樣a-b。而不帶連字符的名稱均留做瀏覽器原生元素使用。換個說法就是名稱帶連字符的元素被識別爲有效的自定義元素,而不帶連字符的元素要麼被識別爲原生元素,要麼被識別爲無效元素。數組

const compose = (...fns) => {
  const lastFn = fns.pop()
  fns = fns.reverse()
  return a => fns.reduce((p, fn) => fn(p), lastFn(a))
}
const info = msg => console.log(msg)
const type = o => Object.prototype.toString.call(o)
const printType = compose(info, type)

const newElem = tag => document.createElement(tag)

// 建立有效的自定義元素
const xAlert = newElem('x-alert')
infoType(xAlert) // [object HTMLElement]

// 建立無效的自定義元素
const alert = newElem('alert')
infoType(alert) // [object HTMLUnknownElement]

// 建立有效的原生元素
const div = newElem('div')
infoType(div) // [object HTMLDivElement]

 那若是我偏要用alert來自定義元素呢?瀏覽器自當會說一句「悟空,你又調皮了」
promise

 如今咱們已經經過命名規範來有效區分自定義元素和原生元素,而且經過前綴解決了命名衝突問題。嘿稍等,添加前綴真的是解決命名衝突的好方法嗎?這其實跟經過添加前綴解決id衝突同樣,假若有兩個元素髮生命名衝突時,咱們就再把前綴加長直至再也不衝突爲止,那就有可能出現很JAVA的com-cnblogs-fsjohnhuang-alert的命名,噪音明顯有點多,直接下降語義化的程度,重點還有每次引用該元素時都要敲這麼多字符,打字的累看的也累。這一切的根源就是有且僅有一個Scope——Global Scope,所以像解決命名衝突的附加信息則沒法經過上下文來隱式的提供,直接致使須要經過前綴的方式來硬加上去。
 前綴的方式我算是認了,但能不能少打寫字呢?像命名空間那樣
木有命名衝突時瀏覽器

#!usr/bin/env python
# -*- coding: utf-8 -*-
from django.http import HttpResponse

def index(request):
  return HttpResponse('Hello World!')

存在命名衝突時

#!usr/bin/env python
# -*- coding: utf-8 -*-
import django.db.models
import peewee

type(django.db.models.CharField)
type(peewee.CharField)

前綴也能有選擇的省略就行了!

把玩Custome Element v0

 對元素命名吐嘈一地後,是時候把玩API了。

從頭到腳定義新元素

/** x-alert元素定義 **/
const xAlertProto = Object.create(HTMLElement.prototype, {
  /* 元素生命週期的事件 */
  // 實例化時觸發
  createdCallback: {
    value: function(){
      console.log('invoked createCallback!')

      const raw = this.innerHTML
      this.innerHTML = `<div class="alert alert-warning alert-dismissible fade in">
                          <button type="button" class="close" aria-label="Close">
                            <span aria-hidden="true">&times;</span>
                          </button>
                          <div class="content">${raw}</div>
                        </div>`
      this.querySelector('button.close').addEventListener('click', _ => this.close())
    }
  },
  // 元素添加到DOM樹時觸發
  attachedCallback: {
    value: function(){
      console.log('invoked attachedCallback!')
    }
  },
  // 元素DOM樹上移除時觸發
  detachedCallback: {
    value: function(){
      console.log('invoked detachedCallback!')
    }
  },
  // 元素的attribute發生變化時觸發
  attributeChangedCallback: {
    value: function(attrName, oldVal, newVal){
      console.log(`attributeChangedCallback-change ${attrName} from ${oldVal} to ${newVal}`)
    }
  },
  /* 定義元素的公有方法和屬性 */
  // 重寫textContent屬性
  textContent: {
    get: function(){ return this.querySelector('.content').textContent },
    set: function(val){ this.querySelector('.content').textContent = val }
  },
  close: {
    value: function(){ this.style.display = 'none' }
  },
  show: {
    value: function(){ this.style.display = 'block' }
  }
}) 
// 向瀏覽器註冊自定義元素
const XAlert = document.registerElement('x-alert', { prototype: xAlertProto })

/** 操做 **/
// 實例化
const xAlert1 = new XAlert() // invoked createCallback!
const xAlert2 = document.createElement('x-alert') // invoked createCallback!
// 添加到DOM樹
document.body.appendChild(xAlert1) // invoked attachedCallback!
// 從DOM樹中移除
xAlert1.remove() // invoked detachedCallback!
// 僅做爲DIV的子元素,而不是DOM樹成員不會觸發attachedCallback和detachedCallback函數
const d = document.createElement('div')
d.appendChild(xAlert1)
xAlert1.remove()
// 訪問元素實例方法和屬性
xAlert1.textContent = 1
console.log(xAlert1.textContent) // 1
xAlert1.close()
// 修改元素實例特性
xAlert1.setAttribute('d', 1) // attributeChangedCallback-change d from null to 1
xAlert1.removeAttribute('d') // attributeChangedCallback-change d from 1 to null 
// setAttributeNode和removeAttributeNode方法也會觸發attributeChangedCallback

 上面經過定義x-alert元素展示了Custom Element的全部API,其實就是繼承HTMLElement接口,而後選擇性地實現4個生命週期回調方法,而在createdCallback中書寫自定義元素內容展開的邏輯。另外能夠定義元素公開屬性和方法。最後經過document.registerElement方法告知瀏覽器咱們定義了全新的元素,你要好好對它哦!
 那如今的問題在於假如<x-alert></x-alert>這個HTML Markup出如今document.registerElement調用以前,那會出現什麼狀況呢?這時的x-alert元素處於unresolved狀態,而且能夠經過CSS Selector :unresolved來捕獲,當執行document.registerElement後,x-alert元素則處於resolved狀態。因而可針對兩種狀態做樣式調整,告知用戶處於unresolved狀態的元素暫不可用,敬請期待。

<style>
  x-alert{
    display: block;
  }
  x-alert:unresolved{
    content: 'LOADING...';
  }
</style>

漸進加強原生元素

 有時候咱們只是想在現有元素的基礎上做些功能加強,假若又要從頭作起那也太折騰了,幸虧Custom Element規範早已爲咱們想好了。下面咱們來對input元素做加強

const xInputProto = Object.create(HTMLInputElement.prototype, {
  createdCallback: {
    value: function(){ this.value = 'x-input' }
  },
  isEmail: {
    value: function(){
      const val = this.value
      return /[0-9a-zA-Z]+@\S+\.\S+/.test(val)
    }
  }
})
document.registerElement('x-input', {
  prototype: xInputProto,
  extends: 'input'
})

// 操做
const xInput1 = document.createElement('input', 'x-input') // <input is="x-input">
console.log(xInput1.value) // x-input
console.log(xInput1.isEmail()) // false

Custom Element v1 —— 換個裝而已啦

 Custom Element API如今已經升級到v1版本了,其實就是提供一個專門的window.customElements做爲入口來統一管理和操做自定義元素,而且以對ES6 class更友善的方式定義元素,其中的步驟和概念並無什麼變化。下面咱們採用Custom Element v1的API重寫上面兩個示例

  1. 從頭定義

class XAlert extends HTMLElement{
  // 至關於v0中的createdCallback,但要注意的是v0中的createdCallback僅元素處於resolved狀態時才觸發,而v1中的constructor就是即便元素處於undefined狀態也會觸發,所以儘可能將操做延遲到connectedCallback裏執行
  constructor(){
    super() // 必須調用父類的構造函數

    const raw = this.innerHTML
    this.innerHTML = `<div class="alert alert-warning alert-dismissible fade in">
                        <button type="button" class="close" aria-label="Close">
                          <span aria-hidden="true">&times;</span>
                        </button>
                        <div class="content">${raw}</div>
                      </div>`
    this.querySelector('button.close').addEventListener('click', _ => this.close())
  }
  // 至關於v0中的attachedCallback
  connectedCallback(){
    console.log('invoked connectedCallback!')
  }
  // 至關於v0中的detachedCallback
  disconnectedCallback(){
    console.log('invoked disconnectedCallback!')
  }
  // 至關於v0中的attributeChangedCallback,但新增一個可選的observedAttributes屬性來約束所監聽的屬性數目
  attributeChangedCallback(attrName, oldVal, newVal){
    console.log(`attributeChangedCallback-change ${attrName} from ${oldVal} to ${newVal}`)
  }
  // 缺省時表示attributeChangedCallback將監聽全部屬性變化,若返回數組則僅監聽數組中的屬性變化
  static get observedAttributes(){ return ['disabled'] }
  // 新增事件回調,就是經過document.adoptNode方法修改元素ownerDocument屬性時觸發
  adoptedCallback(){
    console.log('invoked adoptedCallback!')
  }
  get textContent(){
    return this.querySelector('.content').textContent
  }
  set textContent(val){
    this.querySelector('.content').textContent = val
  }
  close(){
    this.style.display = 'none'
  }
  show(){
    this.style.display = 'block'
  }
}
customElements.define('x-alert', XAlert)
  1. 漸進加強

class XInput extends HTMLInputElement{
  constructor(){
    super()

    this.value = 'x-input'
  }
  isEmail(){
    const val = this.value
    return /[0-9a-zA-Z]+@\S+\.\S+/.test(val)
  }
}
customElements.define('x-input', XInput, {extends: 'input'})

// 實例化方式
document.createElement('input', {is: 'x-input'})
new XInput()
<input is="x-input">

 除此以外以前的unresolved狀態改爲defined和undefined狀態,CSS對應的選擇器爲:defined:not(:defined)
 還有就是新增一個customeElements.whenDefined({String} tagName):Promise方法,讓咱們能監聽自定義元素從undefined轉換爲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.
});

從頭定義一個恰好可用的元素不容易啊!

 到這裏我想你們已經對Custom Element API有所認識了,下面咱們嘗試自定義一個完整的元素吧。不過再實操前,咱們先看看一個恰好可用的元素應該注意哪些細節。

明確各階段適合的操做

1.constructor
 用於初始化元素的狀態和設置事件監聽,或者建立Shadow Dom。
2.connectedCallback
 資源獲取和元素渲染等操做適合在這裏執行,但該方法可被調用屢次,所以對於只執行一次的操做要自帶檢測方案。
3.disconnectedCallback
 適合做資源清理等工做(如移除事件監聽)

更細的細節

1.constructor中的細節
1.1. 第一句必須調用super()保證父類實例建立
1.2. return語句要麼沒有,要麼就只能是returnreturn this
1.3. 不能調用document.writedocument.open方法
1.4. 不要訪問元素的特性(attribute)和子元素,由於元素可能處於undefined狀態並無特性和子元素可訪問
1.5. 不要設置元素的特性和子元素,由於即便元素處於defined狀態,經過document.createElementnew方式建立元素實例時,本應該是沒有特性和子元素的
2.打造focusable元素 by tabindex特性
 默認狀況下自定義元素是沒法獲取焦點的,所以須要顯式添加tabindex特性來讓其focusable。另外還要注意的是若元素disabledtrue時,必須移除tabindex讓元素unfocusable。
3.ARIA特性
 經過ARIA特性讓其餘閱讀器等其餘訪問工具能夠識別咱們的自定義元素。
4.事件類型轉換
 經過addEventListener捕獲事件,而後經過dispathEvent發起事件來對事件類型進行轉換,從而觸發更符合元素特徵的事件類型。

下面咱們來擼個x-btn

class XBtn extends HTMLElement{
  static get observedAttributes(){ return ['disabled'] }
  constructor(){
    super()

    this.addEventListener('keydown', e => {
      if (!~[13, 32].indexOf(e.keyCode)) return  

      this.dispatchEvent(new MouseEvent('click', {
        cancelable: true,
        bubbles: true
      }))
    })

    this.addEventListener('click', e => {
      if (this.disabled){
        e.stopPropagation()
        e.preventDefault()
      }
    })
  }
  connectedCallback(){
    this.setAttribute('tabindex', 0)
    this.setAttribute('role', 'button')
  }
  get disabled(){
    return this.hasAttribute('disabled')
  }
  set disabled(val){
    if (val){
      this.setAttribute('disabled','')
    }
    else{
      this.removeAttribute('disabled')
    }
  }
  attributeChangedCallback(attrName, oldVal, newVal){
    this.setAttribute('aria-disabled', !!this.disabled)
    if (this.disabled){
      this.removeAttribute('tabindex')
    }
    else{
      this.setAttribute('tabindex', '0')
    }
  }
}
customElements.define('x-btn', XBtn)

如何開始使用Custom Element v1?

 Chrome54默認支持Custom Element v1,Chrome53則需要修改啓動參數chrome --enable-blink-features=CustomElementsV1。其餘瀏覽器可以使用webcomponets.js這個polyfill。

題外話一番

 關於Custom Element咱們就說到這裏吧,不過我在此提一個有點怪但又確實應該被注意到的細節問題,那就是自定義元素是否是必定要採用<x-alert></x-alert>來聲明呢?可否採用<x-alert/><x-alert>的方式呢?
 答案是不行的,因爲自定義元素屬於Normal Element,所以必須採用<x-alert></x-alert>這種開始標籤和閉合標籤來聲明。那麼什麼是Normal Element呢?
其實元素分爲如下5類:

  1. Void elements
     格式爲<tag-name>,包含如下元素area,base,br,col,embed,hr,img,keygen,link,meta,param,source,track,wbr

  2. Raw text elements
     格式爲<tag-name></tag-name>,包含如下元素script,style

  3. escapable raw text elements
     格式爲<tag-name></tag-name>,包含如下元素textarea,title

  4. Foreign elements
     格式爲<tag-name/>,MathML和SVG命名空間下的元素

  5. Normal elements
     格式爲<tag-name></tag-name>,除上述4種元素外的其餘元素。某些條件下能夠省略結束標籤,由於瀏覽器會自動爲咱們補全,但結果每每會很吊軌,因此仍是本身寫完整比較安全。

總結

 當頭一回聽到Custom Element時我是那麼的興奮不已,猶如找到根救命稻草似的。但如同其餘新技術的出現同樣,利弊同行,如何判斷和擇優利用是讓人頭痛的事情,也許前人的經驗能給我指明方向吧!下篇《WebComponent魔法堂:深究Custom Element 之 從過去看如今》,咱們將穿越回18年前看看先驅HTML Component的黑歷史,而後再次審視WebComponent吧!
 尊重原創,轉載請註明來自:http://www.cnblogs.com/fsjohn... ^_^肥仔John

感謝

How to Create Custom HTML Elements
A vocabulary and associated APIs for HTML and XHTML
Custom Elements v1
custom-elements-customized-builtin-example

相關文章
相關標籤/搜索