利用 Stencil 建構 Web Component


Posted by ArvinH on 2020-03-30

前言

好一陣子之前在網路上看到 Stencil JS,原以為是跟 CSS 相關的框架,沒想到是 ionic 團隊 製作的 Web Component 工具,在 GitHub 上有七千多顆星星,看來是深受喜愛,但當時我對於 Web Component 存有一些疑慮,所以沒有多加研究,例如,雖然主打用 Browser 原生支援的 Custom Elements 來渲染共用元件,能共用在任何前端框架上,但其實也意味著,使用這共用元件的團隊,對於這個元件的掌控力也降低了,若是想要修改元件的行為,不可避免的也得去學習如何修改 Web Component,反而徒增成本。
不過從另一個角度想,今天若有專門的團隊利用 Web Component 來建構與維護一整套 Design System 的話,可能就會是不錯的選擇。

總之,最近偶然間聽到高手學弟再度提及 Stencil 的名字,加上我在修改個人網站的時候,正好想在文章頁加入類似 Medium 的拍手元件,覺得可以利用這機會來試試看,用 Stencil 來製作一個能在 React 或其他前端框架上能運行的 Web Component,用這篇文章記錄下過程。

先看看成品:

點擊拍手 emoji 時,除了記錄點擊次數外,還會有光點由外至內被吸引到手上,最後讓拍手 emoji 的顏色由灰階轉黃再發紅光,營造出一種元氣彈吸收能量的感覺。

懶得看文章的可以到 GitHub 上看原始碼:https://github.com/ArvinH/claps-button

Stencil 簡介

Stencil is a compiler that generates Web Components (more specifically, Custom Elements). Stencil combines the best concepts of the most popular frameworks into a simple build-time tool.

在官網上清楚的寫到 Stencil 的定位是一個 Compiler,用了目前流行的 tech stack 來製作出完全符合標準的 Web Components。

參考了 React 的 Virtual DOM、Fiber 的 Async rendering、reactive data-binding,並支援 TypeScript 和 JSX 語法。對於習慣用 React 開發的人來說,算是非常好上手的一套工具。

官網上的文檔清晰明瞭,而且很好閱讀,一路從 Introduction 順著讀下去,就會默默把各種功能與 API 也都看過一遍,包含如何整合在不同的前端框架中的資訊。

其中也花了一些篇幅在介紹為什麼他們要製作這套工具,以及他們的目的是什麼。對於他們的出發點與目標,我覺得蠻有意思的:

Stencil aims to combine the best concepts of the most popular frontend frameworks into a compile-time tool rather than run-time tool.

他們對於 Stencil 的定位是屬於 compile-time tool,應用各種工具最佳化開發體驗,最終編譯出符合 Web Standards 且能運行在多數瀏覽器的元件。

為此,他們提供了 out-of-the-box 的按需加載,這點對於 Web Component 來說非常重要,也大大提高我的使用意願;API 部分他們是從 Web Component 標準的 lifecycle 上再做延伸,並沒有太多他們自己的客製化 API,學習曲線相對低,但還是能保有良好的開發體驗,像是在使用 Framework 開發一般。

有興趣的讀者可以前往官網閱讀,內容不算太多,很快能掃過一遍。

如何製作

專案設定

接下來就說明如何使用 Stencil 來製作 Web Component。

首先我們利用 Stencil 的 cli 開啟一個專案:

npm init stencil

你可以看到我們其實有三種選擇:ionic-pwaappcomponent

stencil-cli

ionic-pwa 與 app 都是用來建構一個完整 Web application 的 starter kit,單純建構 Component 的話只需要選擇 component 這個 starter kit 即可。有興趣的讀者也可以裝 ionic-pwa 與 app 試試看,Stencil 提供了自己的 router ?與 redux lib,讓你能有與開發目前一般前端應用程式相似的體驗。

選擇安裝好 component starter kit 後,可以看到下面的專案結構:

stencil-starter-kit-structure

