Web 組件勢必取代前端?

在現代Web API的發展下,創建可重用的前端組件終於不再需要框架了。

Web 組件勢必取代前端?

作者 | Danny Moerkerke,JavaScript程序員,自由職業者

譯者 | 彎月

出品 | CSDN(ID:CSDNnews)

還記得document.querySelector第一次獲得瀏覽器的廣泛支持,終結了jQuery一統天下的局面的時刻嗎?我們終於擁有了一個原生的方法來代替多年來一直需要通過jQuery來提供的功能:簡單地選擇DOM元素的方法。我相信,同樣的情況也會發生在前端框架上,比如Angular和React。

有了這些框架,我們就能完成一些一直想做但一直沒辦法實現的事情——創建可重用的自動化前端組件。然而,這些框架會增加複雜性,增加專有的語法,還會增大負擔。

一切終將變化。

在現代Web API的發展下,創建可重用的前端組件終於不再需要框架了。有了自定義元素和影子DOM,我們就可以創建能夠隨意複用的組件。

Web組件(Web Component)的概念最初於2011年提出,組件包括一系列功能,可以僅通過HTML、CSS和JavaScript就能創建可重用的組件。也就是說,創建組件不需要再使用React或Angular之類的框架。更妙的是,這些組件還能夠無縫地集成到這些框架中。

有史以來我們第一次能夠僅通過HTML、CSS和JavaScript創建組件並在任何現代瀏覽器上運行。現在,最新版本的Chrome、Safari、Firefox和Opera桌面版,以及Safari的iOS版、Chrome的Android版都支持Web組件。

Edge將在下一個版本(版本19)中支持Web組件。舊版本瀏覽器還可使用polyfill(https://github.com/webcomponents/webcomponentsjs),最低能在IE11上實現Web組件。

也就是說,現在幾乎能在任何瀏覽器(包括移動瀏覽器)上使用Web組件。

你可以創建自定義的HTMl標籤,它能夠從被擴展的HTML元素那裡繼承所有的屬性,然後只需要簡單地導入一段腳本,就可以在任何支持Web組件的瀏覽器中使用。組件中定義的所有HTML、CSS和JavaScript的定義域都僅限於組件內部。

在瀏覽器的開發者工具中,組件將顯示為單個HTML標籤,所有的樣式和行為都完全被封裝,不需要任何額外的技巧,不需要框架,也不需要編譯。

我們來看看Web組件的主要功能。

自定義元素

自定義元素(Custom Elements)就是用戶自定義的HTML元素,可以使用CustomElementRegistry定義自定義元素。如果你想註冊新的元素,只需通過window.customElements獲得registry的實例,然後調用其define方法:

window.customElements.define('my-element', MyElement);

define方法的第一個參數是要創建的新元素的標籤名稱。接下來,你只需要下面的代碼就可以使用該元素:

<my-element> 

名稱中的橫線(-)是必須的,這是為了避免與原生HTML元素的命名衝突。

MyElement構造函數必須是ES6類,然而很不幸的是,由於Javascript類不同於傳統的OOP語言的類,這很容易造成混亂。而且,因為這裡可以使用Object,所以Proxy也是可行的,這樣就能在自定義元素上實現簡單的數據綁定。但是,如果想實現對原生HTML元素的擴展,這個限制是必須的,這樣才能保證你的元素能夠繼承整個DOM API。

下面我們來為自定義元素寫一個類:

class MyElement extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
// here the element has been inserted into the DOM
}
}

我們自定義元素的類只是普通的JavaScript類,它擴展了原生的HTMLElement。除了構造函數之外,它還有個方法叫做connectedCallback,當元素被插入到DOM樹之後該方法會被調用。你可以認為它相當於React的componentDidMount方法。

一般來說,組件的設置應當儘可能低推遲到connectdedCallback中進行,因為這是唯一一個能夠確保所有屬性和子元素都存在的地方。一般來說,構造函數應該僅初始化狀態,以及設置影子DOM(Shadow DOM)。

