[譯] 2018 來談談 Web Component

老教授發表於2018-08-18

對很多人來說,元件已經成為他們開發工作中的核心概念。元件提供了一種健壯的模型,允許我們用一個個更小的更簡單的封裝好的部件來搭建出複雜的應用程式。元件的概念在 Web 上已經存在一段時間了,比如在 JavaScript 生態的早期,Dojo Toolkit 已經在它的 Dijit 外掛系統裡面應用了元件這個概念。

現代框架比如說 React、Angular、Vue 和 Dojo 進一步把元件放在開發的前列,並作為核心要素用在它們自己的框架結構上。然而,雖說元件結構變得越來越普遍,但是各種各樣的框架和庫也衍生出一個紛繁複雜、四分五裂的元件生態。這種分裂常常將一些團隊釘死在某個特定的框架上,哪怕時間、技術的更迭也不會輕易地改變。

解決這種割裂的形勢,讓 Web 元件模型統一化,這項工作已經在努力推進中。最早的努力當數 “Web Component” 規範說明 circa 2011 的出現,並在同年的 Fronteers Conference 大會上由 Alex Russell 將之宣之於眾。該 Web Component 規範的產生和發展,旨在提供一種權威的、瀏覽器能理解的方式來建立元件。在做出跨瀏覽器支援的元件方案這件事上我們還有很多事情要做,但已經比以往任何時候更接近目標了。理論上講,這些規範和實踐鋪平了元件間相互作用相互結合的道路,即使這些元件出自不同的供應方(比如 React,比如 Vue)。下面我們開始探索 Web Component 規範的組成。

組成部分

Web Component 並非單一的技術,而是由一系列 W3C 定義的瀏覽器標準組成,使得開發者可以構建出瀏覽器原生支援的元件。這些標準包括:

  • HTML Templates(譯者注:模板)and Slots(譯者注:插槽) — 可複用的 HTML 標籤,提供了和使用者自定義標籤相結合的介面
  • Shadow DOM(譯者注:影子節點) — 對標籤和樣式的一層 DOM 包裝
  • Custom Elements(譯者注:自定義元素) — 帶有特定行為且使用者自命名的 HTML 元素

這裡還有另一個 Web Component 規範,HTML Imports,用於將 HTML 程式碼及 Web Component 匯入到網頁中。然而,在交叉參考 ES Module 規範後,Firefox 團隊認為這不是一種最佳實踐,該規範也就沒多少人在推動了。

Shadow DOM 和 Custom Element 規範經歷了一些迭代,現在都已經是第二個版本(v1)。在 2016 年 2 月,有人推動將 Shadow DOM 和 Custom Element 併入 DOM 標準規範裡面,而不再作為獨立的規範存在。

template 標籤和 slot 標籤

HTML 模板是支援度最高的特性,可以說是 Web Component 規範最直觀的體現。它允許開發者定義一個直到被複制使用時才會進行渲染的 HTML 標籤塊。你可以參考下面的簡單示例來定義一個模板:

<template id="custom-template>
     <h1>HTML Templates are rad</h1>
</template>
複製程式碼

一旦 DOM 裡面定義了這樣的一個模板,就可以在 JavaScript 裡面引用了:

const template = document.getElementById("custom-template");
const templateContent = template.content;
const container = document.getElementById("container");
const templateInstance = templateContent.cloneNode(true);
container.appendChild(templateInstance);
複製程式碼

像上面那樣寫,就可以藉助 cloneNode 函式來複用這個模板。提到 <template> 標籤就不得不提 <slot> 標籤。slot 標籤允許開發者通過特定接入點來動態替換模板中的 HTML 內容。它用 name 屬性來作為唯一識別標誌(譯者注,就類似普通 DOM 節點的 id 屬性):

<template id="custom-template">
    <p><slot name="custom-text">We can put whatever we want here!</slot></p>
</template>
複製程式碼

slot 標籤在 Custom Element 的注入中非常有用。它允許開發者在寫好的 Custom Element 裡面設定標記。當 Custom Element 裡面的節點用到了 slot 屬性作為標記,那這個節點就會替換掉模板裡面對應的 slot 標籤。