重點只有一個,就是 components 資料夾,底下包含了 e2e 測試的檔案、css 檔與 component 主程式所在的 my-component.tsx。可以看到都是由 Typescript 為主要 stack,其中的 type file components.d.ts 在你 buildrun dev 的過程中會自動幫你 codegen 出來。

其中的 index.htmlrun dev 時的主要 html template,你可以在這邊測試你設計的 component 使用情境。

接著來看一下 package.json 的內容:

{
  "name": "my-first-component",
  "version": "0.0.1",
  "description": "Stencil Component Starter",
  "main": "dist/index.js",
  "module": "dist/index.mjs",
  "es2015": "dist/esm/index.mjs",
  "es2017": "dist/esm/index.mjs",
  "types": "dist/types/index.d.ts",
  "collection": "dist/collection/collection-manifest.json",
  "collection:main": "dist/collection/index.js",
  "unpkg": "dist/my-first-component/my-first-component.js",
  "files": [
    "dist/",
    "loader/"
  ],
  "scripts": {
    "build": "stencil build --docs",
    "start": "stencil build --dev --watch --serve",
    "test": "stencil test --spec --e2e",
    "test.watch": "stencil test --spec --e2e --watchAll",
    "generate": "stencil generate"
  },
  "devDependencies": {
    "@stencil/core": "^1.8.8"
  },
  "license": "MIT"
}

基本的 npm script 都幫你準備好了,當你 npm run build 的時候,除了基本的 main - dist/index.js 外,module 模式的 dist/index.mjses2015es2017 的編譯版本都有,甚至準備了給 unpkg 使用的 destination。

唯一一點要注意的是,當你準備要 publish 你的 component 到 npm 上,讓人透過 unpkg 使用時,記得一定要先 npm run build,產生 production build 才會有的編譯檔案,這樣才能夠正確地被人引入使用。

另外,裡面唯一比較特殊的 config 檔案 - stencil.config.ts 則是用來設定各種 stencil plugin 的,例如在我的範例中,我使用了 SCSS,這時就可以在此 config 檔中設定 scss plugin:

import { Config } from '@stencil/core';
// sass plugin
import { sass } from '@stencil/sass';
export const config: Config = {
  namespace: 'claps-button',
  outputTargets: [
    {
      type: 'dist',
      esmLoaderPath: '../loader'
    },
    {
      type: 'docs-readme'
    },
    {
      type: 'www',
      serviceWorker: null // disable service workers
    }
  ],
  // can setup plugins here
  plugins: [
    sass(),
  ]
};

程式開發

利用 starter kit 把專案建立好,接著把名稱改成自己的 component 後,就能開始正式撰寫程式碼。

import { Component, Prop, State, Listen, h, Host } from '@stencil/core';
@Component({
  tag: 'claps-button',
  styleUrl: 'claps-button.scss',
  shadow: true,
})
export class MyComponent {
  @State() count: number = 0;
  @Prop() color: string;
  @Listen('click', { capture: true })
  handleClick(e) {
    e.stopPropagation();
    this.count = this.count + 1;
  }
  render() {
    return (
      <Host>
        <div class="counter" style={{
          color: this.color,
        }}>{this.count}</div>
        <div
          class="claps-btn-container"
          data-count={this.count}
          onClick={this.handleClick}
        >
          👏
        </div>
      </Host>
    );
  }
}

一步步解析

@Component decorator 提供 Stencil compiler 關於你的元件的 Metadata。其中,tag 顧名思義就是你元件的 tag 名稱要叫做什麼;styleUrl 標示出你要引入的 css 檔案;shadow 代表你的元件是否要使用 shadow dom。

使用 shadow dom 的差別在於你想不想要元件內的所有元素被封裝起來。不清楚何謂 shadow dom 的可以看 MDN 的說明。若是你有開啟 shadow dom,你的元件在 HTML Tree 中會長得像這樣:

下面實際 Component 實作的部分,則跟 React Component 類似,一樣有 render 函式回傳 JSX;ㄧ樣有 private/public method 可以宣告使用。