元素的constructor和connectedCallback的區別在於,constructor在元素被創建時調用(例如通過調用document.createElement創建),而connectedCallback是在元素真正被插入到DOM中時調用,例如當元素所在的文檔被解析時,或者通過document.body.appendChild添加元素時。

你也可以通過customElements.get('my-element')來獲取自定義元素的構造函數的引用,通過該方法來創建元素,假設該元素已經通過customElements.define()註冊過了的話。然後可以通過new element()而不是document.createElement()來初始化元素:

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中移除時會調用該方法。在這個方法中可以進行必要的清理工作,但要記住這個方法不一定會被調用,比如用戶關閉瀏覽器或關閉瀏覽器標籤頁的時候。

還有個adoptedCallback方法,當通過document.adoptNode(element)將元素收養至文檔中時會調用該方法。到目前為止,我從來沒遇到過需要使用該回調函數的情況。

另一個常用的生命週期方法是attributeChangedCallback。當屬性被添加到observedAttributes數組時該方法會被調用。該方法調用時的參數為屬性的名稱、屬性的舊值和新值:

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
}
}
}

該回調函數僅在屬性存在於observedAttributes數組中時才會被調用,在上例中為foo和bar。任何其他屬性的變化不會調用該回調函數。

屬性主要用於定義元素的初始配置和初始狀態。理論上通過序列化的方式給屬性傳遞複雜的值,但這會對性能造成很大影響,而且由於你能夠訪問組件的方法,所以這樣做是沒有必要的。但如果確實希望像React、Angular等框架提供的功能那樣,在屬性上實現數據綁定,可以看看Ploymer(https://polymer-library.polymer-project.org/)。

生命週期方法的順序

生命週期方法的執行順序為:

constructor -> attributeChangedCallback -> connectedCallback

為什麼attributeChangedCallback會在connectedCallback之前被調用?

回憶一下,Web組件的屬性的主要目的是初始化配置。也就是說,當組件被插入到DOM中時,配置應當已經被初始化過了,所以attributeChangedCallback應當在connectedCallback之前被調用。

也就是說,如果想根據特定屬性的值,在影子DOM中配置任何結點,那就需要在constructor中引用屬性,而不能在connectedCallback中進行。

例如,如果組件中有個id="container",而你需要在屬性disabled發生改變時,將這個元素設置為灰色背景,那麼需要在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中才能創建this.container,那麼可能在第一次attributeChangedCallback被調用時,this.container不存在。所以,儘管你應當儘量將組件的設置推遲到connectedCallback中進行,但這是個例外情況。

另一點很重要的是,要意識到你可以在通過customElements.define()註冊Web組件之前就使用它。當元素存在於DOM中,或者被插入到DOM中時,如果它還沒有被註冊,那麼它將成為HTMLUnknownElement的實例。瀏覽器會對於任何它不認識的HTML元素的處理方法是,你依然可以像使用其他元素那樣使用它,只是它沒有任何方法,也沒有默認的樣式。

在通過customElements.define()註冊之後,該元素就會通過類定義得到增強。該過程稱為“升級”(upgrading)。可以在元素被升級時通過customElements.whenDefined調用一個回調函數,該方法返回一個Promise,在元素被升級時該Promise得到解決:

customElements.whenDefined('my-element')
.then(() => {
// my-element is now defined
})

Web組件的公共API

除了生命週期方法之外,你還可以在元素上定義方法,這些方法可以從外部調用。這個功能是React和Angular等框架無法實現的。例如,你可以定義一個名為doSomething的方法:

class MyElement extends HTMLElement {
...
doSomething() {
// do something in this method
}
}

然後在組件外部像這樣調用它:

const element = document.querySelector('my-element');
element.doSomething();

任何在元素上定義的屬性都會成為它的公開JavaScript API的一部分。這樣,只需給元素的屬性提供setter,就可以實現數據綁定,從而實現類似於在元素的HTML裡渲染屬性值等功能。因為原生的HTML屬性(attribute)值僅支持字符串,因此對象等複雜的值應該作為自定義元素的屬性(properties)。

除了定義Web組件的初始狀態之外,HTML屬性(attribute)還用來反映相應的組件屬性(property)的值,因此元素的JavaScript狀態可以反映到其DOM表示中。下面的例子演示了input元素的disabled屬性:


const input = document.querySelector('input');
input.disabled = true;

在將input的disabled屬性(property)設置為true後,這個改動會反映到相應的disabled HTML屬性(attribute)中:


用setter可以很容易實現從屬性(property)到HTML屬性(attribute)的映射:

class MyElement extends HTMLElement {
...

set disabled(isDisabled) {
if(isDisabled) {
this.setAttribute('disabled', '');
}
else {
this.removeAttribute('disabled');
}
}
get disabled() {
return this.hasAttribute('disabled');
}
}

如果需要在HTML屬性(attribute)發生變化時執行一些動作,那麼可以將其加入到observedAttributes數組中。為了保證性能,只有加入到這個數組中的屬性(attribute)才會被監視。當HTML屬性(attribute)的值發生變化時,attributeChangedCallback就會被調用,同時傳入HTML屬性的名稱、當前值和新值:

class MyElement extends HTMLElement { 
static get observedAttributes() {
return ['disabled'];
}
constructor() {
const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `


`;
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屬性(attribute)改變,this.container(即元素的影子DOM中的div元素)上的“disabled”就會隨之改變。

影子DOM

使用影子DOM,自定義元素的HTML和CSS可以完全封裝在組件內部。這意味著在文檔的DOM樹中,元素會顯示為單一的HTML標籤,其實際內部HTML結構會出現在#shadow-root中。

實際上,好幾個原生HTML元素也在使用影子DOM。例如,如果在網頁上放置一個<video>元素,它會顯示為單一的標籤,但同時顯示的播放、暫停按鈕等在開發者工具中查看<video>元素時是看不到的。/<video>/<video>

這些元素實際上是<video>元素的影子DOM的一部分,因此默認是隱藏的。要在Chrome中顯示影子DOM,可以在“偏好設置”中的開發者工具中找到設置,勾選“顯示用戶代理的影子DOM”。在開發者工具中重新檢查<video>元素,就能看到元素的影子DOM。/<video>/<video>

影子DOM還支持真正的CSS範圍(scope)。所有定義在組件內部的CSS只對組件本身有效。元素僅從組件外部定義的CSS中繼承最小量的屬性,甚至,連這些屬性都可以配置為不繼承。但是,你可以暴露一些CSS屬性,允許組件的使用者給組件添加樣式。這種機制解決了現有的CSS的許多問題,同時依然支持自定義組件的樣式。

定義影子root的方式如下:

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `

Hello world

`;

這段代碼在定義影子root時使用了mode: 'open',其含義是它可以通過開發者工具進行查看和操作,可以查詢,也可以配置任何暴露的CSS屬性,也可以監聽它拋出的事件。影子root的另一個模式是mode: 'closed',但這個選項不推薦使用,因為使用者將無法與組件進行人和交互,甚至都不能監聽其拋出的事件。

要給影子root添加HTML,可以將HTML字符串賦值給影子root的innerHTML屬性,也可以使用<template>元素。HTML模板基本上是一段HTML片段,供以後使用。在插入到DOM樹中之前,它不可見,也不會被解析,也就是說其內部定義的任何外部資源都不會被下載,任何CSS和JavaScript在插入到DOM之前也不會被解析。例如,你可以定義多個<template>元素,當組件的HTML根據組件狀態而發生變化時,將相應的模板插入到DOM中。這樣就可以很容易地改變組件的大量HTML,而不需要逐個處理DOM結點。/<template>/<template>

創建影子root之後,就可以在上面使用所有DOM的方法,就像平常處理document對象那樣,如使用this.shadowRoot.querySelector來查找元素。組件的所有CSS都可以定義在


分享到:


相關文章: