【第1604期】Web Component可以取代你的前端框架嗎?

前端早讀課2019-05-14 14:57:44

前言

Web Component蠻早就有出現過,有時候需要時間。今日早讀文章由騰訊@林林小輝翻譯授權分享。

正文從這開始~~

還記得當document.querySelector最開始被廣泛的被瀏覽器支持並且結束了無處不在的JQuery。這最終給我們提供了一個原生的方法,雖然JQuery已經提供了很久。我覺得這同樣將會發生在像Angular和React這的前端框架身上。

這些框架可以幫助我們去做一些做不到的事情,比如創建可以複用的前端組件,但是這樣需要付出複雜度、專屬語法、性能消耗的代價。 但是這些將會得到改變。

現代瀏覽器的API已經更新到你不需要使用一個框架就可以去創建一個可複用的組件。Custom Element和Shadow DOM都可以讓你去創造可複用的組件。

最早在2011年,Web Components就已經是一個只需要使用HTML、CSS、JavaScript就可以創建可複用的組件被介紹給大家。這也意味著你可以不使用類似React和Angular的框架就可以創造組件。甚至,這些組件可以無縫的接入到這些框架中。

這麼久以來第一次,我們可以只使用HTML、CSS、JavaScript來創建可以在任何現代瀏覽器運行的可複用組件。Web Components現在已經被主要的瀏覽器的較新版本所支持。

Edge將會在接下來的19版本提供支持。而對於那些舊的版本可以使用 polyfill兼容至IE11. 這意味著你可以在當下基本上任何瀏覽器甚至移動端使用Web Components。

創造一個你定製的HTML標籤,它將會繼承HTM元素的所有屬性,並且你可在任何支持的瀏覽器中通過簡單的引入一個script。所有的HTML、CSS、JavaScript將會在組件內部局部定義。 這個組件在你的瀏覽器開發工具中顯示為一個單獨個HTML標籤,並且它的樣式和行為都是完全在組件內進行,不需要工作區,框架和一些前置的轉換。

讓我們來看一些Web Components的一些主要功能。

自定義元素

自定義元素是簡單的用戶自定義HTML元素。它們通過使用CustomElementRegistry來定義。要註冊一個新的元素,通過window.customElements中一個叫define的方法來獲取註冊的實例。

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

方法中的第一個參數定義了新創造元素的標籤名,我們可以非常簡單的直接使用

為了避免和native標籤衝突,這裡強制使用中劃線來連接。 這裡的MyElement的構造函數需要使用ES6的class,這讓JavaScript的class不像原來面向對象class那麼讓人疑惑。同樣的,如果一個Object和Proxy可以被使用來給自定義元素進行簡單的數據綁定。但是,為了保證你的原生HTML元素的拓展性並保證元素繼承了整個DOM API,需要使用這個限制。 讓我們寫一個這個自定義元素class

class MyElement extends HTMLElement {
constructor
() {
super();
}

connectedCallback
() {
// here the element has been inserted into the DOM
}
}

這個自定義元素的class就好像一個常規的繼承自nativeHTML元素的class。在它的構造函數中有一個叫connectedCallback額外添加的方法,當這個元素被插入DOM樹的時候將會觸發這個方法。你可以把這個方法與React的componentDidMount方法。

通常來說,我們需要在connectedCallback之後進行元素的設置。因為這是唯一可以確定所有的屬性和子元素都已經可用的辦法。構造函數一般是用來初始化狀態和設置Shadow DOM。

元素的構造函數和connectCallback的區別是,當時一個元素被創建時(好比document.createElement)將會調用構造函數,而當一個元素已經被插入到DOM中時會調用connectedCallback,例如在已經聲明並被解析的文檔中,或者使用document.body.appendChild添加。

你同樣可以用過調用customElements.get(‘my-element’)來獲取這個元素構造函數的引用,從而構造元素。前提是你已經通過customElement.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相對應的則是disconnectCallback,當元素從DOM中移除的時候將會調用它。但是要記住,在用戶關閉瀏覽器或者瀏覽器tab的時候,不會調用這個方法。 還有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這樣的框架提供屬性的綁定,那你可以看一下。Polymer。

生命週期函數的順序

順序如下:

constructor -> attributeChangedCallback -> connectedCallback

為什麼attributeChangedCallback要在connectedCallback之前執行呢?

回想一下,web組件上的屬性主要用來初始化配置。這意味著當組件被插入DOM時,這些配置需要可以被訪問了。因此attributeChangedCallback要在connectedCallback之前執行。 這意味著你需要根據某些屬性的值,在Shadow DOM中配置任何節點,那麼你需要在構造函數中引用這些節點,而不是在connectedCallback中引用它們。

例如,如果你有一個ID為container的組件,並且你需要在根據屬性的改變來決定是否給這個元素添加一個灰色的背景,那麼你可以在構造函數中引用這個元素,以便它可以在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,它還是不尅用的。因此儘管你應該儘可能的延後你組件的connectedCallback,但在這種情況下是不可能的。

同樣重要的是,你可以在組件使用customElement.define()之前去使用它。當改元素出現在DOM或者被插入到DOM,而還沒有被註冊時。它將會是一個HTMLUnkonwElement的實例。瀏覽器將會這樣處理未知的元素,你可以像處理其他元素一樣與它交互,除此之前,它將不會有任何方法和默認樣式。

然後當通過使用customElement.define()去定義它時,並可使用類來定義增加它,這個過程被稱為升級。當使用customElement.whenDefined升級元素時,可以調用回調,並會返回一個promise。當這個元素被升級時。

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

Web Component的公共API

除了這些生命週期方法,你還可以定義可以從外部調用的方法,這對於使用React和Angular等框架目前是不可行的。例如你可以定義一個名為doSomething的方法:

class MyElement extends HTMLElement {
...

doSomething
() {
// do something in this method
}
}

然後你可以在外部使用它

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

在你的元素上定義的任何方法,都會成為其公共JavaScript的一部分。通過這種方式,你可以給元素的屬性提供setter來實現數據綁定。例如在元素的HTML中展示設置的屬性值。由於本質上不可以將給屬性設置除了字符串以外的值,所以應該講像對象這樣的複雜之作為屬性傳遞給自定義元素。

除了生命組件的初始狀態,屬性還可以用於對應屬性的值,以便將元素的Javascript狀態反應到DOM的表現中。input元素的disabled屬性就是一個很好的例子:

 name="name">

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

在將input的disabled的屬性設置為true後,改變也會相應的反映到disabled屬性上。

 name="name" disabled>

通過setter可以很容易的將property反應到attribute上。

class MyElement extends HTMLElement {
...

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

get disabled() {
return this.hasAttribute('disabled');
}
}

當attribute改變後需要執行某些操作時,將其添加到observedAttributes數組中。作為一種性能優化,只有在這被列舉出的屬性才會監測它們的改變。無論這個attribute什麼時候改變了,都會調用attributeChangedCallback,參數分別是當前值和新的值。

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上面的名為disabled的class都會顯示或隱藏,它是ShadowDOM的內在元素。 接下來讓我們看一下。

Shadow DOM

使用Shadow DOM,自定義元素的HTML和CSS完全封裝在組件內。這意味著元素將以單個的HTML標籤出現在文檔的DOM樹種。其內部的結構將會放在#shadow-root。

實際上一些原生的HTML元素也使用了Shadow DOM。例如你再一個網頁中有一個

這些控件實際上就是video元素的Shadow DOM的一部分,因此默認情況下是隱藏的。要在Chrome中顯示Shadow DOM,進入開發者工具中的Preferences中,選中Show user agent Shadow DOM。當你在開發者工具中再次查看video元素時,你就可以看到該元素的Shadow DOM了。

Shadow DOM還提供了局部作用域的CSS。所有的CSS都只應用於組件本身。元素將只繼承最小數量從組件外部定義的CSS,甚至可以不從外部繼承任何CSS。不過你可以暴露這些CSS屬性,以便用戶對組件進行樣式設置。這可以解決許多CSS問題,同時仍然允許自定義組件樣式。 定義一個Shadow root:

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

Hello world

