Web Component

最近覺得可以開始玩 Web Component 了,然後就開始研究,本來以為應該會是個簡單的東西,結果意外的有一些細節還蠻複雜,有些設計目前也不知道為什麼,不過還是從基本的來開始整理吧。

Web Component 其實最早只有 Shaodw DOM 的概念,不過現在已經變成好幾個標準合在一起來構成 component 了,這些標準中,最主要的三個分別是:

這三者分別都是構成 Web Component 很重要的元素,不過也分別規範了一些不是單純只能應用在 Web Component 上的功能,這篇文章不一定會說到這些特色,一切都看緣份吧~

Shadow DOM

我所知當中這是最早被提出的部分,這份文件的目的在提出一套標準來在文件樹上把一些 DOM 節點隱藏起來,有這個需求的原因是因為現在越來越多的自訂 UI 元件,為了達成效果可能用了很多的<div><span>等元素,但是在做 DOM Traversal 或是 Inspect 時,這些元素的出現其實很多餘,而且看起來會很花,如果是開發要除錯看到大量不相干的東西實在是很干擾,而最重要的其實是,如果要把能讓這些自訂元件模組化再利用,那應該也要能把東西封裝起來。

Shadow DOM 最大的功用就是在 DOM Tree 上能把子文件樹封裝起來的機制,使用很簡單,就在你想要放隱藏元件結構的地方,執行createShadowRoot(),然後把東西塞進去就可以了:

var host = document.querySelector('.custom-component');
var root = host.createShadowRoot();
host.innerHTML = html_template_string;

目前應該還只有 Chrome 支援,而且要先去about:flags裡面把「Experimental Web Platform features」打開,然後標準還沒定案,所以其實現在要執行webkitCreateShadowRoot才會動。這個 function 回傳的東西稱為 shadow root,它下面的文件樹就稱為 shadow tree,至於host那個變數所指的 DOM node 則是 shadow host

簡單說的 Shadow DOM 就這樣講完了,不過事情當然不會這麼簡單,還有幾個問題要解決,首先第一個是 style,要給 shadow tree 內的元素加上樣式其實就把 style 標籤也插進去就可以了:

var html_template_string = 
    '<style>div { color: red; }</style><div>Click me!</div>';

預設這種在 shadow tree 裡面的 style 都是 scoped 的,是從 host 節點開始算,所以包括 host 節點本身也會受到影響,而在這邊,一個新的 CSS at-rule@host就誕生了,是專門從 shadow tree 裡面來對外面的 host 節點的樣式做調整用的:

@host {
    .custom-component {
        display: inline-block;
    }
}

第二個問題則是,如果我要做的 custom element 是下拉式選單時,我的選項哪裡來呢?當然我可以直接把東西用 JS 寫到 shadow tree 裡面,但是如果是用以前的<select>的話,是用<select>下的子節點作為選項的來源。:

<select>
    <option value="1"> 1
    <option value="2"> 2
    <option value="3"> 3
</select>

如果要用 shadow DOM 做到類似的效果,把複雜的 UI 呈現用結構藏起來,只讓單純的選項出現在文件樹內,也是可以辦到的,不過比較複雜,這個特性叫 distribution,配合一個新的標籤<content>和 CSS selector 來把 shadow host 的子元素移動到 shadow tree 內。

舉例來說,下面是我的 shadow host 節點:

<div class="x-header">
    <h1>Site Name</h1>
    <h2>Section 1</h2>
    <h2>Section 2</h2>
    <h2>Section 3</h2>
</div>

除了他自己外還有一個大標題,三個小標題。然後我希望丟進 shadow root 的結構如下:

<header>
   <img src="logo.png" />
   <h1>Site Name</h1>
</header>
<nav>
    <div class="wrapper">
        <h2>Section 1</h2>
        <h2>Section 2</h2>
        <h2>Section 3</h2>
    </div>
</nav>

那麼我可以用<content>來辦到:

<header>
   <img src="logo.png" />
   <content select="h1">
</header>
<nav>
    <div class="wrapper">
        <content select="h2">
    </div>
</nav>

select屬性的內容是 CSS selector,可以把符合該 selector 的 shadow host 子節點抓出來放在<content>的位置。如果不給任何 selector 的話,就會把全部的東西都抓過去,像下拉選單就可以用這種方式來把選項填入 shadow tree。

不過這邊要注意,distributed 的 DOM node 吃的樣式是外面的文件的樣式,shadow tree 裡面的 style 不會套用到他們上面。

