如何基於 WebComponents 封裝 UI 組件庫

如何基於 WebComponents 封裝 UI 組件庫

https://www.zoo.team/article/web-components

前言

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

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

在這個例子用我們使用了 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)
})()

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

因此我們需要換一個思路,我們上面使用的方式都是 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)
})()

封裝我們自己的組件庫

設計目錄結構

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

.
└── 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 ></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'
  1. 按需導入我們只需要導入組件的 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 ></slot> </div>
  `
  // 後面省略...
})()

這樣我們就能在全局中修改主題色了。案例地址 (https://github.com/lovelts/cai-ui)

在原生、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% 的完美分數 (https://custom-elements-everywhere.com/libraries/vue/results/results.html)。但是還需要我們做出如下配置:

跳過 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 class>
      <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 實現了一份代碼多個框架使用,卻還沒有霸佔組件庫的市場呢?我總結了以下幾點:

參考文檔:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/UTGADsfmJXSg5zGik4Rb5g