`
;

這定義了一個帶mode: open的Shadow root,這意味著可以再開發者工具找到它並與之交互,配置暴露出的CSS屬性,監聽拋出的事件。同樣也可以定義mode:closed,會得到與之相反的表現。

你可以使用使用HTML字符串添加到innerHtml的property屬性中,或者使用一個

當Shadow root被創建之後,你可以使用document對象的所有DOM方法,例如this.shadowRoot.querySelector去查找元素。組件的所有樣式都被定義在style標籤內,如果你想使用一個常規的標籤,你也可以獲取外部樣式。除此之外,還可以使用:host選擇器對組件本身進行樣式設置。例如,自定義元素默認使用display: inline,所以如果你想要將組件展示為款元素,你可以這樣做:

:host {
display
: block;
}

這還允許你進行上下文的樣式化。例如你想要通過disabled的attribute來改變組件的背景是否為灰色:

:host([disabled]) {
opacity
: 0.5;
}

默認情況下,自定義元素從周圍的CSS中繼承一些屬性,例如顏色和字體等,如果你想清空組件的初始狀態並且將組件內的所有CSS都設置為默認的初始值,你可以使用:

:host {
all
: initial;
}

非常重要,需要注意的一點是,從外部定義在組件本身的樣式優先於使用:host在Shadow DOM中定義的樣式。如果你這樣做

my-element {
display
: inline-block;
}

它將會被覆蓋

:host {
display
: block;
}

不應該從外部去改變自定義元素的樣式。如果你希望用戶可以設置組件的部分樣式,你可以暴露CSS變量去達到這個效果。例如你想讓用戶可以選擇組件的背景顏色,可以暴露一個叫 —background-color的CSS變量。 假設現在有一個Shadow DOM的根節點是 

#container {
background
-color: var(--background-color);
}

現在用戶可以在組件的外部設置它的背景顏色

my-element {
--background-color: #ff0000;
}

你還可以在組件內設置一個默認值,以防用戶沒有設置

:host {
--background-color: #ffffff;
}

#container {
background
-color: var(--background-color);
}

當然你還可以讓用戶設置任何的CSS變量,前提是這些變量的命名要以—開頭。

通過提供局部的CSS、HTML,Shadow DOM解決了全部CSS可能帶來的一些問題,這樣問題通常導致不斷地添加樣式表,其中包含了越來越多的選擇器和覆蓋。Shadow DOM似的標記和樣式捆綁到自己的組件內,而不需要任何工具和命名約定。你再也不用擔心新的class或id會與現有的任何一個衝突。

除此之外,還可以通過CSS變量設置web組件的內部樣式,還可以將HTML注入到Web Components中。

通過slots組成

組合是通過Shadow DOM樹與用戶提供的標記組合在一起的過程。這是通過元素完成的,該元素基本是Shadow DOM的佔位符,用來呈現用戶提供的標記。用戶提供的標記又可以成為 light DOM。合成會將light DOM和Shadow DOM合併成為一個新的DOM樹。

例如,你可以創建一個組件,並提供標準的img標籤作為組件要呈現的內容:


src="foo.jpg" slot="image">
src="bar.jpg" slot="image">

組件現在將會獲取兩個提供的圖像,並且使用slots將它們渲染到組件的Shadow DOM中。注意到slot=”image”的attribute,這告訴了組件應該要在Shadow DOM的什麼位置渲染它們。例如這樣

id="container">
class="images">
name="image">

當light DOM中的節點被分發到Shadow DOM中時,得到的DOM樹看起來是這樣的:

id="container">
class="images">
name="image">
src="foo.jpg" slot="image">
src="bar.jpg" slot="image">


正如你看到的,任何用戶提供的具有slot屬性的元素,都將在slot元素中呈現。而slot元素具有name屬性,其值與slot屬性的值對應。

它接受用戶提供的option元素,並將它們呈現到下拉菜單中。 帶有name屬性的slot被稱為具名slot,但是這個屬性不是必須的。它僅用於需要將內容呈現在特定位置時使用。當一個或多個slot沒有name屬性時,將按照用戶提供內容的順序在其中展示。當用戶提供的內容少於slot時,slot可以提供默認的展示。

看一下的Shadow DOM:

id="container">
class="images">



No image here!


如果你再只給兩個image的話,最後的結果如下:

id="container">
class="images">

src="foo.jpg">


src="bar.jpg">


No image here!


通過slot在Shadow DOM中展示的元素被稱為分發節點。這些組件被插入前的樣式也將會被用於他們插入後。在Shadow DOM中,分發節點可以通過::sloted()來獲取額外的樣式

::slotted(img) {
float: left;
}

::sloted()可以接受任何有效的CSS選擇器,但它只能選擇頂級節點,例如::slotedd(section img)的情況,將不會作用於this content


slot="image">
src="foo.jpg">

在JavaScript中使用slots

你可以通過JavaScript與slots進行交互去監測哪個節點被分發到哪個slot,哪些slot被插入了元素,以及slotchange事件。

要找出哪些元素已經被分發給對應的slots可以使用 slot.assignedNodes() 如果你還想查看slot的默認內容,你可以使用 slot.assignedNodes({flatten: true}) 要找出哪些slot被分發的元素,可以使用element.assignedSlot 當slot內的節點發生改變,即添加或刪除節點時,將會出發slotchange事件。要注意的是,只有當slot節點自身改變才會觸發,而這些slot節點的子節點並不會觸發。

slot.addEventListener('slotchange', e => {
const changedSlot = e.target;
console
.log(changedSlot.assignedNodes());
});

在元素第一次初始化時,Chrome會觸發slotchange事件,而Safari和Firefox則不會。

Shadow DOM中的事件

默認情況下,自定義元素(如鼠標和鍵盤事件)的標準事件將會從Shadow DOM中冒泡。每當一個事件來此Shadow DOM中的一個節點時,它會被重定向,因此該事件似乎來自元素本身。如果你想找出事件實際來自Shadow DOM中的哪個元素,可以調用event.composedPath()來檢索事件經過的節點數組。然而,事件的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的節點拋出而不是自定義元素本身,他不會從ShadowDOM上冒泡,除非它使用了composition: true來創建

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來向一個元素的shadow root添加HTML,你也可以使用 來做。template保存HTML供以後使用。它不會被渲染,並只有確保內容是有效的才會進行解析。模板中的JavaScript不會被執行,也會獲取任何外部資源,默認情況下它是隱藏的。

當一個web component需要根據不同的情況來渲染不同的標記時,可以用不同的模板來完成:

class MyElement extends HTMLElement {
...

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

this.shadowRoot.innerHTML = `





