Web Components 是個什麼樣的東西

前端組件化這個主題相關的內容已經火了好久好久,angular 剛出來時的 Directive 到 angular2 的 components,還有 React 的 components 等等,無一不是前端組件化的一種實現和探索,可是提上議程的 Web Components 標準是個怎樣的東西,相關的一些框架或者類庫,如 React,Angular2,甚至是 x-tag,polymer 如今實現的組件化的東西和 Web Components 標準差異在哪裏?我花時間努力地把現有的 W3C Web Components 文檔看了下,而後堅強地寫下這些記錄。css

首先咱們須要知道,Web Components 包括了四個部分:html

這四部分有機地組合在一塊兒,纔是 Web Components。git

能夠用自定義的標籤來引入組件是前端組件化的基礎,在頁面引用 HTML 文件和 HTML 模板是用於支撐編寫組件視圖和組件資源管理,而 Shadow DOM 則是隔離組件間代碼的衝突和影響。github

下邊分別是每一部分的筆記內容。web

Custom Elements

概述

Custom Elements 顧名思義,是提供一種方式讓開發者能夠自定義 HTML 元素,包括特定的組成,樣式和行爲。支持 Web Components 標準的瀏覽器會提供一系列 API 給開發者用於建立自定義的元素,或者擴展示有元素。算法

這一項標準的草案還處於不穩定的狀態,時有更新,API 還會有所變化,下邊的筆記以 Cutsom Elements 2016.02.26 這個版本爲準,由於在最新的 chrome 瀏覽器已是能夠工做的了,這樣可使用 demo 來作嘗試,最後我會再簡單寫一下最新文檔和這個的區別。

registerElement

首先,咱們能夠嘗試在 chrome 控制檯輸入 HTMLInputElement,能夠看到是有這麼一個東西的,這個理解爲 input DOM 元素實例化時的構造函數,基礎的是 HTMLElement

Web Components 標準提出提供這麼一個接口:

document.registerElement('x-foo', {
  prototype: Object.create(HTMLElement.prototype, {
    createdCallback: {      
      value: function() { ... }
    },
    ...
  })
})

你可使用 document.registerElement 來註冊一個標籤,標準中爲了提供 namesapce 的支持,防止衝突,規定標籤類型(也能夠理解爲名字)須要使用 - 鏈接。同時,不能是如下這一些:

  • annotation-xml

  • color-profile

  • font-face

  • font-face-src

  • font-face-uri

  • font-face-format

  • font-face-name

  • missing-glyph

第二個參數是標籤相關的配置,主要是提供一個 prototype,這個原型對象是以 HTMLElement 等的原型爲基礎建立的對象。而後你即可以在 HTML 中去使用自定義的標籤。如:

<div>
  <x-foo></x-foo>
</div>

是否是嗅到了 React 的味道?好吧,React 說它本身主要不是作這個事情的。

生命週期和回調

在這個 API 的基礎上,Web Components 標準提供了一系列控制自定義元素的方法。咱們來一一看下:

一個自定義元素會經歷如下這些生命週期:

  • 註冊前建立

  • 註冊自定義元素定義

  • 在註冊後建立元素實例

  • 元素插入到 document 中

  • 元素從 document 中移除

  • 元素的屬性變化時

這個是很重要的內容,開發者能夠在註冊新的自定義元素時指定對應的生命週期回調來爲自定義元素添加各類自定義的行爲,這些生命週期回調包括了:

  • createdCallback
    自定義元素註冊後,在實例化以後會調用,一般多用於作元素的初始化,如插入子元素,綁定事件等。

  • attachedCallback
    元素插入到 document 時觸發。

  • detachedCallback
    元素從 document 中移除時觸發,可能會用於作相似 destroy 之類的事情。

  • attributeChangedCallback
    元素屬性變化時觸發,能夠用於從外到內的通訊。外部經過修改元素的屬性來讓內部獲取相關的數據而且執行對應的操做。

