如何基於 WebComponents 封裝 UI 元件庫

語言: CN / TW / HK

前言

作為一名前端攻城獅,相信大家也都在關注著前端的一些新技術,近些年來前端元件化開發已為常態,我們經常把重用性搞的模組抽離成一個個的元件,來達到複用的目的,這樣減少了我們的維護成本,提高了開發的效率。但是都有一個缺點離不開框架本身,因為我們瀏覽器本身解析不了那些元件。那麼有沒有一種技術也可以達到這種效果呢?答案就是今天的主角 Web Components。

Web Components 是一套不同的技術,允許您建立可重用的定製元素(它們的功能封裝在您的程式碼之外)並且在您的web應用中使用它們。 目前 W3C 也在積極推動,並且瀏覽器的支援情況還不錯。FireFox、Chrome、Opera 已全部支援,Safari 也大部分支援,Edge 也換成 webkit 核心了,離全面支援應該也不遠了。當然社群也有相容的解決方案 webcomponents/polyfills 。

WebComponents 三要素和生命週期

Button 元件示例

首先我們就從一個最簡單的 Button 元件開始,我們可以通過在元件中傳入 type 來改變按鈕的樣式,並且動態監聽了資料的變化。

// html
<cai-button type="primary"> 
  <span slot="btnText">
    按鈕
  </span>
</cai-button>
<template id="caiBtn">
  <style>
    .cai-button {
      display: inline-block;
      padding: 4px 20px;
      font-size: 14px;
      line-height: 1.5715;
      font-weight: 400;
      border: 1px solid #1890ff;
      border-radius: 2px;
      background-color: #1890ff;
      color: #fff;
      box-shadow: 0 2px #00000004;
    }
    .cai-button-warning {
      border: 1px solid #faad14;
      background-color: #faad14;
    }
    .cai-button-danger {
      border: 1px solid #ff4d4f;
      background-color: #ff4d4f;
    }
  </style>
  <div class="cai-button"> <slot name="btnText"></slot> </div>
</template>
<script>
  const template = document.getElementById("caiBtn");
  class CaiButton extends HTMLElement {
    constructor() {
      super()
      this._type = {
        primary: 'cai-button',
        warning: 'cai-button-warning',
        danger: 'cai-button-danger',
      }
      // 開啟shadow dom
      const shadow = this.attachShadow({
        mode: 'open'
      })
      const type = this
      const content = template.content.cloneNode(true) // 克隆一份 防止重複使用 汙染
      // 把響應式資料掛到this
      this._btn = content.querySelector('.cai-button')
      this._btn.className += ` ${this._type[type]}`
      shadow.appendChild(content)
    }
    static get observedAttributes() {
      return ['type']
    }
    attributeChangedCallback(name, oldValue, newValue) {
      this[name] = newValue;
      this.render();
    }
    render() {
      this._btn.className = `cai-button ${this._type[this.type]}`
    }
  }
  // 掛載到window
  window.customElements.define('cai-button', CaiButton)
</script>

三要素、生命週期和示例的解析

  • Custom elements(自定義元素):一組 JavaScript API,允許您定義 custom elements 及其行為,然後可以在您的使用者介面中按照需要使用它們。在上面例子中就指的是我們的自定義元件,我們通過 class CaiButton extends HTMLElement {} 定義我們的元件,通過 window.customElements.define('cai-button', CaiButton) 掛載我們的已定義元件。

  • Shadow DOM(影子 DOM ):一組 JavaScript API,用於將封裝的“影子” DOM 樹附加到元素(與主文件 DOM 分開呈現)並控制其關聯的功能。通過這種方式,您可以保持元素的功能私有,這樣它們就可以被指令碼化和樣式化,而不用擔心與文件的其他部分發生衝突。使用 const shadow = this.attachShadow({mode : 'open'}) 在 WebComponents 中開啟。

  • HTML templates(HTML模板)slot :template 可以簡化生成dom元素的操作,我們不再需要 createElement 每一個節點。slot 則和 Vue 裡面的 slot 類似,只是使用名稱不太一樣。

    內部生命週期函式

  • connectedCallback : 當 WebComponents 第一次被掛在到 dom 上是觸發的鉤子,並且只會觸發一次。類似 Vue 中的 mounted React 中的 useEffect(() => {}, []),componentDidMount。

  • disconnectedCallback : 當自定義元素與文件 DOM 斷開連線時被呼叫。

  • adoptedCallback : 當自定義元素被移動到新文件時被呼叫。

  • attributeChangedCallback : 當自定義元素的被監聽屬性變化時被呼叫。上述例子中我們監聽了 type 的變化,使 button 元件呈現不同狀態。 雖然 WebComponents 有三個要素,但卻不是缺一不可的,WebComponents 藉助 shadow dom 來實現樣式隔離,藉助 templates 來簡化標籤的操作。