Shadow DOM

在頁面上定位具體的節點這是 web 開發的一個基本能力。CSS 選擇器不僅可以用來給節點加樣式,還可以用來查詢特定的 DOM 集合。這通常發生在根據一個識別符號選擇特定節點,比方說使用 document.querySelectorAll 就可以找到整個 DOM 樹中匹配指定選擇器的節點陣列。然而,如果應用程式非常龐大,有很多節點有衝突的 class 屬性,那又該怎麼辦?此時,程式就不知道哪個節點是想被選中的,bug 也就隨之產生。如果可能的話,將部分 DOM 節點抽象出來,隔離開來,讓它們不會被 DOM 選擇器選擇到,那豈不是很好?Shadow DOM 就能做到,它允許開發者將一些節點放到獨立的子樹上來實現隔離。根本上說 Shadow DOM 提供了一種健壯的封裝方式來做到頁面節點的隔離,這也是 Web Component 的核心優勢。

與此相似,CSS 的類和 ID 應用於全域性樣式時也會出現類似的問題。衝突的命名標示會導致樣式的相互覆蓋。那參考上面 DOM 樹選擇節點的思路,如果能將 CSS 樣式限制在某個 DOM 的子樹上,不就可以避免全域性樣式衝突,解決問題?比較有名的樣式設定技術比如 CSS Modules 或者 Styled Components,它們的核心出發點之一就是為了解決這個問題。舉個例子,CSS 模組技術通過對類名和模組名進行雜湊處理,賦予每個 CSS 樣式唯一的識別符號從而避免衝突。Shadow DOM 跟它們不同之處在於它並不對類名做處理,而是直接就把這個作為原生特性來支援。它將部分 DOM 節點隔離開來使得我們的網站和程式少了不可預知的變化,更加穩定。

那在程式碼層面上該怎麼操作?可以這樣將 Shadow DOM 附加到一個節點上:

element.attachShadow({mode: 'open'});
複製程式碼

這裡 attachShadow 函式接受一個含 mode 屬性的物件作為引數。Shadow DOM 可以開啟關閉開啟時使用 element.shadowRoot 就可以拿到 DOM 子樹,反之如果關閉了則會拿到 null。接著建立一個 Shadow DOM 就會建立一個陰影的邊界,在封裝節點的同時封裝樣式。預設情況下該節點內部的所有樣式會被限制僅在這個影子樹裡生效,於是樣式選擇器寫起來就短得多了。Shadow DOM 通常可以和 HTML 模板結合使用:

const shadowRoot = element.attachShadow({mode: 'open'});
shadowRoot.appendChild(templateContent.cloneNode(true));
複製程式碼

現在這個 element 就有一個影子樹,影子樹的內容是模板的一個複製。Shadow DOM、 <template> 標籤、<slot> 標籤在這裡和諧地應用在一起,構造出了可複用、封裝良好的元件。

通過 Custom Element 進一步封裝

HTML 的 template 和 slot 標籤提供了複用性和靈活性,Shadow DOM 提供了封裝方法。而 Custom Element 再進一步,將所有這些特性打包在一起成為有自己名字的可反覆使用的節點,讓它可以像常規 HTML 節點一樣用起來。

定義一個 Custom Element

定義 Custom Element 要用到 JavaScript。Custom Element 依賴 ES2015+ 的 Class 特性,用 Class 作為其宣告模式,通常是從 HTMLElement 或它的子類繼承而來。這裡有一個 Custom Element 的例子,使用 ES2015+ 語法建立,用於計數:

// 我們定義一個 ES6 的類,擴充於 HTMLElement
class CounterElement extends HTMLElement {
    constructor() {
        super();
 
        // 初始化計數器的值
        this.counter = 0;
 
        // 我們在當前 custom element 上附加上一個開啟的影子根節點
        const shadowRoot= this.attachShadow({mode: 'open'});
 
        // 我們使用模板字串來定義一些內嵌樣式
        const styles=`
            :host {
                position: relative;
                font-family: sans-serif;
            }
 
            #counter-increment, #counter-decrement {
                width: 60px;
                height: 30px;
                margin: 20px;
                background: none;
                border: 1px solid black;
            }
 
            #counter-value {
                font-weight: bold;
            }
        `;
 
        // 我們給影子根節點提供一些 HTML
        shadowRoot.innerHTML = `
            <style>${styles}</style>
            <h3>Counter</h3>
            <slot name='counter-content'>Button</slot>
            <button id='counter-increment'> - </button>
            <span id='counter-value'>; 0 </span>;
            <button id='counter-decrement'> + </button>
        `;
 
        // 我們可以通過影子根節點查詢內部節點
        // 就比如這裡的按鈕
        this.incrementButton = this.shadowRoot.querySelector('#counter-increment');
        this.decrementButton = this.shadowRoot.querySelector('#counter-decrement');
        this.counterValue = this.shadowRoot.querySelector('#counter-value');
 
        // 我們可以繫結事件,用類方法來響應
        this.incrementButton.addEventListener("click", this.decrement.bind(this));
        this.decrementButton.addEventListener("click", this.increment.bind(this));
 
    }
 
    increment() {
        this.counter++
        this.invalidate();
    }
 
    decrement() {
        this.counter--
        this.invalidate();
    }
 
    // 當計數器的值發生變化時呼叫
    invalidate() {
        this.counterValue.innerHTML = this.counter;
    }
}
 
// 這裡定義了可以在 DOM 樹上直接使用的真實節點
customElements.define('counter-element', CounterElement);
複製程式碼

特別注意最後一行,那裡註冊了可以用在 DOM 裡面的 Custom Element。

Custom Element 的種類

上面程式碼展示瞭如何從 HTMLElement 介面做擴充,然而我們還可以從更具體的節點上擴充,比如 HTMLButtonElement。Web Component 規範提供了一個完整的可供繼承的介面列表

Custom Element 可分為兩種主要型別:獨立自定義元素(Autonomous custom elements)內建自定義元素(Customized built-in elements)。獨立自定義元素和那些早已定義且不繼承自特定介面的節點類似(譯者注:就是我們平常使用的 DOM 節點)。一個獨立自定義元素只要在頁面一定義上,就可以像常規 HTML 節點那樣使用。舉個例子,上面定義的計數節點,既可以在 HTML 中通過 <counter-element></counter-element> 定義,也可以在 JavaScript 中用 document.createElement('counter-element') 來建立。

內建自定義元素在使用上略有不同,當 HTML 定義節點時可以傳一個 is 屬性到標準節點上(比如 <button is='special-button'>),又或者使用 document.createElement 時傳一個 is 屬性作為引數(比如 document.createElement("button", { is: "special-button" })。

Custom Element 的生命週期

Custom Element 也有一系列的生命週期事件,用於管理元件連線和脫離 DOM :

  • connectedCallback:連線到 DOM
  • disconnectedCallback: 從 DOM 上脫離
  • adoptedCallback: 跨文件移動

一種常見錯誤是將 connectedCallback 用做一次性的初始化事件,然而實際上你每次將節點連線到 DOM 時都會被呼叫。取而代之的,在 constructor 這個 API 介面呼叫時做一次性初始化工作會更加合適。

此處還有一個 attributeChangedCallback 事件可以用來監聽節點(譯者注:使用 Custom Element 定義的節點)屬性的變化,然後通過這個變化來更新內部狀態。不過,要想用上這個能力,必須先在節點類裡面定義一個名為 observedAttributes 的 getter:

constructor() {
    super();
    // ...
    this.observedAttributes();
}
 
get observedAttributes() {return ['someAttribute']; } 
// 其他方法
複製程式碼

從這裡起就可以通過 attributeChangedCallback 來處理節點屬性的變化:

attributeChangedCallback(attributeName, oldValue, newValue) {
    if (attributeName==="someAttribute") {
        console.log(oldValue, newValue)
        // 根據屬性變化做一些事情
    }
}
複製程式碼

支援度如何?

截至 2018 年 6 月,Shadow DOM 第二版和 Custom Element 第二版在 Chrome、Safari、三星瀏覽器上已經支援,還被 Firefox 列為要支援的特性,希望很大。而 Edge 依然在考慮是否支援。在這個時間點,Github 倉庫 webcomponents 上已經有了一系列的 polyfill。這些 polyfill 使得包括 IE11 在內的所有當下活躍的瀏覽器上都能運轉 Web Component。該 webcomponents 庫包含多種形態,既提供了一個包含所有必要 polyfill 的指令碼(webcomponents-bundle.js),也提供了一個通過特性檢測來只載入必要 polyfill 的版本(webcomponents-loader.js)。如果使用第二種,你還是必須將各個 polyfill 檔案都放到伺服器上來保證載入器可以載入到。

對於那些程式碼中只能用 ES5 的情況,還必須載入一個 custom-elements-es5-adapter.js 檔案,而且它必須首先載入,不能跟元件程式碼打包在一起。之所以需要這個適配檔案是因為 Custom Element 必須 繼承自 HTMLElement 類,且建構函式中必須以 ES2015 的方式呼叫 super()(這在 ES5 程式碼裡看起來會很困惑!)。在 IE11 中還是會由於不支援 ES2015 的類特性而丟擲錯誤,不過可以忽略之

Web Component 和框架

歷史上,Web Component 最大的支持者之一是 Polymer 庫。Polymer 針對 Web Component API 新增了一些語法糖使得定義和傳遞元件變得更加容易。在最新版本 Polymer3 中,它與時俱進用上了 ES2015 的模組特性並且使用 npm 作為標準的包管理工具,跟上了其他的現代框架。Web Component 編碼工具的另一種形態則更像是編譯器而非框架。StencilSvelte 這兩個框架就是這樣。它們使用各自的工具 API 來書寫元件,然後編譯成原生的 Web Component。一些框架比如 Dojo 2, 則選擇允許開發者編寫特定框架的元件,不過也允許編譯成原生 Web Component 就是了。在 Dojo2 中這是用 @dojo/cli tools 來實現的。

努力實現原生的 Web Component 的一個願景,是希望跨越不同團隊不同專案來共用元件,即使它們用的是不同的框架。當下不同的框架和 Web Component 規範有不同的關係,有些更貼近規範有些則不然。已經有一些指引告訴我們怎麼在諸如 ReactAngular 這樣的框架中用上原生的 Web Component ,但它們的實現上還是帶著濃濃的框架特色。有一個很好的資源可以幫你理解這些關係,那就是 Rod Dodson 的 Custom Elements Everywhere,它通過測試用例測出不同框架想和 Custom Element(Web元件規範的核心) 結合的難易程度。

最後的想法

圍繞 Web Component 的使用和炒作不斷持續此起彼伏。這意味著,隨著 Web Component 得到越來越好的支援,polyfill 將逐漸淡出我們的視野,元件書寫將更加簡潔和快速。Shadow DOM 允許開發者寫一些簡單的限定區域有效的 CSS,這無疑更加容易管理,通常效能也會更好。Custom Element 提供了一種統一的方法來定義元件,這些元件可以(理論上)跨程式碼庫和團隊來使用。目前有一些額外的規範建議,開發者可以根據基本規範加以利用:

這些補充規範可以為原生 web 平臺增加更多功能,讓開發者不用再去理解那麼多抽象概念,釋放更多的潛力。

該基本規範毫無疑問是一套強大的工具,但最終它是否能發揮最大的效用還是要取決於用到它的框架、開發者和團隊。目前如 React、Vue、Angular 這樣的框架已經大大佔據了開發者的大腦,它們會因為這些原生態的技術和工具而逐漸敗下陣來嗎?只能讓時間來見證了。


下一步

你是否希望在你的下一個專案或框架中用上 Web Component?聯絡我們,探討下我們可以怎麼幫到你!

SitePen On-Demand Development 可以獲取幫助,它有我們對 JavaScript 和 TypeScript 大大小小問題的快速有效解決方案。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章