Web 組件勢必取代前端?

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

 

以下爲譯文:

還記得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></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屬性:

 

 

<input name="name">

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

 

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

 

<input name="name" disabled>

 

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

 

 

影子DOM

 

 

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

 

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

 

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

 

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

 

定義影子root的方式如下:

 

 

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `<p>Hello world</p>`;

 

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

 

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

 

創建影子root之後,就可以在上面使用所有DOM的方法,就像平常處理document對象那樣,如使用this.shadowRoot.querySelector來查找元素。組件的所有CSS都可以定義在<style>標籤中,但也可以通過通常的<link rel="stylesheet">來讀取外部樣式表。除了一般的CSS之外,還可以使用:host選擇器給組件自己定義樣式。例如,自定義元素默認使用display: inline,使用下面的CSS可以將其定義爲塊元素:

 

 

:host {
  display: block;
}

 

這還可以實現上下文樣式。例如,如果想在組件定義了disabled屬性時灰掉,可以這樣做:

 

 

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

 

默認情況下,自定義元素會從周圍的CSS繼承一些屬性,如color、font等。但是如果你希望從全新的狀態開始,使組件的所有CSS屬性重置到默認值,可以這樣做:

 

 

:host {
  all: initial;
}

 

有一點很重要:外部定義在組件上的樣式的優先級要高於在影子DOM中使用:host定義的樣式。因此,如果定義了:

 

 

my-element {
  display: inline-block;
}

 

它將會覆蓋:

 

 

:host {
  display: block;
}

 

外部不可能給自定義元素內部的任何元素定義樣式。但如果希望使用者能夠給組件(中的部分元素)定義樣式,那麼可以通過暴露CSS變量來實現。例如,如果希望使用者能選擇組件的背景顏色,那麼可以暴露名爲--background-color的CSS變量。

 

假設組件的影子DOM的根節點的元素爲<div id="container">:

 

 

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

 

那麼,組件的使用者可以從外部定義其背景色:

 

 

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

 

組件內部應該爲其定義默認值,以備使用者不定義背景色的情況:

 

 

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

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

 

當然,CSS變量的名字可以任意選擇,唯一的要求是必須以“--”開始。

 

通過對CSS和HTML範圍(scope)的支持,影子DOM解決了CSS的全局性帶來的問題——會導致巨大的、只能添加的樣式表,其中的選擇器的規則越來越具體,充滿了各種覆蓋。影子DOM使得開發者可以將標記語言和樣式打包到組件內部,而不需要任何工具或命名規則。這樣就不用擔心新的class或id會與已有的衝突。

 

除了能夠通過CSS變量給Web組件內部設置樣式之外,還可以給Web組件注入HTML。

 

通過slot進行組合

 

組合就是將影子DOM樹與使用者提供的標記語言組合在一起。<slot>元素可以實現這一過程,可以認爲它是影子DOM中的一個佔位符,使用者提供的標記語言將在此處渲染。使用者提供的標記語言稱爲“輕量DOM”(light DOM)。組合過程將輕量DOM和影子DOM結合在一起,形成新的DOM樹。

 

例如,你可以創建一個<image-gallery>組件,使用該組件時,提供兩個標準的<img>標籤供組件渲染用:

 

 

<image-gallery>
  <img src="foo.jpg" slot="image">
  <img src="bar.jpg" slot="image">
</image-gallery>

 

該組件將接受兩個圖像,並在組件的影子DOM內部渲染。注意圖像上的slot="image"屬性。該屬性告訴組件圖像在影子DOM中渲染的位置。影子DOM的樣子可能如下:

 

 

<div id="container">
  <div class="images">
    <slot name="image"></slot>
  </div>
</div>

 

當輕量DOM中的元素被分配到元素的影子DOM中後,得到的DOM樹如下所示:

 

 

<div id="container">
  <div class="images">
    <slot name="image">
      <img src="foo.jpg" slot="image">
      <img src="bar.jpg" slot="image">
    </slot>
  </div>
</div>

 

可見,用戶提供的帶有slot屬性的元素將被渲染到slot元素內部,slot元素的name屬性值必須匹配相應的slot屬性的值。

 