這個回調在不一樣狀況下有對應不一樣的參數:

  • 設置屬性時,參數列表是:屬性名稱,null,值,命名空間

  • 修改屬性時,參數列表是:屬性名稱,舊值,新值,命名空間

  • 刪除屬性時,參數列表是:屬性名稱,舊值,null,命名空間

好了,就上邊瞭解到的基礎上,假設咱們要建立一個自定義的 button-hello 按鈕,點擊時會 alert('hello world'),代碼以下:

document.registerElement('button-hello', {
  prototype: Object.create(HTMLButtonElement.prototype, {
    createdCallback: {
      value: function createdCallback() {
        this.innerHTML = '<button>hello world</button>'
        this.addEventListener('click', () => {
          alert('hello world')
        })
      }
    }
  })
})

要留意上述代碼執行以後才能使用 <button-hello></button-hello>

擴展原有元素

其實,若是咱們須要一個按鈕,徹底不須要從新自定義一個元素,Web Components 標準提供了一種擴展示有標籤的方式,把上邊的代碼調整一下:

document.registerElement('button-hello', {
  prototype: Object.create(HTMLButtonElement.prototype, {
    createdCallback: {
      value: function createdCallback() {
        this.addEventListener('click', () => {
          alert('hello world')
        })
      }
    }
  }),
  extends: 'button'
})

而後在 HTML 中要這麼使用:

<button is="button-hello">hello world</button>

使用 is 屬性來聲明一個擴展的類型,看起來也蠻酷的。生命週期和自定義元素標籤的保持一致。

當咱們須要多個標籤組合成新的元素時,咱們可使用自定義的元素標籤,可是若是隻是須要在原有的 HTML 標籤上進行擴展的話,使用 is 的這種元素擴展的方式就好。

原有的 createElementcreateElementNS,在 Web Components 標準中也擴展成爲支持元素擴展,例如要建立一個 button-hello

const hello = document.createElement('button', 'button-hello')

標準文檔中還有不少細節上的內容,例如接口的參數說明和要求,回調隊列的實現要求等,這些更可能是對於實現這個標準的瀏覽器開發者的要求,這裏不作詳細描述了,內容不少,有興趣的自行查閱:Cutsom Elements 2016.02.26

和最新版的區別

前邊我提到說文檔的更新變化很快,截止至我寫這個文章的時候,最新的文檔是這個:Custom Elements 2016.07.21

細節不作描述了,講講我看到的最大變化,就是向 ES6 靠攏。大體有下邊三點:

  • 從本來的擴展 prototype 來定義元素調整爲建議使用 class extends 的方式

  • 註冊自定義元素接口調整,更加方便使用,傳入 type 和 class 便可

  • 生命週期回調調整,createdCallback 直接用 class 的 constructor

前兩個點,咱們直接看下代碼,本來的代碼按照新的標準,應該調整爲:

class ButtonHelloElement extends HTMLButtonElement {
  constructor() {
    super()

    this.addEventListener('click', () => {
      alert('hello world')
    })
  }
}

customElements.define('button-hello', ButtonHelloElement, { extends: 'button' })

從代碼上看會感受更加 OO,編寫上也比本來要顯得方便一些,本來的生命週期回調是調整爲新的:

  • constructor in class 做用至關於本來的 createdCallback

  • connectedCallback 做用至關於 attachedCallback

  • disconnectedCallback 做用至關於 detachedCallback

  • adoptedCallback 使用 document.adoptNode(node) 時觸發

  • attributeChangedCallback 和本來保持一致

connect 事件和插入元素到 document 有些許區別,主要就是插入元素到 document 時,元素狀態會變成 connected,這時會觸發 connectedCallback,disconnect 亦是如此。

HTML Imports

概述

HTML Imports 是一種在 HTMLs 中引用以及複用其餘的 HTML 文檔的方式。這個 Import 很漂亮,能夠簡單理解爲咱們常見的模板中的 include 之類的做用。

咱們最多見的引入一個 css 文件的方式是:

<link rel="stylesheet" href="/css/master.css">

Web Components 如今提供多了一個這個:

<link rel="import" href="/components/header.html">

HTMLLinkElement