在這個例子用我們使用了 slot 傳入了倆個標籤之間的內容,如果我們想要不使用 slot 傳入標籤之間的內容怎麼辦?

我們可以通過 innerHTML 拿到自定義元件之間的內容,然後把這段內容插入到對應節點即可。

元件通訊

瞭解上面這些基本的概念後,我們就可以開發一些簡單的元件了,但是如果我們想傳入一些複雜的資料型別(物件,陣列等)怎麼辦?我們只傳入字串還可以麼?答案是肯定的!

傳入複雜資料型別

使用我們上面的 button,我們不僅要改變狀態,而且要想要傳入一些配置,我們可以通過傳入一個 JSON 字串

// html
<cai-button id="btn">
</cai-button>
<script>
  btn.setAttribute('config', JSON.stringify({icon: '', posi: ''}))
</script>

// button.js 
class CaiButton extends HTMLElement {
    constructor() {
       xxx    
    }
    static get observedAttributes() {
      return ['type', 'config'] // 監聽config
    }
    attributeChangedCallback(name, oldValue, newValue) {
      if(name === 'config') {
        newValue = JSON.parse(newValue)
      }
      this[name] = newValue;
      this.render();
    }
    render() {
    }
  }
  window.customElements.define('cai-button', CaiButton)
})()

這種方式雖然可行但卻不是很優雅。

  • 對於使用者說:我用你個元件你還要讓我把所有的複雜型別都轉換成字串?
  • 對於開發元件者來說:我為什麼要每次都 JSON.parse() 一下?
  • HTML 中會有很長的資料。

因此我們需要換一個思路,我們上面使用的方式都是 attribute 傳值,資料型別只能是字串,那我們可以不用它傳值嗎?答案當然也是可以的。和 attribute 形影不離還有我們 js 中的property,它指的是 dom 屬性,是js物件並且支援傳入複雜資料型別。

// table元件  demo,以下為虛擬碼 僅展示思路
<cai-table id="table">
</cai-table>

table.dataSource = [{ name: 'xxx', age: 19 }]
table.columns = [{ title: '', key: '' }]

這種方式雖然解決上述問題,但是又引出了新的問題--自定義元件中沒有辦法監聽到這個屬性的變化,那現在我們應該怎麼辦? 或許從一開始是我們的思路就是錯的,顯然對於資料的響應式變化是我們原生 js 本來就不太具備的能力,我們不應該把使用過的框架的思想過於帶入,因此從元件使用的方式上我們需要做出改變,我們不應該過於依賴屬性的配置來達到某種效果,因此改造方法如下。

<cai-table thead="Name|Age">
  <cai-tr>
    <cai-td>zs</cai-td>
    <cai-td>18</cai-td>
  </cai-tr>
  <cai-tr>
    <cai-td>ls</cai-td>
        <cai-td>18</cai-td>
  </cai-tr>
</cai-table>

我們把屬於 HTML 原生的能力歸還,而是不是採用配置的方式,就解決了這個問題,但是這樣同時也決定了我們的元件並不支援太過複雜的能力。

狀態的雙向繫結

上面講了資料的單向繫結,元件狀態頁面也會隨之更新,那麼我們怎麼實現雙向繫結呢?

接下來我們封裝一個 input 來實現雙向繫結。

<cai-input id="ipt" :value="data" @change="(e) => { data = e.detail }"></cai-input>