<select>元素就使用了這種方式,你可以在Chrome的開發者工具中查看(如果你勾選了“顯示用戶代理的影子DOM”選項,如上文所示):

 

 

 

它接受用戶提供的<option>元素,將其渲染成下拉菜單。

 

帶有name屬性的slot元素稱爲命名slot,但該屬性並不是必須的。name屬性只是用來將內容渲染到特定的位置。如果一個或多個slot沒有name屬性,內容將會按照使用者提供的順序進行渲染。如果使用者提供的內容少於slot的個數,slot還可以提供默認內容。

 

假設<image-gallery>的影子DOM如下所示:

 

 

<div id="container">
  <div class="images">
    <slot></slot>
    <slot></slot>
    <slot>
      <strong>No image here!</strong> <-- fallback content -->
    </slot>
  </div>
</div>

 

提供上文中的兩個圖像時,產生的DOM樹如下:

 

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

 

影子DOM內部通過slot渲染的元素稱爲分配結點。這些結點的樣式會在渲染到組件內部的影子DOM(即“分配”)後依然有效。在影子DOM內部,分配結點還可以通過::slotted()選擇器獲得額外的樣式:

 

 

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

 

::slotted()可以接受任何有效的CSS選擇器,但只能選擇頂層結點。例如,::slot(section img)在這種情況下無法使用:

 

 

<image-gallery>
  <section slot="image">
    <img src="foo.jpg">
  </section>
</image-gallery>

 

用JavaScript處理slot

 

JavaScript也可以處理slot,可以查看某個slot被分配了什麼結點,查看某個元素被分配到了哪個slot,還可以使用slotchange事件。

 

調用slot.assignedNodes()可以訪問slot分配到的結點。如果想獲取任何默認內容,可以調用slot.assignedNodes({flatten: true})。

 

查看element被分配到的slot,可以訪問element.assignedSlot。

 

每當slot內部的結點發生變化(結點被添加或刪除)時會產生slotChange事件。注意該事件僅在slot結點本身上觸發,而不會在slot結點的子元素上觸發。

 

 

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

 

Chrome會在元素首次初始化時觸發slotchange事件,而Safari和Firefox在此情況下不會。

 

影子DOM中的事件

 

自定義元素產生的標準事件(如鼠標和鍵盤事件等)默認情況下會從影子DOM中冒泡出來。如果事件從影子DOM內部的結點產生,那麼它的目標會被重新設置,使之看起來像是從自定義元素本身產生的。如果想知道事件到底產生於影子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));

 

但是,任何影子DOM內部的結點拋出的事件則不會冒泡到影子DOM外面,除非它是使用composed: 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
    }));
  }
}

 

template元素

 

除了使用this.shadowRoot.innerHTML給影子root中的元素添加HTML之外,還可以使用<template>來實現這一點。模板用來提供一小段代碼供以後使用。模板中的代碼不會被渲染,初始化時它的內容會被解析,但僅僅用來保證其內容是正確的。模板內部的JavaScript不會被執行,任何外部資源也不會被獲取。默認情況下它是隱藏的。

 

如果Web組件需要根據不同的情況渲染完全不同的標記,那麼可以使用不同的模板來實現這一點:

 

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放到了影子root內。一開始時兩個模板都是隱藏的,只有容器被渲染。在connectedCallback內我們調用this.shadowRoot.querySelector('#view1').content.cloneNode(true)獲取了#view1的內容。模板的content屬性返回的模板內容爲DocumentFragment實例,該實例可以通過appendChild添加到另一個元素中。由於appendChild在元素已存在於DOM中的情況下會移動元素,所以我們首先需要使用cloneNode(true)來複制它。否則,模板的內容將會被移動而不會被添加,意味着我們只能使用其內容一次。

 

模板在需要快速改變一大片HTML或重用HTML的情況下非常有用。模板也不限於Web組件,可以用在DOM中的任何地方。

 

擴展原生元素

 

到目前爲止,我們一直在擴展HTMLElement來創建全新的HTML元素。自定義元素還可以用來擴展內置的原生元素,從而實現對圖像、按鈕等已有HTML元素的增強。在撰寫本文時,該功能僅Chrome和Firefox支持。

 

擴展已有HTML元素的好處是,它能繼承所有的屬性和方法。這樣就可以漸進式增強已有元素,因此即使瀏覽器不支持自定義元素,該元素也是可用的,它只不過是採用默認的內置行爲。而如果撰寫全新的HTML標記,在不支持自定義元素的瀏覽器中就完全無法使用了。

 

舉個例子,假設我們要增強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組件沒有擴展更通用的HTMLElement,而是擴展了HTMLButtonElement。現在調用customElements.define時還帶了另一個參數{extends: 'button'},來指明我們的類擴展了<button>元素。這看起來有點多餘,因爲我們已經指明過要擴展HTMLButtonElement了,但這是必要的,因爲有可能有其他元素使用了同一個DOM接口。例如,<q>和<blockquote>都使用同一個HTMLQuoteElement接口。

 

增強後的按鈕可以使用is屬性了:

 

<button is="my-button">

 

該按鈕被我們的MyElement類增強。如果它加載到不支持自定義元素的瀏覽器中,它就會變成普通的按鈕。這是真正的漸進式增強!

 

注意,在擴展已有元素時不能使用影子DOM。這僅僅是通過繼承所有屬性、方法和事件並提供額外的功能來擴展原生HTML的方法。當然,在組件內部修改元素的DOM和CSS是可能的,但試圖創建影子root則會拋出錯誤。

 

擴展內置元素的另一個好處就是,它可以用於元素限制父子關係的情況。例如,<thead>元素僅允許<tr>元素作爲子結點,那麼使用<awesome-tr>元素將被視爲非法標記。這種情況下我們可以擴展內置的<tr>元素,並這樣使用:

 

 

<table>
  <thead>
    <tr is="awesome-tr"></tr>
  </thead>
</table>

 

這樣使用Web組件可以帶來非常好的漸進式增強,但正如前面所說,目前只有Chrome和Firefox支持。Edge將來也會支持,但在本文撰寫之時,Safari並不支持。

 

測試Web組件

 

測試Web組件非常容易、直接,與Angular、React等框架相比,測試Web組件簡直是小菜一碟。不需要任何編譯,也不需要複雜的設置。只需創建元素,添加到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組件暴露爲ES6模塊。這就是說,測試文件也需要作爲ES6組件加載到瀏覽器中。因此,需要在瀏覽器中使用下面的html文件來運行測試。除了Mocha之外,我們還加載了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使用BDD接口。接下來加載測試文件(本例中只有一個),然後調用mocha.run()運行測試。

 

注意,在使用ES6模塊時,還需要將mocha.run()放在帶有type="module"的script內。這是因爲默認情況下ES6模塊是被延遲加載的,如果mocha.run()在正常的script標籤內,它將在my-element.test.js加載之前被執行。

 

在舊的瀏覽器中使用Polyfill

 

目前,最新版本的Chrome、Firefox、Safari和Opera桌面版都支持自定義元素,Edge 19也將支持。iOS和Android上的Safari、Chrome和Firefox也支持。

 

對於舊的瀏覽器,可以使用WebcomponentsJS這個polyfill:

 

npm install --save @webcomponents/webcomponentsjs

 

可以使用webcomponents-loader.js,該文件會進行功能檢測,只有在必要時纔會加載polyfill。使用polyfill就可以使用自定義元素,而不需要改動源代碼。但是,它並不能提供真正的CSS範圍,意味着如果不同的Web組件中的元素擁有同樣的class名和id,它們將會衝突。而且,影子DOM的CSS選擇器:host()和:slotted()可能無法正確工作。

 

想要讓這兩個選擇器正確工作,你需要加載Shady CSS polyfill,還需要(少量)修改源代碼。我個人不喜歡這一點,所以我寫了個Webpack加載器來幫你實現這一點。這意味着你需要編譯代碼,但不再需要修改源代碼了。

 