你可以利用 @State()@Prop() 兩個 decorator 來宣告元件的狀態(state) 與可接受的 Props。

@Listen() decorator 則是 Stencil 用來監聽外部 DOM Event 的,可以傳入不同的 option,像是上面範例中的 { capture: true } 就是告知我們要在 Capture 階段 fire listener;也可以指定 target,告訴 Stencil 你要把這個 listener 綁定在誰身上,像是 scroll event,可以 target 在 window 上:

@Listen('scroll', { target: 'window' })
handleScroll(ev) {
  console.log('the body was scrolled', ev);
}

此外,還有個 @Event() decorator 可以用來 emit data 跟 event,並用 @Listen() decorator 捕獲。詳情可以參閱官網

最後在 render 函式中有個特別的 Element 叫做 Host,在 Web component 的世界中,host environment 代表的是 host 你的 custom web component 的環境,通常就是一般的 DOM Tree,而這邊的 Host component 也雷同,代表的就是你的 Web component 本身,你傳入給 Host element 的屬性都會反映在 web component 的 tag 身上。從官網給的範例來看可能會更清楚:

@Component({tag: 'todo-list'})
export class TodoList {
  @Prop() open = false;
  render() {
    return (
      <Host
        aria-hidden={this.open ? 'false' : 'true'}
        class={{
          'todo-list': true,
          'is-open': this.open
        }}
      />
    )
  }
}

根據傳入你的 custom tag 的屬性 open 的狀態,會改變其 aria-hidden 屬性的內容。

如果 this.open === true

<todo-list class="todo-list open" aria-hidden="false"></todo-list>

此外,Host element 也可以單純用來取代 React 中的 Fragement

其他細節與注意事項

至此為止其實就已經可以產出一個 Web Component 了,接下來要做的就都只是加入元件所需的實作細節,下面程式碼我省略了一些產生動畫的細節,有興趣的可以從 GitHub 上參考原始碼,實作方式其實很簡單暴力,就是固定產生許多小光點,然後算好圓形座標的位置與位移,並利用 animate API 來加上 keyframe,在點擊拍手 emoji 的時候觸發動畫,並加上 text-shadowfilter: grayscale 的屬性來增加效果。

這邊我著重說明一些關於 Stencil 實作 web component 上的一些細節:

@Component({
  tag: 'claps-button',
  styleUrl: 'claps-button.scss',
  shadow: true,
})
export class MyComponent {
  @Element() el: HTMLElement;
  @State() count: number = 0;
  @Prop() color: string;
  @Prop() size: string;
  @Prop() preserve: boolean;

  @Listen('click', { capture: true })

  handleClick(e) {
    e.stopPropagation();
    this.count = this.count + 1;
    if (this.preserve) {
      localStorage.setItem(`claps-wc-${location.pathname}`, `${this.count}`);
    }
    window.requestAnimationFrame(this.runAnimation.bind(this));
  }

  componentWillLoad() {
    if (!this.preserve) return;
    this.count = +localStorage.getItem(`claps-wc-${location.pathname}`);
  }

  runAnimation() {
    const root = document.createDocumentFragment();
    const rootElm  = this.el.shadowRoot.querySelector('.claps-btn-container');
      // ...略
    const generateChi = () => {
      // ...略
      // 產生圓形氣體顆粒效果
    }
    const chiArray = generateChi();
    chiArray.forEach(chi => {
      root.appendChild(chi);
    })
    rootElm.appendChild(root);
  }

  render() {
    return (
      <Host>
        <div class="counter" style={{
          color: this.color,
          borderColor: this.color,
          width: this.size || '3rem',
          height: this.size || '3rem',
        }}>{this.count}</div>
        <div
          class="claps-btn-container"
          data-count={this.count}
          onClick={this.handleClick}
          style={{
            width: this.size || '3rem',
            height: this.size || '3rem',
            fontSize: this.size || '3rem',
            textShadow: `1px 0px ${(
              this.count < 20 ? this.count : 20
            ).toFixed(2)}px red`,
            filter: `grayscale(${(
              1 - this.count / 20
            ).toFixed(2)})`
          }}
        >
          👏
        </div>
      </Host>
    );
  }
}

首先是 lifecycle,Stencil 除了提供 Web Component 標準的 lifecycle - connectedCallback()disconnectedCallback() 外,還提供了許多類似當初 React 的 component lifecycle,可以從官方的圖檔中一目瞭然:

stencil-component-lifecycle

我們範例中用到的 componentWillLoad 會在 Component 進行第一次 render 前被呼叫,所以適合用來 initialized data,這邊的例子是從 localStorage 中取出資料。

另一個執得注意的地方是取得 shadow dom 元素的方法。

runAnimation() 函數中,我需要將動態產生的光點掛載到 web component 的 shadow dom 上,而要能存取 shadow dom,需要先利用 @Element() decorator 來宣告一個 Host element 的 reference,該 decorator 會回傳一個 HTMLElement 的 instance,你就能以此來存取 shadow dom:

const rootElm = this.el.shadowRoot.querySelector('.claps-btn-container');

最後,還有一些這次範例中未用到的 API,像是 public method 的使用與類似 Observable 的 Watch decorator 等等,有興趣的讀者在前往官網查看即可,都寫得蠻清楚的。

Stencil API Docs

如何運用在不同 framework 上

使用在不同框架的方法,我試了兩種,首先是 React;

最簡單的方式就是在 template index.html 中放入:

<script src='https://unpkg.com/claps-button@1.0.7/dist/claps-button.js'></script>

然後就能在任意 component 中加入:

<claps-button size="3rem" preserve ></claps-button>

然而像 Vue 的話,就沒辦法直接這樣做,必須要以 npm install ${your web component} 的方式載入到你的 App 中,並在 main.js 中,從 stencil 提供的 loader 中去 defineCustomElements

import Vue from "vue";
import App from "./App.vue";
import {
  applyPolyfills,
  defineCustomElements
} from "claps-button/loader/index.cjs.js";
Vue.config.productionTip = false;
// Tell Vue to ignore all components defined in the claps-button
// package. The regex assumes all components names are prefixed
// 'claps'
Vue.config.ignoredElements = [/claps-\w*/];
// Bind the custom elements to the window object
applyPolyfills().then(() => {
  defineCustomElements();
});
new Vue({
  render: h => h(App)
}).$mount("#app");

But!

這樣的做法目前似乎有許多問題,會一直出現 TypeError: Cannot read property 'isProxied' of undefined 的 Error,在網路上有許多討論,截至我完成這篇文章為止,尚未有結論。例如:GitHub 上的 issueSvelte 的例子

所以我想目前可能還是以 script tag 的方式引入最為保險。

結論

Web Component 的存在必要還是有很多爭議,我個人偏好將其運用在 Design System 中,由一個固定的 Team 去維護,如此以來才能發揮他目前的最大功用。

用 Stencil 開發 Web Component 的體驗挺不錯的,如果你有手刻過 custom elements,你應該也會驚艷於他們將整個開發體驗包裹得很好,有時間的話值得玩玩!

這次我在開發的拍手元件的同時,除了學習到 Stencil 的開發方式外,也意外練習到動畫製作的經驗,挺有趣的,大家也拿個自己想做的小 component 來試試看吧!也歡迎跟我分享心得互相指教!

資料來源

  1. StencilJS official site
  2. emoji claps inspired by this codepen

#web component #stenciljs









Related Posts

個人CodeBase紀錄 - EP.2 不想 Bind data 到吐,來自訂一下 Aspose 的擴充

個人CodeBase紀錄 - EP.2 不想 Bind data 到吐,來自訂一下 Aspose 的擴充

[Linux] windows遠端 + wsl + docker 開放 0.0.0.0:8080 給外部使用

[Linux] windows遠端 + wsl + docker 開放 0.0.0.0:8080 給外部使用

在 ES6 你會遇到的新功能

在 ES6 你會遇到的新功能




Newsletter




Comments