// js
(function () {
  const template = document.createElement('template')
  template.innerHTML = `
  <style>
    .cai-input {

    }
  </style>
  <input type="text" id="caiInput">
  `
  class CaiInput extends HTMLElement {
    constructor() {
      super()
      const shadow = this.attachShadow({
        mode: 'closed'
      })
      const content = template.content.cloneNode(true)
      this._input = content.querySelector('#caiInput')
      this._input.value = this.getAttribute('value')
      shadow.appendChild(content)
      this._input.addEventListener("input", ev => {
        const target = ev.target;
        const value = target.value;
        this.value = value;
        this.dispatchEvent(new CustomEvent("change", { detail: value }));
      });
    }
    get value() {
      return this.getAttribute("value");
    }
    set value(value) {
      this.setAttribute("value", value);
    }
  }
  window.customElements.define('cai-input', CaiInput)
})()
  • 這樣就封裝了一個簡單雙向繫結的 input 元件,程式碼中 get/set 和 observedAttributes / attributeChangedCallback 前者是監聽單個,後者可以監聽多個狀態改變並做出處理。
  • 這裡面核心的一步是 我們監聽了這個表單的input事件,並且在每次觸發 input 事件的時候觸發 自定義的 change 事件 ,並且把輸入的引數回傳。
  • 那我們應該怎麼使用呢? 以 vue 為例子,vue 的雙向繫結 v-model 其實是一個語法糖, 我們的元件則沒有辦法使用這個語法糖,與 v-model 不簡化寫法類似 <cai-input :value="data" @change="(e) => { data = e.detail }">

封裝我們自己的元件庫

設計目錄結構

第一步:要有一個優雅的組價庫我們首先要設計一個優雅的目錄結構 設計目錄結構如下

.
└── cai-ui
    ├── components // 自定義元件
    |   ├── Button
        |       |     ├── index.js
    |   └── ...
    └── index.js.  // 主入口

獨立封裝

獨立封裝我們的元件,由於我們元件庫中元件的引入,我們肯定是需要把每個元件封裝到單獨檔案中的。

在我們的 Button/index.js 中寫入如下:

(function () {
  const template = document.createElement('template')
  template.innerHTML = `
  <style>
    /* css和上面一樣 */
  </style>
  <div class="cai-button"> <slot name="text"></slot> </div>
  `
  class CaiButton extends HTMLElement {
    constructor() {
      super()
            // 其餘和上述一樣
    }
    static get observedAttributes() {
      return ['type']
    }
    attributeChangedCallback(name, oldValue, newValue) {
      this[name] = newValue;
      this.render();
    }
    render() {
      this._btn.className = `cai-button ${this._type[this.type]}`
    }
  }
  window.customElements.define('cai-button', CaiButton)
})()

封裝到元件到單獨的 js 檔案中

全部匯入和按需匯入

  1. 支援全部匯入,我們通過一個 js 檔案全部引入元件
    // index.js
    import './components/Button/index.js'
    import './components/xxx/xxx.js'
  2. 按需匯入我們只需要匯入元件的js檔案即可如 import 'cai-ui/components/Button/index.js'

自定義配置主題

支援主題色可配置 我們只需把顏色寫成變數即可,改造如下:

(function () {
  const template = document.createElement('template')
  template.innerHTML = `
  <style>
        /* 多餘省略 */
    .cai-button {
      border: 1px solid  var(--primary-color, #1890ff);
      background-color: var(--primary-color, #1890ff);
    }
    .cai-button-warning {
      border: 1px solid var(--warning-color, #faad14);
      background-color: var(--warning-color, #faad14);
    }
    .cai-button-danger {
      border: 1px solid var(--danger-color, #ff4d4f);
      background-color: var(--danger-color, #ff4d4f);
    }  
  </style>
  <div class="cai-button"> <slot name="text"></slot> </div>
  `
  // 後面省略...
})()

這樣我們就能在全域性中修改主題色了。 案例地址

在原生、Vue 和 React 中優雅的使用

在原生 HTML 中應用:

<script type="module">
    import '//cai-ui';
</script>

<!--or-->
<script type="module" src="//cai-ui"></script>