本來的 link 標籤在添加了 HTML Import 以後,多了一個只讀的 import 屬性,當出現下邊兩種狀況時,這個屬性爲 null

  • link 不是用來 import 一個 HTML 的。

  • link 元素不在 document 中。

不然,這個屬性會返回一個表示引入的 HTML 文件的文檔對象,相似於 document。好比說,在上邊的代碼基礎上,能夠這樣作:

const link = document.querySelector('link[rel=import]')
const header = link.import;

const pulse = header.querySelector('div.logo');

阻塞式

咱們要知道的是,默認的 link 加載是阻塞式的,除非你給他添加一個 async 標識。

阻塞式從某種程度上講是有必要的,當你 improt 的是一個完整的自定義組件而且須要在主 HTML 中用標籤直接使用時,非阻塞的就會出現錯誤了,由於標籤尚未被註冊。

document

有一點值得留意的是,在 import 的 HTML 中,咱們編寫的 script 裏邊的 document 是指向 import 這個 HTML 的主 HTML 的 document。

若是咱們要獲取 import 的 HTML 的 document 的話,得這麼來:

const d = document.currentScript.ownerDocument

這樣設計是由於 import 進來的 HTML 須要用到主 HTML 的 document。例如咱們上邊提到的 registerElement

在一個被 import 的 HTML 文件中使用下邊三個方法會拋出一個 InvalidStateError 異常:

  • document.open()

  • document.write()

  • document.close()

對於 HTML Import,標準文檔中還有很大一部份內容是關於多個依賴加載的處理算法的,在這裏就不詳述了,有機會的話找時間再開篇談,這些內容是須要瀏覽器去實現的。

HTML Templates

概述

這個東西很簡單,用過 handlebars 的人都知道有這麼一個東西:

<script id="template" type="text/x-handlebars-template">
  ...
</script>

其餘模板引擎也有相似的東西,那麼 HTML Templates 即是把這個東西官方標準化,提供了一個 template 標籤來存放之後須要可是暫時不渲染的 HTML 代碼。

之後能夠這麼寫了:

<template id="template">
  ...
</template>

接口和應用

template 元素有一個只讀的屬性 content,用於返回這個 template 裏邊的內容,返回的結果是一個 DocumentFragment

具體是如何使用的,直接參考官方給出的例子:

<!doctype html>
<html lang="en">
  <head>
    <title>Homework</title>
  <body>
    <template id="template"><p>Smile!</p></template>
    <script>
      let num = 3;
      const fragment = document.getElementById('template').content.cloneNode(true);
      while (num-- > 1) {
        fragment.firstChild.before(fragment.firstChild.cloneNode(true));
        fragment.firstChild.textContent += fragment.lastChild.textContent;
      }
      document.body.appendChild(fragment);
    </script>
</html>

使用 DocumentFragment 的 clone 方法以 template 裏的代碼爲基礎建立一個元素節點,而後你即可以操做這個元素節點,最後在須要的時候插入到 document 中特定位置即可以了。

Template 相關的東西很少,並且它如今已是歸入生效的 標準文檔 中了。

咱們接下來看看重磅的 Shadow DOM。

Shadow DOM

概述

Shadow DOM 好像提出很久了,最本質的需求是須要一個隔離組件代碼做用域的東西,例如我組件代碼的 CSS 不能影響其餘組件之類的,而 iframe 又過重而且可能有各類奇怪問題。

能夠這麼說,Shadow DOM 旨在提供一種更好地組織頁面元素的方式,來爲日趨複雜的頁面應用提供強大支持,避免代碼間的相互影響。

看下在 chrome 它會是咋樣的:

Shadow DOM in chrome

咱們能夠經過 createShadowRoot() 來給一個元素節點建立 Shadow Root,這些元素類型必須是下邊列表的其中一個,不然會拋出 NotSupportedError 異常。

  • 自定義的元素

  • article

  • aside

  • blockquote

  • body

  • div

  • header, footer

  • h1, h2, h3, h4, h5, h6

  • nav

  • p

  • section

  • span

createShadowRoot() 是如今 chrome 實現的 API,來自文檔:https://www.w3.org/TR/2014/WD...。最新的文檔 API 已經調整爲 attachShadow()

返回的 Shadow Root 對象從 DocumentFragment 繼承而來,因此可使用相關的一些方法,例如 shadowRoot.getElementById('id') 來獲取 Shadow DOM 裏邊的元素。

簡單的使用以下:

const div = document.getElementById('id')
const shadowRoot = div.createShadowRoot()
const span = document.createElement('span')

span.textContent = 'hello world'
shadowRoot.appendChild(span)

在這裏,我把這個 div 成爲是這個 Shadow DOM 的 宿主元素,下邊的內容會延續使用這個稱呼。

Shadow DOM 自己就爲了代碼隔離而生,因此在 document 上使用 query 時,是無法獲取到 Shadow DOM 裏邊的元素的,須要在 Shadow Root 上作 query 才行。

在這裏附上一個文檔,裏邊有詳細的關於新的標準和如今 blink 引擎實現的 Shadow DOM 的區別,官方上稱之爲 v0 和 v1:Shadow DOM v1 in Blink

API

Shadow Root 除了從 DocumentFragment 繼承而來的屬性和方法外,還多了另外兩個屬性:

  • host 只讀屬性,用來獲取這個 Shadow Root 所屬的元素

  • innerHTML 用來獲取或者設置裏邊的 HTML 字符串,和咱們經常使用的 element.innerHTML 是同樣的

另外,在最新的標準文檔中,元素除了上邊提到的 attachShadow 方法以外,還多了三個屬性:

  • assignedSlot 只讀,這個元素若是被分配到了某個 Shadow DOM 裏邊的 slot,那麼會返回這個對應的 slot 元素

  • slot 元素的 slot 屬性,用來指定 slot 的名稱

  • shadowRoot 只讀,元素下面對應的 Shadow Root 對象

slot 是什麼?接着看下邊的內容,看完下一節的最後一部分就會明白上述內容和 slot 相關的兩個 API 有什麼做用。

slot

slot 提供了在使用自定義標籤的時候能夠傳遞子模板給到內部使用的能力,能夠簡單看下 Vue 的一個例子。

咱們先來看下如今 chrome 能夠跑的 v0 版本,這一個版本是提供了一個 content 標籤,表明了一個佔位符,而且有一個 select 屬性用來指定使用哪些子元素。

<!-- component input-toggle template -->
<input type="checkbox"></input>
<content select=".span"></content>

自定義的元素裏邊的子元素代碼是這樣的:

<input-toggle name="hello">
  <span>hello</span>
  <span class="span">test</span>
</input-toggle>

那麼展示的結果會和下邊的代碼是同樣的:

<input-toggle name="hello">
  <input type="checkbox"></input>
  <span class="span">test</span>
</input-toggle>

這裏只是說展示結果,實際上,input-toggle 裏邊應該是建立了一個 Shadow DOM,而後 content 標籤引用了目標的 span 內容,在 chrome 看是這樣的:

content tag in chrome

而後,是最新標準中的 slot 使用方式,直接上例子代碼:

<!-- component input-toggle template -->
<input type="checkbox"></input>
<slot name="text"></slot>

在自定義的元素標籤是這麼使用 slot 的:

<input-toggle name="hello">
  <input type="checkbox"></input>
  <span class="span" slot="text">test</span>
</input-toggle>

經過 slot="text" 的屬性來讓元素內部的 slot 佔位符能夠引用到這個元素,多個元素使用這個屬性也是能夠的。這樣子咱們便擁有了使用標籤是從外部傳 template 給到自定義元素的內部去使用,並且具有指定放在那裏的能力。

CSS 相關

由於有 Shadow DOM 的存在,因此在 CSS 上又添加了不少相關的東西,其中一部分仍是屬於討論中的草案,命名之類的可能會有變動,下邊說起的內容主要來自文檔:Shadow DOM in CSS scoping 1,不少部分在 chrome 是已經實現的了,有興趣能夠寫 demo 試試。