第三個問題是,用 JS 字串或是動態產生 DOM 節點的方式來作 shadow DOM 的內容實在很多問題,所以 HTML 還多了個 Templates 擴充,就是多了一個眾望所歸的<template>標籤,內容會 parse,會有內容的 DOM tree,但是不 render,所以裡面需要的圖片、JS 等等都不會抓下來,然後 template 標籤的content屬性就是內容的 DOM node,所以第一段程式碼範例就可以改寫成:

var host = document.querySelector('.custom-component');
var root = host.createShadowRoot();
var clone = templateNode.content.cloneNode(true);
root.appendChild(clone);

以前比較常見的作法是用<script>標籤,然後說 type 是某個瀏覽器不認得的語言,像是 "text/template" 之類的,這樣瀏覽器就不會去執行內容,不過這方法的缺點一就是語意不對,二是潛藏有安全性問題。

Custom Element

Custom Element 的部分就是定義了怎樣在 document 下定義新的自訂標籤,不是以前剛進入 HTML5 時代時為了語意而產生的新標籤,這邊說的自訂標籤通常都是為了有特殊的用途,會能和使用者有互動,可以操控等特性的,只用 shadow DOM 的話,雖然可以把整個元件封裝起來,但是最外層還是用 div 標籤來把東西包起來,在語意上不太合,而且加上可能有非同步的問題,新標籤可能是動態插入文件樹內,傳統的方法並不方便處理這種狀況。

在繼續之前要先說,網路上還可以看到一些比較舊的文章有介紹<element>這個標籤,不過該標籤目前已經被廢棄了,custom element 現在是全由 JavaScript 那邊來和 document 作溝通,所以基本上就是定義了一組 document 的擴充 API,register

document.register('x-button', {prototype: xButtonProto});

這樣在這個文件樹下,就有了<x-button>這個新的標籤,其中標籤的命名方式是有建議一定要有個 '-' 的,有 '-' 的標籤,在有定義之前,會被認為是 unresolved element,可以用新的 CSS pseudo class:unresolved來隱藏起來,避免類似 FOUT 的現象,但是如果你沒有 '-' 而只是用了簡單的單詞來作自訂標籤的名稱,像是 slider、calendar 之類的,這樣就會被認為是 unknown element,就沒辦法用:unresolved了。

第二個參數則是 option object,其中的 prototype 則是定義了關於這個新的自訂標籤的行為,通常會從 HTMLElement 繼承來:

var xButtonProto = Object.create(HTMLElement.prototype);

然後接著定義它的 lifecycle callback function,lifecycle 這是個新的東西,在 custom element 的標準裡面定義了一個標籤的一生會發生的事情,包括:

  • created
  • enteredView
  • leftView
  • attributeChanged

最常會用到的大概就是 created 了吧:

xButtonProto.createdCallback = function () {
    var root = this;
    var host = this.webkitCreateShadowRoot();
    var clone = tpl.content.cloneNode(true);
    host.appendChild(clone);
};

像這樣定義 lifecycle callback,就會在<x-button>插入文件時,把他裡面的 shadow tree 建好,同時你也可以把一些事件和行為也在這個時間點建立起來。要拿到 shadow tree 裡面的元素的話,可以直接對 shadow host 下 query selector 指令來達成。

目前 WebKit 雖然有支援 custom element,不過也還是實驗中的狀態,要自己去把 'Experimental Web Platform features' 選項打開。

HTML Imports

HTML Imports 雖然是比較晚期才聽到有成形的標準,不過其實很早之前,在 Web Component 的概念成形之前,就已經有很多地方有類似的,引用外部資源需求了,所以標準的設計也沒有局現在 Web Component 的使用,結果而言,在 Web Component 這塊,import 的功能是讓 Web Component 的程式碼能夠在維護、發布時也能模組化、封裝起來的關鍵標準,但是他的設計方向卻不是以此為中心,HTML Imports 設計上市 link 標籤的一個擴充:

<link rel="import" href="component.html" />

結果就是,有支援 HTML Imports 的瀏覽器就會把 component.html 抓下來也 parse 過,正常網頁該抓的圖片、script,該跑的 script 也都會真的執行,而且在該檔案內的 script 執行環境的 document 和外面的 document 是同一個。所以理論上,只要把所有的 custom element 相關的東西移到獨立的檔案,然後在 import 進來就好了,這樣就可以保持主文件內容的乾淨,也可以讓這些自訂的標籤能夠獨立管理,方便維護。

不過這邊有個細節需要注意,就是要怎麼在被 import 的 component.html 裡面的 script 中,去 query component.html 的內容呢?例如我要 query template 出來塞到 shadow tree 裡面,那就要用 currentScript 這個 HTML5 的 API:

var doc = document.currentScript.ownerDocument;
var tpl = doc.getElementById('x-button-template');