<cai-button type="primary">點選</cai-button>
<cai-input id="caiIpt"></cai-button>
<script>
  const caiIpt = document.getElementById('caiIpt')
  /* 獲取輸入框的值有兩種方法
   * 1. getAttribute
   * 2. change 事件
  */
  caiIpt.getAttribute('value') 
  caiIpt.addEventListener('change', function(e) {
    console.log(e); // e.detail 為表單的值
  }) 
</script>

在 Vue 2x 中的應用:

// main.js
import 'cai-ui';

<template>
  <div id="app">
    <cai-button :type="type">
      <span slot="text">哈哈哈</span>
    </cai-button>
    <cai-button @click="changeType">
      <span slot="text">哈哈哈</span>
    </cai-button>
    <cai-input id="ipt" :value="data" @change="(e) => { data = e.detail }"></cai-input>
  </div>
</template>
<script>
export default {
  name: "App",
  components: {},
  data(){
    return {
      type: 'primary',
      data: '',
    }
  },
  methods: {
    changeType() {
      console.log(this.data);
      this.type = 'danger'
    }
  },
};
</script>

在 Vue 3x 中的差異:

在最近的 Vue3 中,Vue 對 WebComponents 有了更好的支援。Vue 在 Custom Elements Everywhere 測試中獲得了 100% 的完美分數 。但是還需要我們做出如下配置:

跳過 Vue 本身對元件的解析

custom Elements 的風格和 Vue 元件很像,導致 Vue 會把自定義(非原生的 HTML 標籤)標籤解析並註冊為一個 Vue 元件,然後解析失敗才會再解析為一個自定義元件,這樣會消耗一定的效能並且會在控制檯警告,因此我們需要在構建工具中跳過這個解析:

// vite.config.js
import vue from '@vitejs/plugin-vue'

export default {
  plugins: [
    vue({
      template: {
        compilerOptions: {
          // 將所有包含短橫線的標籤作為自定義元素處理
          isCustomElement: tag => tag.includes('-')
        }
      }
    })
  ]
}

元件的具體使用方法和 Vue 2x 類似。

在 React 中的應用

import React, { useEffect, useRef, useState } from 'react';
import 'cai-ui'

function App() {
  const [type, setType] = useState('primary');
  const [value, setValue] = useState();
  const iptRef = useRef(null)
  useEffect(() => {
    document.getElementById('ipt').addEventListener('change', function(e) {
      console.log(e);
    })
  }, [])
  const handleClick = () => {
    console.log(value);
    setType('danger')
  }
  return (
    <div className="App">
      <cai-button type={type}>
        <span slot="text">哈哈哈</span>
      </cai-button>
      <cai-button onClick={handleClick}>
        <span slot="text">點選</span>
      </cai-button>
      <cai-input id="ipt" ref={iptRef} value={value} ></cai-input>
    </div>
  );
}

export default App;

Web Components 觸發的事件可能無法通過 React 渲染樹正確的傳遞。 你需要在 React 元件中手動新增事件處理器來處理這些事件。 在 React 使用有個點我們需要注意下,WebComponents 元件我們需要新增類時需要使用 claas 而不是 className

總結現階段的劣勢

看完這篇文章大家肯定會覺得為什麼 WebComponents 實現了一份程式碼多個框架使用,卻還沒有霸佔元件庫的市場呢?我總結了一下幾點:

  • 更加偏向於 UI 層面,與現在資料驅動不太符,和現在的元件庫能力上相比功能會比較弱,使用場景相對單一。

  • 相容性還有待提升:這裡不僅僅指的是瀏覽器的相容性,還有框架的相容性,在框架中使用偶爾會發現意外的“驚喜”,並且寫法會比較複雜。

  • 如果不借助框架開發的話,寫法會返璞歸真,HTML CSS JS 會糅合在一個檔案,html CSS 都是字串的形式 ,沒有高亮,格式也需要自己調整,對於開發人員來說還是難受的。

  • 單元測試使用繁瑣:單元測試是元件庫核心的一項,但是在 WebComponents 中使用單元測試十分複雜。

    參考文件:

  • WebComponents | MDN

  • Vue 3.0 官方文件

  • React 官方文件

❉ 作者介紹 ❉