在上文我們簡單的瞭解過 Web Components 的使用場景,它可以讓我們像使用原生標籤一樣使用我們定義的元件,而 Stencil 又可以讓我們像寫 React 一樣高效的書寫 Web Components 元件。
京東的跨端框架 Taro 的元件部分,就是用基於 Web Components 的工具鏈 Stencil 開發,可以看出,Stencil 和 Web Components 已經逐漸被前端開發者所認可接受。
那麼大家就會有疑問了:
- Web Components 到底提供了哪些 api 來定義瀏覽器可以識別的標籤元件?
- Stencil 又是如何基於原生 Web Components 封裝語法糖提高開發效率更高呢?
我們帶著以上兩個問題,我們來一步一步瞭解下 Web Components 與 Stencil。
Web Components
首先來了解下 Web Components 的基本概念, Web Component 是指一系列加入 w3c 的 HTML與DOM的特性,目的是為了從原生層面實現元件化,可以使開發者開發、複用、擴充套件自定義元件,實現自定義標籤。
這是目前前端開發的一次重大的突破。它意味著我們前端開發人員開發元件時,不必關心那些其他MV*框架的相容性,真正可以做到 “Write once, run anywhere”。
例如:
// 假如我已經構建好一個 Web Components 元件 <hello-world>並匯出
// 在 html 頁面,我們就可以直接引用元件
<script src="/my-component.js"></script>
// 而在 html 裡面我們可以這樣使用
<hello-world></hello-word>
而且跟任何框架無關,代表著它不需要任何外部 runtime 的支援,也不需要複雜的Vnode演算法對映到實際DOM,只是瀏覽器api本身對標籤內部邏輯進行一些編譯處理,效能必定會比一些MV*框架要好一些。
那它是怎麼做到高效能的呢?主要和它的核心API有關。其實在上篇中我們已經簡單提到了 Web Components 的三個核心 API,接下來我帶大家詳細分析各個api所承擔的功能和實際用法,想必瞭解過 Web Component 核心技術後,大家就不會對它感到陌生了。
三個核心API
Custom elements(自定義元素)
首先來了解下自定義元素,其實它是作為 Web Component 的基石。那麼我們來看下這個基石提供了哪些方法,提供給我們進行高樓大廈的建設。
- 自定義元素掛載方法
自定義元素通過CustomElementRegistry 來自定義可以直接渲染的html元素,掛載在 window.customElements.define 來供開發者呼叫,demo 如下:
// 假如我已經構建好一個 Web Components 元件 <hello-world>並匯出
class HelloWorld extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
padding: 10px;
background-color: #eee;
}
</style>
<h1>Hello World!</h1>
`;
}
}
// 掛載
window.customElements.define('hello-world', HelloWorld)
// 然後就可以在 html 中使用
<hello-world></hello-world>
注意:自定義元素必須用'-'連線符連線,來作為特定的區分,如果沒有檢測到自定義元素,則瀏覽器會作為空div處理。
渲染結果:
- 自定義元素的類
由上面的例子 "class HelloWorld extends HTMLElement { xxx } " 發現,自定義元素的構造都是基於 HTMLElement,所以它繼承了 HTML 元素特性,當然,也可以繼承 HTMLElement的派生類,如:HTMLButtonElement 等,來作為現有標籤的擴充套件。
- 自定義元素的生命週期
類似於現有MV*框架的生命週期,自定義元素的基類裡面也包含了完整的生命週期 hook 來提供給開發者實現一些業務邏輯的應用:
class HelloWorld extends HTMLElement {
constructor() {
// 1 構建元件的時候的邏輯 hook
super();
}
// 2 當自定義元素首次被渲染到文件時候呼叫
connectedCallback(){
}
// 3 當自定義元素在文件中被移除呼叫
disconnectedCallback(){
}
// 4 當自定義元件被移動到新的文件時呼叫
adoptedCallback(){
}
// 5 當自定義元素的屬性更改時呼叫
attributeChangedCallback(){
}
}
- 新增自定義方法和屬性
由於自定義元素由一個類來構造,所以新增自定義屬性和方法就如同平常開發類的方法一致。
class HelloWorld extends HTMLElement {
constructor() {
super();
}
tag = 'hello-world'
say(something: string) {
console.log(`hello world, I want to say ${this.tag} ${something}`)
}
}
// 呼叫方法如下
const hw = document.querySelector('hello-world');
hw.say('good');
// 控制檯列印效果如下
Shadow DOM(影子DOM)
有了自定義元素作為基石,我們想要更加順暢的進行元件化封裝,必定少不了對於DOM樹的操作。那麼好的,Shadow DOM(影子DOM)就應運而生了。
顧名思義,影子DOM就是用來隔離自定義元素不受到外界樣式或者一些副作用的影響,或者內部的一些特性不會影響外部。使自定義元素保持一個相對獨立的狀態。
在我們日常開發html頁面的時候也會接觸到一些使用 Shadow DOM 的標籤,比如:audio 和 video 等;在具體dom樹中它會一一個標籤存在,會隱藏內部的結構,但是其中的控制元件,比如:進度條、聲音控制等,都會以一個Shadow DOM存在於標籤內部,如果想要檢視具體的DOM結構,則可以嘗試在chrome的控制檯 -> Preferences -> Show user agent Shadow DOM, 就可以檢視到內部的結構構成。
如果元件使用Shadow host,常規document中會存在一個 Shadow host節點用來掛載 Shadow DOM,Shadow DOM內部也會存在一個DOM樹:Shadow Tree,根節點為Shadow root,外部可以用偽類:host來訪問,Shadow boundary其實就是Shadow DOM的邊界。具體架構圖如下:
下面我們通過一個簡單的例子來看下Shadow DOM的實際用處:
// Shadow DOM 開啟方式為
this.attachShadow( { mode: 'open' } );
- 不使用Shadow DOM
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Components</title>
<style>
h1 {
font-size: 20px;
color: yellow;
}
</style>
</head>
<body>
<div></div>
<hello-world></hello-world>
<h1>Hello World! 外部</h1>
<script type="module">
class HelloWorld extends HTMLElement {
constructor() {
super();
// 關閉 shadow DOM
// this.attachShadow({ mode: 'open' });
const d = document.createElement('div');
const s = document.createElement('style');
s.innerHTML = `h1 {
display: block;
padding: 10px;
background-color: #eee;
}`
d.innerHTML = `
<h1>Hello World! 自定義元件內部</h1>
`;
this.appendChild(s);
this.appendChild(d);
}
tag = 'hello-world'
say(something) {
console.log(`hello world, I want to say ${this.tag} ${something}`)
}
}
window.customElements.define('hello-world', HelloWorld);
const hw = document.querySelector('hello-world');
hw.say('good');
</script>
</body>
</html>
渲染效果為,可以看到樣式已經互相汙染:
- 使用 Shadow DOM
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Components</title>
<style>
h1 {
font-size: 20px;
color: yellow;
}
</style>
</head>
<body>
<div></div>
<hello-world></hello-world>
<h1>Hello World! 外部</h1>
<script type="module">
class HelloWorld extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
h1 {
font-size: 30px;
display: block;
padding: 10px;
background-color: #eee;
}
</style>
<h1>Hello World! 自定義元件內部</h1>
`;
}
tag = 'hello-world'
say(something) {
console.log(`hello world, I want to say ${this.tag} ${something}`)
}
}
window.customElements.define('hello-world', HelloWorld);
const hw = document.querySelector('hello-world');
hw.say('good');
</script>
</body>
</html>
渲染結果為:
可以清晰的看到樣式直接互相隔離無汙染,這就是Shadow DOM的好處。
HTML templates(HTML模板)
template模板可以說是大家比較熟悉的一個標籤了,在Vue專案中的單頁面元件中我們經常會用到,但是它也是 Web Components API 提供的一個標籤,它的特性就是包裹在 template 中的 HTML 片段不會在頁面載入的時候解析渲染,但是可以被 js 訪問到,進行一些插入顯示等操作。所以它作為自定義元件的核心內容,用來承載 HTML 模板,是不可或缺的一部分。
使用場景如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Components</title>
<style>
h1 {
font-size: 20px;
color: yellow;
}
</style>
</head>
<body>
<div></div>
<hello-world></hello-world>
<template id="hw">
<style>
.box {
padding: 20px;
}
.box > .first {
font-size: 24px;
color: red;
}
.box > .second {
font-size: 14px;
color: #000;
}
</style>
<div class="box">
<p class="first">Hello</p>
<p class="second">World</p>
</div>
</template>
<script type="module">
class HelloWorld extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
root.appendChild(document.getElementById('hw').content.cloneNode(true));
}
}
window.customElements.define('hello-world', HelloWorld);
</script>
</body>
</html>
渲染結果為:
Slot 大家應該也比較熟悉了,相當於一個連線元件內部和外部的一個佔位機制,可以用來傳遞 HTML 程式碼片段,這裡我就不過多贅述,有需要繼續瞭解的同學,Google一下即可。
說完了 Web Components 的“三駕馬車”,大家一定對於 Web Components 有了深入的瞭解,也熟悉了 Web Components 一些常規寫法。
不過,深入瞭解後我們發現,原生的 Web Component 處理封裝元件並不流暢,我們需要大量的特殊處理對於資料的監聽、DOM的渲染等等,所以針對這些不符合現在開發模式的情況,幫助我們提高開發效率的 “輪子” Stencil 應運而生。
那麼 Stencil是什麼?它又解決了什麼問題?對比原生 Web Component 寫法有什麼優勢呢?我們來繼續探索。
Stencil
首先說下它的背景。Stencil 由 Ionic 核心團隊推出,由團隊成員社群聯合維護,已經在github上擁有 10K+ star。
Stencil 可以理解為一個用於快速構建 Web Components 的工具集。也可以理解為一個編譯器,這意味著,當你的元件一旦經過 build 完成後,就會脫離 Stencil,不再依賴。並且 Stencil 相對原生 Web Components 提供了完善的專案目錄架構和配置,並提供了諸多的語法糖和封裝函式。
為什麼要使用 Stencil 來構建 Web Components 元件呢?它有哪些優勢呢?我們繼續探究。
首選,我們來看下 Stencil 官方所描述的自身的優點有哪些:
- Virtual DOM
<!---->
- Async rendering (inspired by React Fiber) fiber 的效能優勢 像Fiber一樣的排程模式
<!---->
- Reactive data-binding 單向資料流
<!---->
- TypeScript
<!---->
- 元件懶載入
<!---->
- JSX支援
<!---->
- 無依賴性元件
<!---->
- 虛擬DOM
<!---->
- 靜態網站生成(SSG)
列了一堆優點,“不明覺厲”,但是這樣我們也感受不到什麼,我們接著來看下它的 Demo :
import { Component, Prop, h } from '@stencil/core';
@Component({
tag: 'my-first-component',
})
export class MyComponent {
@Prop() name: string;
render() {
return (
<p>
My name is {this.name}
</p>
);
}
}
是不是很類似於 React 的寫法,而 @ 的裝飾器又似乎找到了一些 Angular 的影子,總體風格更加偏向於目前主流框架。
我們切合實際開發,再加上我的使用體驗,來實打實掰扯下 Stencil 對比開發原生 Web Components 能解決我們什麼痛點:
- 完善的文件。可以在 Stencil 的官網上查閱到詳細且完備的文件,從專案初始化、開發、部署、各個框架的接入方法,FAQ等等,很完整。可以解決我們在具體開發中遇到的很多問題。這就可以看出官網真的很用心在維護這個框架。
- Stencil 提供完整的入門設定項和 cli 工具,從 "npm init stencil" 開始,Stencil 會提供保姆式的選項配置:
經過配置後,Stencil 會提供一套完整的專案目錄,包含各種初始化配置,做到了真正的開箱即用。
- 由上面 Web Components 使用 DOM 的例子可以看出,原生 Web Components 操作 DOM 並不是很流暢,類似於原生的寫法並不高效,例如:
const d = document.createElement('div');
const s = document.createElement('style');
s.innerHTML = `h1 {
display: block;
padding: 10px;
background-color: #eee;
}`
d.innerHTML = `
<h1>Hello World! 自定義元件內部</h1>
`;
this.appendChild(s);
this.appendChild(d);
而 Stencil 為了解決這一個問題加入了JSX 語法,使操作DOM有了React的體驗。
render() {
return (
<div>
{this.name
? <p>Hello {this.name}</p>
: <p>Hello World</p>
}
</div>
);
}
- Stencil 提供的"@"語法糖裝飾器可以提供 單選資料流、資料變動 hook 等,結合 JSX,帶給我們了絲滑的開發體驗。具體如下:
- @Component() declares a new web component
- @Prop() declares an exposed property/attribute
- @State() declares an internal state of the component
- @Watch() declares a hook that runs when a property or state changes
- @Element() declares a reference to the host element
- @Method() declares an exposed public method
- @Event() declares a DOM event the component might emit
- @Listen() listens for DOM events
// 定義 props name
// 傳入值有變化時,觸發重新渲染
@Prop() name: string;
render() {
return (
<p>
My name is {this.name}
</p>
);
}
- Virtual DOM提供了一種到真實dom的對映,從虛擬dom之間的diff,並將diff info patch到real dom,類似於 React 和 Vue,這樣的虛擬DOM對映,會使追蹤資料變動,重新渲染的流程更加高效。
- Stencil 還提供了更加完善的生命週期。
- 內建完善的 單元測試 和 e2e測試框架,在我們生成元件時,使用元件生成指令時,提供配套的 unit 和 e2e 模板檔案。
- 提供 custom elements polyfill 給予低版本框架更多支援。
- 還有一些其他的特性,比如 Async rendering 類似於 fiber、元件懶載入等等,也是我們日常開發中比較實用的技能。
從以上的種種特性可以看出,Stencil 對比原生 Web Components 更符合我們現在的開發方式,並且提供了完畢的語法糖和生命週期。 配套的基礎架構工具, 可以讓我們無痛進行技術棧的轉換。
瞭解了以上知識點,可能大家已經對 Stencil 有了初步的印象,但是還不深,沒有關係。我會在以後的章節中仔細地為大家分析、實踐。保證你對 Stencil 這個框架了若指掌。