這個寫法在非 import 的 HTML 文件中也是可以動的,所以即使沒有要用 HTML Imports 也可以直接用這個寫法,而 currentScript 的出現其實一部分原因是為了大量的第三方 widget 吧,像是 Facebook like button 之類的,它會給的通常是一個 script 標籤,然後我們把他放到想放的位置,script 再用 document.write 寫入 widget 元素,不過 document.write 不支援 async 的操作,還會有效能 issue,然而 currentScript 會指到現在這行程式碼所在的<script>,就可以非同步的用標準的 DOM 操作而不需要 document.write 也可以把東西放到正確的位置,這樣就可以解決很多這種第三方 script 插入元素的定位問題。

到這邊就可以把上面的東西全部接在一起了,目錄下會有以下檔案:

  • index.html
  • x-button/x-button.html
  • x-button/x-button-style.css
  • x-button/x-button-script.js

x-button.html 的內容:

<template id="x-button-template">
    <style>
        @import 'x-button/x-button-style.css';
    </style>

    <div class="x-button">
        <i class="icon"></i>
        <div class="content"><content></div>
    </div>

</template>

<script src="x-button-script.js"></script>

CSS 跳過,js 的內容:

var doc = document.currentScript.ownerDocument;
var tpl = doc.getElementById('x-button-template');
var xButtonProto = Object.create(HTMLElement.prototype);

xButtonProto.createdCallback = function () {
    var root = this;
    var host = this.webkitCreateShadowRoot();
    var clone = tpl.content.cloneNode(true);
    host.appendChild(clone);
};

document.register('x-button', {prototype: xButtonProto});

最後 html 的內容:

<link rel="import" href="x-button/x-button.html">

<x-button>Super Button</x-button>

Library

瀏覽器的實作,目前是 Chrome 最完整,不過都還在測試,要自己去把功能打開,其中 HTML Imports 比較新,Firefox 也還不支援,不過想要用的話還是可以透過兩套 JavaScript Library 來用,一個是 Google 的 Polymer,另外一套是 Mozilla 的 <x-tags>,Mozilla 的比較單純,就是可以方便定義 Custom Element 的一套 Library,看上面的介紹可以知道要定義一個 custom element 有很多地方要處理,x-tags 則是另外定義了一套 API 來把東西都集中起來,順便也把 custom element 登錄到文件內,另外也開了一個 repository 來收集大家做的 custom element,至於底層的部份,其實是用 Polymer 的 polyfill - platform.js。

Polymer 這套涵蓋的範圍就比較大了,首先它為了讓 Web Component 的相關新標準可以在現在的主流瀏覽器上動,它把這些標準的 polyfill 的做好了,接著基於這些標準做了 polymer-element 和 data binding 機制等核心的功能,然後開發者就可以用這些東西建構自己想要的 custom component,當然它也提供一些常用的 cusotm element,拼裝起來就可以把 application 建構出來。

Polymer 其實除了上面說的三個標準外,還做了好幾個 polyfill,包括 Web Animations、Pointer Event 以及目前沒有在標準內的 binding 機制我覺得終極目標應該是會和 AngularJS 相輔相成,畢竟現在 AngularJS 的 directive 機制就有點像是 Web Component + data binding 的組合。

Summary

其實 Web Component 真的是越看越覺得細節很多,這篇文章其實還是有不少沒提到的細節,不過大部分都可以在參考文獻中找到,有很多東西其實都還在進行中,文件不完整,找的到的範例也太過單純,不過相信資源會越來越完整,目前看起來 Web Component 的潮流遲早會成為主流,加上 Google 還把這麼多標準的 polyfill 都做好了,AngularJS 也把 component 的模式在開發者之間帶起來,我想整個潮流只會越來越快。

Reference

最後整理一下參考資料,有不少細節和一些相關的東西這篇文章還沒有講,之後有機會在分享吧。首先是 w3c 的 editor draft,包含三份 spec 還有一篇 intro

Specs:

然後 HTML5 Rocks 有好幾篇文章:

看到各主題的篇數就知道什麼東西比較複雜了,還有一些其他地方的介紹文章:

一個 Web Component 的 支援度檢查表 和 shadow DOM 視覺化工具。Google 推廣還算蠻用力的,今年的 Google IO 有不少 場次 有相關,還有一個 Google Plus 專頁

Libraries 的就是上面說到的兩個,Google 的 Polymer,建議可以去他的 Github 看看,然後還有 Mozilla 的 x-tags 和 Brick,其中 Brick 沒說到,不過他是基於 x-tag 的專案,要來收集各種 custom element,然後未來可以疊床架屋用的: