還記得 document.querySelector 開(kāi)始獲得主流瀏覽器支持,并逐漸結束 jQuery 統治的歷史嗎? 它終于讓我們能夠原生實(shí)現多年來(lái)使用 jQuery 做的事情,也就是輕松選擇 DOM 元素。我相信類(lèi)似的變革也會(huì )席卷像 Angular 和 React 這樣的前端框架。
這些框架讓我們得以實(shí)現過(guò)去難以達成的目標,亦即創(chuàng )建可復用的自治前端組件;但隨之而來(lái)的代價(jià)是代碼更加復雜、需要專(zhuān)用語(yǔ)法和更多的負載壓力。
但這種情況即將改變。
現代 Web API 已發(fā)展到不再需要框架就能創(chuàng )建可復用前端組件的程度。只需要自定義元素和 Shadow DOM 就足夠創(chuàng )建可在任何地方重復使用的自治組件了。
于 2011 年面世的 Web Components 是一套功能組件,讓開(kāi)發(fā)者可以使用 HTML、CSS 和 JavaScript 創(chuàng )建可復用的組件。這意味著(zhù)你無(wú)需 React 或 Angular 等框架也能創(chuàng )建組件。不僅如此,這些組件還都可以無(wú)縫集成到這些框架中。
有史以來(lái)頭一次,我們只要使用 HTML、CSS 和 JavaScript 就能創(chuàng )建可在任何現代瀏覽器中運行的可復用組件了?,F在,桌面平臺的 Chrome、Safari、Firefox 和 Opera,iOS 上的 Safari 和 Android 上的 Chrome 最新版本都支持 Web Components。
Edge 瀏覽器將在即將發(fā)布的 19 版中提供支持。還有一個(gè) polyfill 用來(lái)兼容老舊的瀏覽器,可以讓 Web Components 與 IE11 兼容。
這意味著(zhù)你現在可以在任何瀏覽器,包括移動(dòng)設備中使用 Web Components。
你可以創(chuàng )建自定義的 HTML 標簽,這些標簽繼承了它們擴展的 HTML 元素的所有屬性,只需導入腳本即可在任何支持的瀏覽器中使用。組件內定義的所有 HTML、CSS 和 JavaScript 都完全限定在組件內部。
該組件將在瀏覽器的開(kāi)發(fā)工具中顯示為單個(gè) HTML 標簽,其樣式和行為完全封裝妥當,無(wú)需額外的處理、框架或轉換。
我們來(lái)看看 Web Components 的主要功能。
自定義元素其實(shí)就是用戶(hù)定義的 HTML 元素。它們是使用 CustomElementRegistry 定義的。要注冊一個(gè)新元素時(shí),需要通過(guò) window.customElements 獲取注冊表實(shí)例并調用其 define 方法:
window.customElement.define('my-element', MyElement);
define 方法的第一個(gè)參數是我們新創(chuàng )建元素的標簽名稱(chēng)。我們加上下面一行就能使用它了:
<my-element></my-element>
名稱(chēng)中的短劃線(xiàn)( - )是必需的,以避免與任何原生 HTML 元素發(fā)生命名沖突。
不幸的是 MyElement 構造函數必須是一個(gè) ES6 類(lèi),考慮到 Javascript 類(lèi)(還)和傳統的 OOP 類(lèi)不太一樣,這就容易讓人頭暈了。此外,如果允許使用對象,則還可以使用代理,從而為自定義元素啟用簡(jiǎn)單數據綁定。但是,需要此限制才能啟用原生 HTML 元素的擴展,并確保你的元素繼承了整個(gè) DOM API。
下面我們?yōu)樽远x元素編寫(xiě)類(lèi):
class MyElement extends HTMLElement { constructor() { super(); } connectedCallback() { // here the element has been inserted into the DOM } }
我們自定義元素的類(lèi)只是一個(gè)常規的 JavaScript 類(lèi),它擴展了原生的 HTMLElement。除了它的構造函數之外,它還有一個(gè)名為 connectedCallback 的方法,當元素插入 DOM 樹(shù)時(shí)調用該方法。你可以將其與 React 的 componentDidMount 方法做對比。
通常來(lái)說(shuō),設置組件應盡可能地延遲到 connectedCallback,因為只有這里你才能確保元素的所有屬性和子元素都可用。一般而言,構造函數只能用來(lái)初始化狀態(tài)和設置 Shadow DOM。
元素的構造函數 constructor 和 connectedCallback 之間的區別在于,在創(chuàng )建元素時(shí)調用構造函數(例如,通過(guò)調用 document.createElement),并在元素實(shí)際插入 DOM 時(shí)調用 connectedCallback,例如當文檔聲明它已被解析或已與 document.body.appendChild 一起添加時(shí)這樣做。
你還可以通過(guò)調用 customElements.get(‘my-element’) 獲取對其構造函數的引用來(lái)構造元素,前提是它已經(jīng)在 customElements.define() 中注冊。然后,你就可以使用 new element() 代替 document.createElement() 來(lái)實(shí)例化元素了:
customElements.define('my-element', class extends HTMLElement {...}); ... const el = customElements.get('my-element'); const myElement = new el(); // same as document.createElement('my-element'); document.body.appendChild(myElement);
connectedCallback 對應的是 disconnectedCallback,當從 DOM 中刪除元素時(shí)調用后者。該方法可以用來(lái)執行任何必要的清理工作,但請記住,當用戶(hù)關(guān)閉瀏覽器或瀏覽器選項卡時(shí)不會(huì )調用此方法。
當通過(guò)調用 document.adoptNode(element) 來(lái)將元素引入文檔時(shí)還會(huì )調用 adoptCallback。到目前為止,我從未遇到過(guò)這個(gè)回調的用例。
還有一個(gè)很有用的生命周期方法是 attributeChangedCallback。每當屬性更改已添加到 observedAttributes 數組時(shí)都會(huì )調用此方法??梢允褂脤傩缘拿Q(chēng)、舊值和新值來(lái)調用它:
class MyElement extends HTMLElement { static get observedAttributes() { return ['foo', 'bar']; } attributeChangedCallback(attr, oldVal, newVal) { switch(attr) { case 'foo': // do something with 'foo' attribute case 'bar': // do something with 'bar' attribute } } }
此回調僅對 observeAttributes 數組中存在的屬性調用,在本例中為 foo 和 bar。這個(gè)回調不會(huì )對其它變動(dòng)過(guò)的屬性調用。
屬性主要用于聲明元素的初始配置 / 狀態(tài)。理論上講,可以通過(guò)序列化將復雜值傳遞給屬性,但這可能會(huì )降低性能表現;因為你可以訪(fǎng)問(wèn)組件的方法,所以不需要這樣做。如果你想通過(guò) React 和 Angular 等框架提供的屬性進(jìn)行數據綁定,你可以查看 Polymer 。
生命周期方法的執行順序是:
constructor -> attributeChangedCallback -> connectedCallback
為什么在 connectedCallback之前執行 attributeChangedCallback?
回想一下,Web Components 上屬性的主要用途是初始配置。這意味著(zhù)當組件插入 DOM 時(shí),此配置需要處于可用狀態(tài),因此需要在 connectedCallback 之前調用 attributeChangedCallback。
這意味著(zhù)如果你需要根據某些屬性的值配置 Shadow DOM 中的任何節點(diǎn)時(shí),需要引用位于構造函數 constructor 中的節點(diǎn),而不是在 connectedCallback 中引用它們。
例如,如果組件中有一個(gè) id=“container”的元素,并且每當觀(guān)察到的屬性禁用更改時(shí)你都需要將此元素設置為灰色背景,請在 constructor 中引用此元素,以便它在 attributeChangedCallback 中可用:
constructor() { this.container = this.shadowRoot.querySelector('#container'); } attributeChangedCallback(attr, oldVal, newVal) { if(attr === 'disabled') { if(this.hasAttribute('disabled') { this.container.style.background = '#808080'; } else { this.container.style.background = '#ffffff'; } } }
如果你等到 connectedCallback 創(chuàng )建了 this.container 之后才引用,那么第一次調用 attributeChangedCallback 時(shí)它就不可用了。因此,盡管你應該盡可能地將組件的設置延遲到 connectedCallback,但在這里這是做不到的。
你也要明白你可以在使用 customElements.define() 注冊之前就可以使用 Web 組件。當元素存在于 DOM 中或插入其中并且尚未被注冊時(shí),它將是一個(gè) HTMLUnknownElement 的實(shí)例。瀏覽器會(huì )用這種方式處理陌生的 HTML 元素,你可以照常與它交互,但它不會(huì )有任何方法或默認的樣式。
當它通過(guò) customElements.define() 注冊時(shí),會(huì )通過(guò)類(lèi)定義得到增強。此過(guò)程被稱(chēng)為升級。使用 customElements.whenDefined 升級元素時(shí)可以調用回調,前者在元素升級時(shí)會(huì )解析返回 Promise 對象:
customElements.whenDefined('my-element') .then(() => { // my-element is now defined })
除了這些生命周期方法之外,你還可以在元素上定義可以從外部調用的方法,目前在使用 React 或 Angular 等框架定義元素時(shí)是不可能做到這一點(diǎn)的。例如,你可以定義一個(gè)名為 doSomething 的方法:
class MyElement extends HTMLElement { ... doSomething() { // do something in this method } }
并從組件外部調用它,如下所示:
const element = document.querySelector('my-element'); element.doSomething();
你在元素上定義的任何方法都將成為其公共 JavaScript API 的一部分。這樣一來(lái),你就可以通過(guò)為元素的屬性提供 setter 來(lái)實(shí)現數據綁定,這樣它就可以在元素的 HTML 中呈現屬性值,諸如此類(lèi)。由于除了字符串之外不能為屬性賦予任何其他值,因此像對象這樣的復雜值應作為屬性傳遞給自定義元素。
除了聲明一個(gè) Web 組件的初始狀態(tài)之外,attribute 屬性還能用來(lái)映射相關(guān) property 屬性的值,以便將元素的 JavaScript 狀態(tài)映射到其 DOM 表達中。一個(gè)例子是 input 元素的 disabled 屬性:
<input name="name"> const input = document.querySelector('input'); input.disabled = true;
將輸入的屬性 disabled property 設置為 true 后,此更改將映射到相關(guān)的 disabled attribute 屬性上:
<input name =“name”disabled>
使用 setter 就能將一個(gè) property 映射到一個(gè) attribute 屬性上:
class MyElement extends HTMLElement { ... set disabled(isDisabled) { if(isDisabled) { this.setAttribute('disabled', ''); } else { this.removeAttribute('disabled'); } } get disabled() { return this.hasAttribute('disabled'); } }
如果需要在屬性更改時(shí)執行某些操作,請將其添加到 observedAttributes 數組中。為提升性能,這里只會(huì )觀(guān)察此處列出的屬性以進(jìn)行更改。一旦屬性的值發(fā)生變動(dòng),就將使用屬性的名稱(chēng)、其當前值及其新值調用 attributeChangedCallback:
class MyElement extends HTMLElement { static get observedAttributes() { return ['disabled']; } constructor() { const shadowRoot = this.attachShadow({mode: 'open'}); shadowRoot.innerHTML = ` <style> .disabled { opacity: 0.4; } </style> <div id="container"></div> `; this.container = this.shadowRoot('#container'); } attributeChangedCallback(attr, oldVal, newVal) { if(attr === 'disabled') { if(this.disabled) { this.container.classList.add('disabled'); } else { this.container.classList.remove('disabled') } } } }
現在,只要 disabled 屬性發(fā)生更改,就會(huì )在 this.container 上切換“disabled”類(lèi),這是元素 Shadow DOM 中的 div 元素。
下面我們進(jìn)一步來(lái)看。
使用 Shadow DOM 時(shí),自定義元素的 HTML 和 CSS 會(huì )完全封裝在組件內部。這意味著(zhù)該元素將在文檔的 DOM 樹(shù)中顯示為單個(gè) HTML 標簽,其內部 HTML 結構則放在一個(gè)#shadow-root 中。
其實(shí) Shadow DOM 也用在幾個(gè)原生 HTML 元素上。例如當你的網(wǎng)頁(yè)中有<video>元素時(shí),它會(huì )顯示為單個(gè)標簽;但它也會(huì )顯示視頻的播放控件,這個(gè)控件是不會(huì )顯示在瀏覽器開(kāi)發(fā)工具中的<video>元素上的。
這些控件實(shí)際上是<video>元素的 Shadow DOM 的一部分,因此默認情況下是隱藏的。要在 Chrome 中顯示 Shadow DOM,請轉到開(kāi)發(fā)工具設置中的“首選項”,然后選中“顯示用戶(hù)代理 Shadow DOM”復選框。當你在開(kāi)發(fā)工具中再次檢查視頻元素時(shí)就能看到并檢查元素的 Shadow DOM 了。
Shadow DOM 還提供真正的作用域 CSS。組件內定義的所有 CSS 僅適用于組件本身。該元素僅從組件外部定義的 CSS 繼承最少量的屬性,甚至可以將這些屬性配置為不從周?chē)?CSS 繼承任何值。但你也可以公開(kāi) CSS 屬性以允許使用者為組件設置樣式。這解決了許多當下存在的 CSS 問(wèn)題,同時(shí)仍然可以使用組件的自定義樣式。
要定義一個(gè)影子根(Shadow root):
const shadowRoot = this.attachShadow({mode: 'open'}); shadowRoot.innerHTML = `<p>Hello world</p>`;
這里定義了一個(gè)帶有 mode:’open’的影子根,這意味著(zhù)它可以在開(kāi)發(fā)工具中檢查,并通過(guò)查詢(xún)、配置任何公開(kāi)的 CSS 屬性或監聽(tīng)它拋出的事件來(lái)交互。也可以用 mode:’closed’定義影子根,但這里不推薦這樣做,因為它不允許組件的使用者以任何方式與它交互;你甚至無(wú)法監聽(tīng)到它拋出的事件。
要將 HTM 添加到影子根,你可以為其 innerHTML 屬性分配 HTML 字符串或使用<template>元素。HTML 模板基本上是一個(gè)惰性 HTML 片段,你可以定義它以便以后使用。在實(shí)際插入 DOM 樹(shù)之前,它將不會(huì )被顯示或解析,這意味著(zhù)在其中定義的任何外部資源都不會(huì )被提取,并且在將其插入 DOM 之前不會(huì )解析任何 CSS 和 JavaScript。當組件的 HTML 根據其狀態(tài)更改時(shí),你可以定義多個(gè)<template>元素,從而根據組件的狀態(tài)插入這些元素,諸如此類(lèi)。這樣你就可以輕松更改組件的大部分 HTML 內容,而無(wú)需擺弄單個(gè) DOM 節點(diǎn)。
創(chuàng )建影子根后,你可以對它使用以往在 document 對象上使用的所有 DOM 方法,例如使用 this.shadowRoot.querySelector 來(lái)查找元素。組件的所有 CSS 都在<style>標簽內定義,但如果你想使用常規的<link rel =“stylesheet”>標簽,也可以獲取外部樣式表。除常規 CSS 外,你還可以使用:host 選擇器來(lái)設置組件本身的樣式。例如,自定義元素默認使用 display:inline,以便將組件顯示為可以使用的塊元素:
:host { display: block; }
這樣你也能使用上下文樣式了。例如,如果要在組件具有 disabled 屬性定義時(shí)將其顯示為灰色,可以使用:
:host([disabled]) { opacity: 0.5; }
默認情況下,自定義元素會(huì )從周?chē)?CSS 繼承一些屬性,例如 color 和 font 等。但是如果你希望以純凈狀態(tài)開(kāi)始并將所有 CSS 屬性重置為組件內的默認值,請使用:
:host { all: initial; }
要注意的是,從外部對組件本身定義的樣式優(yōu)先于 Shadow DOM 中使用:host 定義的樣式。所以如果你要定義:
my-element { display: inline-block; }
它會(huì )覆蓋:
:host { display: block; }
無(wú)法從外部設置自定義元素內的任何節點(diǎn)的樣式。但是如果你希望用戶(hù)能夠設置組件的(部分)樣式,則可以暴露 CSS 變量來(lái)做到這一點(diǎn)。例如,如果你希望用戶(hù)能夠選擇組件的背景顏色,則可以暴露一個(gè)名為–background-color 的 CSS 變量。
假設組件中 Shadow DOM 的根節點(diǎn)是<div id =“container”>:
#container { background-color: var(--background-color); }
現在,組件的用戶(hù)可以從外部設置其背景顏色:
my-element { --background-color: #ff0000; }
如果用戶(hù)未定義組件,則應在組件內為其設置默認值:
:host { --background-color: #ffffff; } #container { background-color: var(--background-color); }
當然,你可以為 CSS 變量選擇任何名稱(chēng)。 CSS 變量的唯一要求是它們要以“–”開(kāi)頭。
通過(guò)提供作用域 CSS 和 HTML,Shadow DOM 解決了 CSS 的全局特性所帶來(lái)的特殊性問(wèn)題,并且通常會(huì )產(chǎn)生巨大的僅添加樣式表,其包含越來(lái)越多的特定選擇器和覆蓋。Shadow DOM 可以將標簽和樣式捆綁到獨立的組件中,而無(wú)需任何工具或命名約定。你永遠不必再擔心新的類(lèi)或 ID 是否會(huì )與現有的類(lèi)沖突。
除了能夠通過(guò) CSS 變量設置 Web Components 的內部樣式之外,還可以將 HTML 注入 Web Components。
組合(Composition)是將 Shadow DOM 樹(shù)與用戶(hù)提供的標記組合在一起的過(guò)程。這是通過(guò)<slot>元素完成的,該元素本質(zhì)上是 Shadow DOM 中的占位符,其中呈現用戶(hù)提供的標記。用戶(hù)提供的標記稱(chēng)為 Light DOM。組合會(huì )將 Light DOM 和 Shadow DOM 組成一個(gè)新的 DOM 樹(shù)。
例如,你可以創(chuàng )建<image-gallery>組件并提供標準的<img>標簽作為要呈現的組件的內容:
<image-gallery> <img src="foo.jpg" slot="image"> <img src="b.arjpg" slot="image"> </image-gallery>
該組件現在將使用給定的兩張圖像并使用 Slot 在組件的 Shadow DOM 內呈現它們。注意圖像上的 slot =“image”屬性。它告訴組件應該在其 Shadow DOM 中的什么位置呈現它們。例如,它可能如下所示:
<div id="container"> <div class="images"> <slot name="image"></slot> </div> </div>
當 Light DOM 中的節點(diǎn)已經(jīng)分布到元素的 Shadow DOM 中時(shí),生成的 DOM 樹(shù)將如下所示:
<div id="container"> <div class="images"> <slot name="image"> <img src="foo.jpg" slot="image"> <img src="bar.jpg" slot="image"> </slot> </div> </div>
如你所見(jiàn),任何具有 slot 屬性的用戶(hù)提供的元素都將在 slot 元素內呈現,該 slot 元素具有 name 屬性,其值與 slot 屬性的值相對應。
簡(jiǎn)單的<select>元素的工作方式與你在 Chrome 開(kāi)發(fā)工具中檢查時(shí)的效果完全相同(當你選擇了顯示用戶(hù)代理 Shadow DOM 時(shí),參見(jiàn)上文):

它采用用戶(hù)提供的<option>元素并將它們呈現到下拉菜單中。
具有 name 屬性的 Slot 元素稱(chēng)為 named slot,但這一屬性并非必需的。它僅用于在特定位置呈現內容。當一個(gè)或多個(gè) slot 沒(méi)有 name 屬性時(shí),內容將按照用戶(hù)提供的順序在其中呈現。當用戶(hù)提供的內容少于 slot 數量時(shí),slot 甚至可以提供后備內容。
假設<image-gallery>的 Shadow DOM 看起來(lái)像這樣:
<div id="container"> <div class="images"> <slot></slot> <slot></slot> <slot> <strong>No image here!</strong> <-- fallback content --> </slot> </div> </div>
當再次給定同樣的兩張圖像時(shí),生成的 DOM 樹(shù)將如下所示:
<div id="container"> <div class="images"> <slot> <img src="foo.jpg"> </slot> <slot> <img src="bar.jpg"> </slot> <slot> <strong>No image here!</strong> </slot> </div> </div>
通過(guò) slot 在 Shadow DOM 內部呈現的元素稱(chēng)為分布式節點(diǎn)。在組件的(分布式)Shadow DOM 中呈現之前就應用于這些節點(diǎn)的所有樣式也將在分發(fā)后得到應用。在 Shadow DOM 中,分布式節點(diǎn)可以通過(guò):: slotted() 選擇器獲得額外的樣式:
::slotted(img) { float: left; }
:: slotted() 可以使用任何有效的 CSS 選擇器,但它只能選擇頂級節點(diǎn)。例如:: slotted(section img) 就不適用于此內容:
<image-gallery> <section slot="image"> <img src="foo.jpg"> </section> </image-gallery>
你可以通過(guò)檢查已分配給某個(gè) slot 的節點(diǎn)、已分配給某個(gè)元素的 slot 以及 slotchange 事件來(lái)通過(guò) JavaScript 與 slot 交互。
要找出哪些元素已分配給某個(gè) slot,可以調用 slot.assignedNodes()。如果你還想檢索任何后備內容,可以調用 slot.assignedNodes({flatten:true})。
要找出一個(gè)元素已分配給哪個(gè)元素的哪個(gè) slot,可以檢查 element.assignedSlot。
只要 slot 中的節點(diǎn)發(fā)生更改(即添加或刪除節點(diǎn)時(shí)),就會(huì )觸發(fā) slotchange 事件。注意事件僅針對 slot 節點(diǎn)本身觸發(fā),而不針對這些 slot 節點(diǎn)的子節點(diǎn)觸發(fā)。
slot.addEventListener('slotchange', e => { const changedSlot = e.target; console.log(changedSlot.assignedNodes()); });
首次初始化元素時(shí),Chrome 會(huì )觸發(fā) slotchange 事件,而 Safari 和 Firefox 則不會(huì )。
來(lái)自鼠標和鍵盤(pán)事件等自定義元素的標準事件默認會(huì )從 Shadow DOM 中彈出來(lái)。每當一個(gè)事件從 Shadow DOM 中的一個(gè)節點(diǎn)出來(lái)時(shí),它將被重新定位,使得該事件看起來(lái)似乎是來(lái)自自定義元素本身。如果要查找事件實(shí)際來(lái)自 Shadow DOM 中的哪個(gè)元素,可以調用 event.composedPath() 來(lái)檢索事件所經(jīng)過(guò)的節點(diǎn)數組。但是,事件的 target 屬性將始終指向自定義元素本身。
你可以使用 CustomEvent 從自定義元素中拋出所需的任何事件。
class MyElement extends HTMLElement { ... connectedCallback() { this.dispatchEvent(new CustomEvent('custom', { detail: {message: 'a custom event'} })); } } // on the outside document.querySelector('my-element').addEventListener('custom', e => console.log('message from event:', e.detail.message));
但是,當從 Shadow DOM 內的節點(diǎn)而不是自定義元素本身拋出一個(gè)事件時(shí),除非它使用 composition:true 創(chuàng )建,否則它不會(huì )從 Shadow DOM 中彈出。
class MyElement extends HTMLElement { ... connectedCallback() { this.container = this.shadowRoot.querySelector('#container'); // dispatchEvent is now called on this.container instead of this this.container.dispatchEvent(new CustomEvent('custom', { detail: {message: 'a custom event'}, composed: true // without composed: true this event will not bubble out of Shadow DOM })); } }
除了使用 this.shadowRoot.innerHTML 將 HTML 添加到元素的影子根之外,你還可以使用<template>元素來(lái)執行此操作。模板會(huì )包含 HTML 供以后使用。它不會(huì )被呈現,最初只會(huì )被解析以確保其內容是有效的。模板內的 JavaScript 不會(huì )被執行,也不會(huì )獲取任何外部資源。默認情況下它是隱藏的。
當 Web 組件需要根據不同情況呈現完全不同的標記時(shí),可以使用不同的模板來(lái)完成此任務(wù):
class MyElement extends HTMLElement { ... constructor() { const shadowRoot = this.attachShadow({mode: 'open'}); this.shadowRoot.innerHTML = ` <template id="view1"> <p>This is view 1</p> </template> <template id="view1"> <p>This is view 1</p> </template> <div id="container"> <p>This is the container</p> </div> `; } connectedCallback() { const content = this.shadowRoot.querySelector('#view1').content.clondeNode(true); this.container = this.shadowRoot.querySelector('#container'); this.container.appendChild(content); } }
這里使用 innerHTML 將兩個(gè)模板放置在元素的影子根中。一開(kāi)始兩個(gè)模板都會(huì )被隱藏,只渲染容器。在 connectedCallback 中,我們使用 this.shadowRoot.querySelector(’#view1’) 獲取#view1 中的內容。模板的 content 屬性將模板的內容作為 DocumentFragment 返回,可以使用 appendChild 將其添加到另一個(gè)元素。由于 appendChild 將移動(dòng) DOM 中已經(jīng)存在的元素,我們需要首先使用 cloneNode(true) 克隆它。否則,模板的內容將被移動(dòng)而不是附加,這意味著(zhù)我們只能使用它一次。
模板可以方便地用來(lái)快速更改大部分 HTML 或復用標記。它們不僅限于 Web Components,還可以在 DOM 中的任何位置使用。
到目前為止,我們一直在擴展 HTMLElement 以創(chuàng )建一個(gè)全新的 HTML 元素。自定義元素還允許擴展原生內置元素,從而可以增強現有的 HTML 元素,例如圖像和按鈕。在撰寫(xiě)本文時(shí),此功能僅被 Chrome 和 Firefox 支持。
擴展現有 HTML 元素的好處是繼承了元素的所有屬性和方法。這樣就能逐步增強現有元素了,意味著(zhù)即使元素在不支持自定義元素的瀏覽器中加載也仍然是可用的。此時(shí)它將簡(jiǎn)單地回退到其默認的內置行為,而如果它是一個(gè)全新的 HTML 標簽就徹底不可用了。
舉個(gè)例子,假設我們要增強 HTML <button>元素:
class MyButton extends HTMLButtonElement { ... constructor() { super(); // always call super() to run the parent's constructor as well } connectedCallback() { ... } someMethod() { ... } } customElements.define('my-button', MyButton, {extends: 'button'});
我們的 Web 組件現在擴展了 HTMLButtonElement,而不是更通用的 HTMLElement。對 customElements.define 的調用現在還需要一個(gè)額外的參數{extends:‘button’}來(lái)表示我們的類(lèi)擴展了<button>元素。這似乎是多余的,因為我們已經(jīng)指出我們想要擴展 HTMLButtonElement,但是由于存在共享相同 DOM 接口的元素,所以這是必要的。例如,<q>和<blockquote>都共享 HTMLQuoteElement 接口。
增強的按鈕現在可以與 is 屬性一起使用:
<button is="my-button">
它現在將通過(guò)我們的 MyElement 類(lèi)增強,如果它在不支持自定義元素的瀏覽器中加載,它將簡(jiǎn)單地回退到標準按鈕,這就是所謂漸進(jìn)式的增強!
注意,在擴展現有元素時(shí)不能使用 Shadow DOM。這只是通過(guò)繼承所有現有屬性、方法和事件并提供其他功能來(lái)擴展原生 HTML 元素的一種方法。當然可以從組件中修改元素的 DOM 和 CSS,但是嘗試創(chuàng )建影子根時(shí)將引發(fā)錯誤。
擴展內置元素的另一個(gè)好處是,這些元素也可以用于對子元素有限制的地方。例如,<thead>元素只允許將<tr>元素作為其子元素,因此像<awesome-tr>這樣的元素將呈現無(wú)效標記。在這種情況下,我們可以擴展內置的<tr>元素并像這樣使用它:
<table> <thead> <tr is="awesome-tr"></tr> </thead> </table>
這種創(chuàng )建 Web 組件的方式是一種很好的漸進(jìn)式增強,但如上所述,目前只有 Chrome 和 Firefox 支持它。 Edge 也將提供支持,但至少目前沒(méi)有。
與為 Angular 和 React 等框架編寫(xiě)測試相比,測試 Web Components 更加簡(jiǎn)單明了,坦率地說(shuō)是輕而易舉的。你不需要轉換或復雜的設置,只需創(chuàng )建元素,將其附加到 DOM 并運行測試即可。
以下是使用 Mocha 測試的示例:
import 'path/to/my-element.js'; describe('my-element', () => { let element; beforeEach(() => { element = document.createElement('my-element'); document.body.appendChild(element); }); afterEach(() => { document.body.removeChild(element); }); it('should test my-element', () => { // run your test here }); });
這里第一行導入 my-element.js 文件,該文件將我們的 Web Components 暴露為 ES6 模塊。這意味著(zhù)測試文件本身也需要作為 ES6 模塊加載到瀏覽器中。這需要下面的 index.html 才能在瀏覽器中運行測試。除了 Mocha 之外,這個(gè)設置還加載了 WebcomponentsJS polyfill,Chai 用于測試,Sinon 用于 spy 和 mock:
<!doctype html> <html> <head> <meta charset="utf-8"> <link rel="stylesheet" href="../node_modules/mocha/mocha.css"> <script src="../node_modules/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script> <script src="../node_modules/sinon/pkg/sinon.js"></script> <script src="../node_modules/chai/chai.js"></script> <script src="../node_modules/mocha/mocha.js"></script> <script> window.assert = chai.assert; mocha.setup('bdd'); </script> <script type="module" src="path/to/my-element.test.js"></script> <script type="module"> mocha.run(); </script> </head> <body> <div id="mocha"></div> </body> </html>
在加載了所需的腳本之后,我們將 chai.assert 作為全局變量暴露,因此我們可以在測試中使用 assert() 并設置 Mocha 來(lái)使用 BDD 接口。然后加載測試文件(在這個(gè)示例中只有一個(gè)文件),然后我們調用 mocha.run() 來(lái)運行測試。
注意,使用 ES6 模塊時(shí)還需要將 mocha.run() 放在帶有 type =“module”的腳本中。這是因為 ES6 模塊默認是延遲的,如果 mocha.run() 放在常規腳本標簽內,它將在加載 my-element.test.js 之前就執行了。
現在,桌面上的 Chrome、Firefox、Safari 和 Opera 的最新版本都支持自定義元素: https://caniuse.com/#feat=custom-elementsv1
即將推出的 Edge 19 也將提供支持,在 iOS 和 Android 上的 Safari、Chrome 和 Firefox 也支持它。
對于舊版瀏覽器,可以通過(guò)以下方式安裝 WebcomponentsJS polyfill:
npm install --save @webcomponents/webcomponentsjs
你可以加入 webcomponents-loader.js 文件,該文件將執行功能檢測以?xún)H加載必要的 polyfill。使用此 polyfill,你就可以使用自定義元素,而無(wú)需向源代碼添加任何內容。但是,它不提供真正的作用域 CSS,這意味著(zhù)如果你在不同的 Web Components 中具有相同的類(lèi)名和 ID 并將它們加載到同一文檔中就將發(fā)生沖突。此外,Shadow DOM CSS 選擇器:host() 和:slotted() 可能無(wú)法正常工作。
為了使其正常工作,你需要使用 Shady CSS polyfill,這也意味著(zhù)你必須(稍微)調整你的源代碼才能使用它。我個(gè)人不喜歡這樣,所以我創(chuàng )建了一個(gè) webpack 加載器處理這個(gè)問(wèn)題。你需要用它來(lái)做轉換,但這樣就不用改代碼了。
Webpack 加載器做了三件事:它為你的 web 組件的 Shadow DOM 中所有不以:: host 或:: slotted 開(kāi)頭的 CSS 規則添加元素標簽名稱(chēng)前綴,從而提供正確的范圍;之后它會(huì )解析所有:: host 和:: slotted 規則,以確保它們也能正常工作。
我創(chuàng )建了一個(gè) Web 組件,一旦它在瀏覽器的可視端口中完全可見(jiàn),就會(huì )平緩地加載一張圖像。你可以在 Github 上找到它。
該組件的主要版本將本機<img>標簽包裝在<lazy-img>自定義元素中:
<lazy-img src="path/to/image.jpg" width="480" height="320" delay="500" margin="0px"></lazy-img>
repo 還包含 extend-native 分支,其中包含使用 is 屬性擴展原生<img>標簽的 lazy-img:
<img is="lazy-img" src="path/to/img.jpg" width="480" height="320" delay="500" margin="0px">
這是關(guān)于原生 Web Components 功能的一個(gè)很好的例子:只需導入 JavaScript 文件,添加 HTML 標簽或使用 is 屬性擴展本地標簽就可以干活了!
我使用自定義元素實(shí)現了 Google 的 Material Design,也放到了 Github 上。
這個(gè)庫還展示了 CSS自定義屬性的強大功能。
一如既往,這取決于你的具體情況。
當下的前端框架有著(zhù)數據綁定、狀態(tài)管理和相當標準化的代碼庫等功能提供的附加價(jià)值。問(wèn)題是你的應用是否真的需要它們。
如果你需要問(wèn)自己,你的應用程序是否真的需要像 Redux 這樣的狀態(tài)管理,你可能其實(shí)并不需要它。當你真的用到它的時(shí)候再考慮也不遲。
你可能會(huì )覺(jué)得數據綁定很好用,但對于非原始值(如數組和對象)來(lái)說(shuō),原生 Web Components 已允許你直接設置屬性??梢栽趯傩陨显O置原始值,并且可以通過(guò) attributeChangedCallback 觀(guān)察對這些屬性的更改。
雖然這種方式很有用,但與在 React 和 Angular 中執行此操作的聲明方式相比,它只是更新 DOM 的一小部分就很麻煩了。這些框架允許定義包含在更改時(shí)更新的表達式的視圖。
雖然有一個(gè)提議要擴展 <template>元素以允許它實(shí)例化并使用數據更新,但原生 Web Components 仍未提供此類(lèi)功能:
<template id="example"> <h1>{{title}}</h1> <p>{{text}}</p> </template> const template = document.querySelector('#example'); const instance = template.createInstance({title: 'The title', text: 'Hello world'}); shadowRoot.appendChild(instance.content); //update instance.update({title: 'A new title', text: 'Hi there'});
當下能提供有效 DOM 更新的庫是 lit-html 。
前端框架的另一個(gè)經(jīng)常被提到的好處是,它們提供了一個(gè)標準的代碼庫,團隊中的每位新人從一開(kāi)始就很熟悉它。雖然我認可這一點(diǎn),但我也覺(jué)得這種好處非常有限。
我使用 Angular、React 和 Polymer 開(kāi)發(fā)了各種項目,盡管它們的確存在相似性,但就算使用相同的框架,這些代碼庫仍然存在很大差異。明確定義的工作方式和樣式指南更有助于你維持代碼庫的一致性,僅僅使用框架是不夠的??蚣芤矌?lái)了額外的復雜性,應該問(wèn)問(wèn)自己這是否真的值得。
現在 Web Components 得到了廣泛支持,你可能會(huì )得出這樣的結論:原生代碼可以為你帶來(lái)與框架相同的功能,但性能更強、需要的代碼更少,更加簡(jiǎn)潔。
原生 Web Components 的好處很明顯:
原生,無(wú)需框架;
易于集成,無(wú)需轉換;
真正的作用域 CSS;
標準化,只有 HTML、CSS 和 JavaScript。
jQuery 及其出色的遺產(chǎn)仍將存在一段時(shí)間,但現在有了更好的選擇,所以新建設的項目很少會(huì )去用它了。我不認為現有的框架會(huì )很快消失,但是原生 Web Components 提供了更好的選項,并且正在快速擴張。我也希望這些前端框架去扮演新的角色,只要在原生 Web Components 周?chē)洚斠粋€(gè)簡(jiǎn)單的附加層就可以了。
https://www.dannymoerkerke.com/blog/web-components-will-replace-your-frontend-framework
更多內容,請關(guān)注前端之巔。
聯(lián)系客服