初識 Web Components 與 Stencil

SuperX發表於2022-04-14

在上文我們簡單的瞭解過 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 的基石。那麼我們來看下這個基石提供了哪些方法,提供給我們進行高樓大廈的建設。

  1. 自定義元素掛載方法

自定義元素通過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處理。

渲染結果:

  1. 自定義元素的類

由上面的例子 "class HelloWorld extends HTMLElement { xxx } " 發現,自定義元素的構造都是基於 HTMLElement,所以它繼承了 HTML 元素特性,當然,也可以繼承 HTMLElement的派生類,如:HTMLButtonElement 等,來作為現有標籤的擴充套件。

  1. 自定義元素的生命週期

類似於現有MV*框架的生命週期,自定義元素的基類裡面也包含了完整的生命週期 hook 來提供給開發者實現一些業務邏輯的應用:

class HelloWorld extends HTMLElement {
    constructor() {
        // 1 構建元件的時候的邏輯 hook
        super();
    }
  // 2 當自定義元素首次被渲染到文件時候呼叫 
  connectedCallback(){
  } 
  // 3 當自定義元素在文件中被移除呼叫 
  disconnectedCallback(){ 
  } 
  // 4 當自定義元件被移動到新的文件時呼叫
  adoptedCallback(){ 
  } 
  // 5 當自定義元素的屬性更改時呼叫
  attributeChangedCallback(){  
  }
}
  1. 新增自定義方法和屬性

由於自定義元素由一個類來構造,所以新增自定義屬性和方法就如同平常開發類的方法一致。

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 能解決我們什麼痛點:

  1. 完善的文件。可以在 Stencil 的官網上查閱到詳細且完備的文件,從專案初始化、開發、部署、各個框架的接入方法,FAQ等等,很完整。可以解決我們在具體開發中遇到的很多問題。這就可以看出官網真的很用心在維護這個框架。
  2. Stencil 提供完整的入門設定項和 cli 工具,從 "npm init stencil" 開始,Stencil 會提供保姆式的選項配置:

經過配置後,Stencil 會提供一套完整的專案目錄,包含各種初始化配置,做到了真正的開箱即用。

  1. 由上面 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>
  );
}
  1. 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>
);
}
  1. Virtual DOM提供了一種到真實dom的對映,從虛擬dom之間的diff,並將diff info patch到real dom,類似於 React 和 Vue,這樣的虛擬DOM對映,會使追蹤資料變動,重新渲染的流程更加高效。
  2. Stencil 還提供了更加完善的生命週期。

  1. 內建完善的 單元測試 和 e2e測試框架,在我們生成元件時,使用元件生成指令時,提供配套的 unit 和 e2e 模板檔案。
  2. 提供 custom elements polyfill 給予低版本框架更多支援。

  1. 還有一些其他的特性,比如 Async rendering 類似於 fiber、元件懶載入等等,也是我們日常開發中比較實用的技能。

從以上的種種特性可以看出,Stencil 對比原生 Web Components 更符合我們現在的開發方式,並且提供了完畢的語法糖生命週期。 配套的基礎架構工具, 可以讓我們無痛進行技術棧的轉換。

瞭解了以上知識點,可能大家已經對 Stencil 有了初步的印象,但是還不深,沒有關係。我會在以後的章節中仔細地為大家分析、實踐。保證你對 Stencil 這個框架了若指掌。

下一章:淺析 Stencil 裝飾器和生命週期

相關文章