由於 Shadow DOM 很大程度上是爲了隔離樣式做用域而誕生的,主文檔中的樣式規則不對 Shadow DOM 裏的子文檔生效,子文檔中的樣式規則也不影響外部文檔。

但不可避免的,在某些場景下,咱們須要外部能夠控制 Shadow DOM 中樣式,如提供一個組件給你,有時候你會但願能夠自定義它內部的一些樣式,同時,Shadow DOM 中的代碼有時候可能須要可以控制其所屬元素的樣式,甚至,組件內部能夠定義上邊提到的經過 slot 傳遞進來的 HTML 的樣式。因此呢,是的,CSS 選擇器中添加了幾個僞類,咱們一一來看下它們有什麼做用。

在閱讀下邊描述的時候,請留意一下選擇器的代碼是在什麼位置的,Shadow DOM 內部仍是外部。

:host 用於在 Shadow DOM 內部選擇到其宿主元素,當它不是在 Shadow DOM 中使用時,便匹配不到任意元素。

在 Shadow DOM 中的 * 選擇器是沒法選擇到其宿主元素的。

:host( <selector> ) 括號中是一個選擇器,這個能夠理解爲是一個用於兼容在主文檔和 Shadow DOM 中使用的方法,當這個選擇器在 Shadow DOM 中時,會匹配到括號中選擇器對應的宿主元素,若是不是,則匹配括號中選擇器可以匹配到的元素。

文檔中提供了一個例子:

<x-foo class="foo">
  <"shadow tree">
    <div class="foo">...</div>
  </>
</x-foo>

在這個 shadow tree 內部的樣式代碼中,會有這樣的結果:

  • :host 匹配 <x-foo> 元素

  • x-foo 匹配不到元素

  • .foo 只匹配到 <div> 元素

  • .foo:host 匹配不到元素

  • :host(.foo) 匹配 <x-foo> 元素

:host-context( <selector> ),用於在 Shadow DOM 中來檢測宿主元素的父級元素,若是宿主元素或者其祖先元素可以被括號中的選擇器匹配到的話,那麼這個僞類選擇器便匹配到這個 Shadow DOM 的宿主元素。我的理解是用於在宿主元素外部元素知足必定的條件時添加樣式。

::shadow 這個僞類用於在 Shadow DOM 外部匹配其內部的元素,而 /deep/ 這個標識也有一樣的做用,咱們來看一個例子:

<x-foo>
   <"shadow tree">
     <div>
       <span id="not-top">...</span>
     </div>
     <span id="top">...</span>
   </>
 </x-foo>

對於上述這一段代碼的 HTML 結構,在 Shadow DOM 外部的樣式代碼中,會是這樣的:

  • x-foo::shadow > span 能夠匹配到 #top 元素

  • #top 匹配不到元素

  • x-foo /deep/ span 能夠匹配到 #not-top#top 元素

/deep/ 這個標識的做用和咱們的 > 選擇器有點相似,只不過它是匹配其對應的 Shadow DOM 內部的,這個標識可能還會變化,例如改爲 >> 或者 >>> 之類的,我的感受, >> 會更舒服。

最後一個,用於在 Shadow DOM 內部調整 slot 的樣式,在我查閱的這個文檔中,暫時是以 chrome 實現的爲準,使用 ::content 僞類,不排除有更新爲 ::slot 的可能性。咱們看一個例子來了解一下,就算名稱調整了也是差很少的用法:

<x-foo>
  <div id="one" class="foo">...</div>
  <div id="two">...</div>
  <div id="three" class="foo">
    <div id="four">...</div>
  </div>
  <"shadow tree">
    <div id="five">...</div>
    <div id="six">...</div>
    <content select=".foo"></content>
  </"shadow tree">
</x-foo>

在 Shadow DOM 內部的樣式代碼中,::content div 能夠匹配到 #one#three#four,留意一下 #two 爲何沒被匹配到,由於它沒有被 content 元素選中,即不會進行引用。若是更換成 slot 的 name 引用的方式亦是同理。