This is the container



`
;
}

connectedCallback
() {
const content = this.shadowRoot.querySelector('#view1').content.clondeNode(true);
this.container = this.shadowRoot.querySelector('#container');

this.container.appendChild(content);
}
}

這裡兩個模板都使用了innerHTML放在shadow root內,最初這兩個模板都是隱藏的,自由container被渲染。在connectedCallback中我們通過this.shadowRoot.querySelector('#view1').content.clondeNode(true)獲取了#view1的內容。模板content的屬性以DocumentFragment形式返回模板的內容,可以勇士appendChild添加到另一個元素中。因為appendChild將在元素已經存在於DOM中時移除它,所以我們需要先使用cloneNode(true),否則模板的內容將會被移除,這意味著我們只能使用一次。

模板對於快速的更改HTML部分或者重寫標記非常有用。它們不僅限於web components並且可以在任何DOM中使用。

擴展原生元素

到目前為止,我們一直在擴展HTMLElement來創建一個全新的HTML元素。自定義元素還允許使用擴展原生內置元素,支持增強已經存在的HTML元素,例如images和buttons。目前此功能僅在Chrome和Firefox中受支持。

擴展現有HTML元素的好處是繼承了元素的所有屬性和方法。這允許對現有元素進行逐步的增強。這意味著即使在不支持自定義元素的瀏覽器中,它仍是可用的。它只會降級到默認的內置行為。而如果它是一個全新的HTML標籤,那它將會完全無法使用。

例如,我們想要增強一個HTML

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 component不在擴展更通用的HTMLElement,而是擴展HTMLButtonElement。當我們使用customElements.define()的時候還需要添加一個額外的參數 {extends: ‘button’}來表示我們的類擴展的是元素。這可能看起來有些多餘,因為我們已經表明了我們想要擴展的是HTMLElementButton,但是這是必要的,因為一些元素共享一個DOM接口。例如  和 

都共享 HTMLQuoteElement接口。

這個增強後的button可以通過is屬性來被使用

現在它將被我們的MyElement類增加,如果它加載在一個不支持自定義元素的瀏覽器中,它將降級到一個標準的按鈕,真正的漸進式增強。

注意,在擴展現有元素時,不能使用Shadow DOM。這只是一種擴展原生HTML元素的方法,它繼承了所有現有的屬性、方法和事件,並提供了額外的功能。當然可以在組件中修改元素的DOM和CSS,但是嘗試創建一個Shadow root將會拋出一個錯誤。

擴展內置元素的另一個好處就是,這些元素也可以應用於子元素被限制的情況。例如thead元素只允許tr作為其子元素,因此元素將呈現無效標記。這種情況下,我們可以拓展內置的tr元素。並像這樣使用它:



is="awesome-tr">

這種創建web components的方式帶來了巨大的漸進式增強,但是正如前面所提到,目前僅有Chrome和Firefox支持。Edge也將會支持,但不幸的是,目前Safari還沒有實現這一點。

測試web components

與為Angular和React這樣的框架編寫測試相比,測試web components既簡單又直接。不需要轉換或者複雜的設置,只需要創建元素,並將其添加到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 component通過es6模塊對外暴露。這意味著我們測試文件也需要作為一個ES6模塊加載到瀏覽器中農。這需要以下的index.html能夠在瀏覽器中運行測試。除了Mocha,這個設置還加載了WebcomponentsJS polyfill,Chai用於斷言,以及Sinon用於監聽和模擬。

tml>



charset="utf-8">
rel="stylesheet" href="../node_modules/mocha/mocha.css">

微信QQ空間新浪微博騰訊微博人人Twitter豆瓣百度貼吧
覺得不錯,分享給更多人看到
前端早讀課 熱門文章:

區塊鏈是什麼?

前端早讀課 最新文章

https://weiwenku.net/d/200512030