Webpack加載器完成三項工作:它給Web組件的影子DOM中的所有不是以::host或::slotted開頭的CSS規則添加前綴,前綴爲元素的名稱,從而提供正確的範圍。之後,它會解析所有::host和::slotted規則,保證它們正確工作。

 

示例#1:lazy-img

 

我創建了一個Web組件,可以懶加載圖像,只有圖像完全出現在瀏覽器的窗口中時才進行加載。代碼在Github(https://github.com/DannyMoerkerke/lazy-img)上。

 

組件的正式版本是將原生的<img>標籤包裹在<lazy-img>自定義元素內:

 

 

<lazy-img
  src="path/to/image.jpg"
  width="480"
  height="320"
  delay="500"
  margin="0px"></lazy-img>

 

代碼倉庫還有個extend-native分支,它利用is屬性擴展原生的<img>爲lazy-img:

 

 

<img
  is="lazy-img"
  src="path/to/img.jpg"
  width="480"
  height="320"
  delay="500"
  margin="0px">

 

這是個用來演示原生Web組件的非常好的例子:只需要導入JavaScript文件,添加HTML標籤或利用is擴展已有的原生標籤就可以了!

 

示例#2:material-webcomponents

 

我利用自定義元素實現了Google的Material Design,代碼也在Github(https://dannymoerkerke.github.io/material-webcomponents)上。

 

該庫也演示了CSS自定義屬性(https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties)的強大功能。

那麼,我還應該使用框架嗎?

 

還是那句話,需要視情況而定。

 

當前的前端框架通過數據綁定、狀態管理和非常標準化的代碼帶來了很多額外的價值。問題就是你的應用程序是否需要這些。

 

如果你不清楚應用程序是否真的需要Redux等狀態管理,那麼很大可能你並不需要。需要時你肯定會感受到。

 

數據綁定也許會給你帶來好處,但Web組件已經支持直接將屬性設置爲數組、對象等非簡單值了。簡單值可以通過HTML屬性(attribute)來設置,屬性的改變可以通過atributeChangedCallback來監視。

 

儘管這一流程完全有道理,但會讓更新一小部分DOM的操作變得很麻煩,而React和Angular的描述性方式更容易。這些框架可以定義一個包含表達式的視圖,在表達式發生變化時進行更新。

 

原生的Web組件(還)不提供這樣的功能,儘管已經有提案建議擴展<template>元素以支持使用數據進行初始化和更新:

 

<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(https://lit-html.polymer-project.org/)。

 

前端框架的另一個經常被提及的好處就是,它們提供了標準的代碼,團隊中的每個新成員都能從一開始就很熟悉。我相信這是正確的做法,但我也認爲這個好處非常有限。

 

我曾在多個項目中使用過Angular、React和Polymer,儘管它們之間有相似性,但即使是使用同一個框架,代碼結構也會大相徑庭。一個清晰的工作方式和樣式指南,爲代碼提供的一致性遠遠好於僅依賴框架。框架也會帶來額外的複雜性,所以應該問問自己這樣做是否值得。

 

現在,Web組件已經得到了廣泛的支持,你也許可以看出,原生代碼可以提供與框架媲美的功能,但性能更好,代碼量更小,複雜度更低。

 

原生Web組件的優勢很明顯:

 

  • 原生,不需要框架

  • 很容易集成,不需要編譯

  • 真正的CSS範圍

  • 標準化,僅使用HTML、CSS和JavaScript

 

jQuery及其優異的遺產依然會繼續存在一段時間,但現在很少有新項目再使用它們,因爲我們有了更好的選擇。我並不認爲現在的框架會很快消失,但作爲更好的選擇,原生Web組件已經出現,而且迅速得到了關注。我認爲,這些前端框架的角色也會改變,它們會在原生Web組件的基礎上提供一個簡單的層。

 

我對於原生Web組件的未來非常樂觀,而且我還會繼續發表有關這方面技術的文章。如果你利用Web組件做了有意思的東西,請在下方留言告訴我們!

 

原文:https://www.dannymoerkerke.com/blog/web-components-will-replace-your-frontend-framework,本文由CSDN翻譯,轉載請註明來源出處。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章