層疊規則,按照這個文檔的說法,對於兩個優先級別同樣的 CSS 聲明,沒有帶 !important 的,在 Shadow DOM 外部聲明的優先級高於在 Shadow DOM 內部的,而帶有 !important 的,則相反。我的認爲,這是提供給外部必定的控制能力,同時讓內部能夠限制必定的影響範圍。

繼承方面相對簡單,在 Shadow DOM 內部的頂級元素樣式從宿主元素繼承而來。

至此,Web Components 四個部分介紹結束了,其中有一些細節,瀏覽器實現細節,還有使用上的部分細節,是沒有說起的,由於詳細記錄的話,還會有不少東西,內容不少。當使用過程當中有疑問時能夠再次查閱標準文檔,有機會的話會再完善這個文章。下一部分會把這四個內容組合起來,總體看下 Web Components 是怎麼使用的。

Web Components

Web Components 總的來講是提供一整套完善的封裝機制來把 Web 組件化這個東西標準化,每一個框架實現的組件都統一標準地進行輸入輸出,這樣能夠更好推進組件的複用。結合上邊各個部分的內容,咱們整合一塊兒來看下應該怎麼使用這個標準來實現咱們的組件:

<!-- components/header.html -->
<template id="">
<style>
::content li {
  display: inline-block;
  padding: 20px 10px;
}
</style>
<content select="ul"></content>
</template>
<script>
(function() {
  const element = Object.create(HTMLInputElement.prototype)
  const template = document.currentScript.ownerDocument.querySelector('template')

  element.createdCallback = function() {
    const shadowRoot = this.createShadowRoot()
    const clone = document.importNode(template.content, true)
    shadowRoot.appendChild(clone)

    this.addEventListener('click', function(event) {
      console.log(event.target.textContent)
    })
  }

  document.registerElement('test-header', { prototype: element })
})()
</script>

這是一個簡單的組件的例子,用於定義一個 test-header,而且給傳遞進來的子元素 li 添加了一些組件內部的樣式,同時給組件綁定了一個點擊事件,來打印點擊目標的文本內容。

看下如何在一個 HTML 文件中引入而且使用一個組件:

<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title></title>

    <link rel="import" href="components/header.html">
  </head>
  <body>
    <test-header>
      <ul>
        <li>Home</li>
        <li>About</li>
      </ul>
    </test-header>
  </body>
</html>

一個 import<link> 把組件的 HTML 文件引用進來,這樣會執行組件中的腳本,來註冊一個 test-header 元素,這樣子咱們即可以在主文檔中使用這個元素的標籤。

上邊的例子是能夠在 chrome 正常運行的。

因此,根據上邊簡單的例子能夠看出,各個部分的內容是有機結合在一塊兒,Custom Elements 提供了自定義元素和標籤的能力,template 提供組件模板,import 提供了在 HTML 中合理引入組件的方式,而 Shadow DOM 則處理組件間代碼隔離的問題。

不得不認可,Web Components 標準的提出解決了一些問題,必須交由瀏覽器去處理的是 Shadow DOM,在沒有 Shadow DOM 的瀏覽器上實現代碼隔離的方式多多少少有缺陷。我的我以爲組件化的各個 API 不夠簡潔易用,依舊有 getElementById 這些的味道,可是交由各個類庫去簡化也能夠接受,而 import 功能上沒問題,可是加載多個組件時性能問題仍是值得商榷,標準可能須要在這個方面提供更多給瀏覽器的指引,例如是否有可能提供一種單一請求加載多個組件 HTML 的方式等。

在如今的移動化趨勢中,Web Components 不只僅是 Web 端的問題,愈來愈多的開發者指望以 Web 的方式去實現移動應用,而多端複用的實現漸漸是以組件的形式鋪開,例如 React NativeWeex。因此 Web Components 的標準可能會影響到多端開發 Web 化的一個模式和發展。

最後,再囉嗦一句,Web Components 我的以爲仍是將來發展趨勢,因此纔有了這個文章。

相關文章
相關標籤/搜索