前言
通過《WebComponent魔法堂:深究Custom Element 之 面向痛點編程》,我們明白到其實Custom Element并不是什么新東西,我們甚至可以在IE5.5上定義自己的alert
元素。但這種簡單粗暴的自定義元素并不是我們需要的,我們需要的是具有以下特點的自定義元素:
- 自定義元素可通過原有的方式實例化(
<custom-element></custom-element>
,new CustomElement()
和document.createElement('CUSTOM-ELEMENT')
) - 可通過原有的方法操作自定義元素實例(如
document.body.appendChild
,可被CSS樣式所修飾等) - 能監聽元素的生命周期
而Google為首提出的H5 Custom Element讓我們可以在原有標準元素的基礎上向瀏覽器注入各種抽象層次更高的自定義元素,并且在元素CRUD操作上與原生API無縫對接,編程體驗更平滑。下面我們一起來通過H5 Custom Element來重新定義alert
元素吧!
命名這件“小事”
在正式擼代碼前我想讓各位最頭痛的事應該就是如何命名元素了,下面3個因素將影響我們的命名:
- 命名沖突。自定義組件如同各種第三方類庫一樣存在命名沖突的問題,那么很自然地會想到引入命名空間來解決,但由于組件的名稱并不涉及組件資源加載的問題,因此我們這里簡化一下——為元素命名添加前綴即可,譬如采用很JAVA的
com-cnblogs-fsjohnhuang-alert
。 - 語義化。語義化我們理解就是元素名稱達到望文生義的境界,譬如
x-alert
一看上去就是知道x
是前綴而已跟元素的功能無關,alert
才是元素的功能。 - 足夠的吊:)高大上的名稱總讓人賞心悅目,就像我們項目組之前開玩笑說要把預警系統改名為"超級無敵全球定位來料品質不間斷跟蹤預警綜合平臺",呵呵!
除了上述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
來自定義元素呢?瀏覽器自當會說一句“悟空,你又調皮了”
現在我們已經通過命名規范來有效區分自定義元素和原生元素,并且通過前綴解決了命名沖突問題。嘿稍等,添加前綴真的是解決命名沖突的好方法嗎?這其實跟通過添加前綴解決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">×</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重寫上面兩個示例
- 從頭定義
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">×</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)
- 漸進增強
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
語句要么沒有,要么就只能是return
或return this
1.3. 不能調用document.write
和document.open
方法
1.4. 不要訪問元素的特性(attribute)和子元素,因為元素可能處于undefined狀態并沒有特性和子元素可訪問
1.5. 不要設置元素的特性和子元素,因為即使元素處于defined狀態,通過document.createElement
和new
方式創建元素實例時,本應該是沒有特性和子元素的
2.打造focusable元素 by tabindex特性
默認情況下自定義元素是無法獲取焦點的,因此需要顯式添加tabindex
特性來讓其focusable。另外還要注意的是若元素disabled
為true
時,必須移除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類:
- Void elements
格式為<tag-name>
,包含以下元素area
,base
,br
,col
,embed
,hr
,img
,keygen
,link
,meta
,param
,source
,track
,wbr
- Raw text elements
格式為<tag-name></tag-name>
,包含以下元素script
,style
- escapable raw text elements
格式為<tag-name></tag-name>
,包含以下元素textarea
,title
- Foreign elements
格式為<tag-name/>
,MathML和SVG命名空間下的元素 - Normal elements
格式為<tag-name></tag-name>
,除上述4種元素外的其他元素。某些條件下可以省略結束標簽,因為瀏覽器會自動為我們補全,但結果往往會很吊軌,所以還是自己寫完整比較安全。
總結
當頭一回聽到Custom Element時我是那么的興奮不已,猶如找到根救命稻草似的。但如同其他新技術的出現一樣,利弊同行,如何判斷和擇優利用是讓人頭痛的事情,也許前人的經驗能給我指明方向吧!下篇《WebComponent魔法堂:深究Custom Element 之 從過去看現在》,我們將穿越回18年前看看先驅HTML Component的黑歷史,然后再次審視WebComponent吧!
尊重原創,轉載請注明來自:http://www.cnblogs.com/fsjohnhuang/p/5938790.html ^_^肥仔John
感謝
How to Create Custom HTML Elements
A vocabulary and associated APIs for HTML and XHTML
Custom Elements v1
custom-elements-customized-builtin-example
文章列表