1.2w字 | 從 0 到 1 上手 Web Components 業務元件庫開發

pingan8787發表於2021-12-24

元件化是前端發展的一個重要方向,它一方面提高開發效率,另一方面降低維護成本。主流的 Vue.js、React 及其延伸的 Ant Design、uniapp、Taro 等都是元件框架。

Web Components 是一組 Web 原生 API 的總稱,允許我們建立可重用的自定義元件,並在我們 Web 應用中像使用原生 HTML 標籤一樣使用。目前已經很多前端框架/庫支援 Web Components

本文將帶大家回顧 Web Components 核心 API,並從 0 到 1 實現一個基於 Web Components API 開發的業務元件庫。

最終效果:https://blog.pingan8787.com/exe-components/demo.html
倉庫地址:https://github.com/pingan8787/Learn-Web-Components

一、回顧 Web Components

在前端發展歷史中,從剛開始重複業務到處複製相同程式碼,到 Web Components 的出現,我們使用原生 HTML 標籤的自定義元件,複用元件程式碼,提高開發效率。通過 Web Components 建立的元件,幾乎可以使用在任何前端框架中。

1. 核心 API 回顧

Web Components 由 3 個核心 API 組成:

  • Custom elements(自定義元素):用來讓我們定義自定義元素及其行為,對外提供元件的標籤;
  • Shadow DOM(影子 DOM):用來封裝元件內部的結構,避免與外部衝突;
  • HTML templates(HTML 模版):包括 <template><slot> 元素,讓我們可以定義各種元件的 HTML 模版,然後被複用到其他地方,使用過 Vue/React 等框架的同學應該會很熟悉。
另外,還有 HTML imports,但目前已廢棄,所以不具體介紹,其作用是用來控制元件的依賴載入。

2. 入門示例

接下來通過下面簡單示例快速瞭解一下如何建立一個簡單 Web Components 元件

  • 使用元件
<!DOCTYPE html>
<html lang="en">
<head>
    <script src="./index.js" defer></script>
</head>
<body>
    <h1>custom-element-start</h1>
    <custom-element-start></custom-element-start>
</body>
</html>
  • 定義元件
/**
 * 使用 CustomElementRegistry.define() 方法用來註冊一個 custom element
 * 引數如下:
 * - 元素名稱,符合 DOMString 規範,名稱不能是單個單詞,且必須用短橫線隔開
 * - 元素行為,必須是一個類
 * - 繼承元素,可選配置,一個包含 extends 屬性的配置物件,指定建立的元素繼承自哪個內建元素,可以繼承任何內建元素。
 */

class CustomElementStart extends HTMLElement {
    constructor(){
        super();
        this.render();
    }
    render(){
        const shadow = this.attachShadow({mode: 'open'});
        const text = document.createElement("span");
        text.textContent = 'Hi Custom Element!';
        text.style = 'color: red';
        shadow.append(text);
    }
}

customElements.define('custom-element-start', CustomElementStart)

上面程式碼主要做 3 件事:

  1. 實現元件類

通過實現 CustomElementStart 類來定義元件。

  1. 定義元件

將元件的標籤和元件類作為引數,通過 customElements.define 方法定義元件。

  1. 使用元件

匯入元件後,跟使用普通 HTML 標籤一樣直接使用自定義元件 <custom-element-start></custom-element-start>

隨後瀏覽器訪問 index.html 可以看到下面內容:

3. 相容性介紹

MDN | Web Components 章節中介紹了其相容性情況:

  • Firefox(版本63)、Chrome和Opera都預設支援Web元件。
  • Safari支援許多web元件特性,但比上述瀏覽器少。
  • Edge正在開發一個實現。

關於相容性,可以看下圖:

圖片來源:https://www.webcomponents.org/

這個網站裡面,有很多關於 Web Components 的優秀專案可以學習。

4. 小結

這節主要通過一個簡單示例,簡單回顧基礎知識,詳細可以閱讀文件:

二、EXE-Components 元件庫分析設計

1. 背景介紹

假設我們需要實現一個 EXE-Components 元件庫,該元件庫的元件分 2 大類:

  1. components 型別

通用簡單元件為主,如exe-avatar頭像元件、 exe-button按鈕元件等;

  1. modules 型別

複雜、組合元件為主,如exe-user-avatar使用者頭像元件(含使用者資訊)、exe-attachement-list附件列表元件等等。

詳細可以看下圖:

接下來我們會基於上圖進行 EXE-Components 元件庫設計和開發。

2. 元件庫設計

在設計元件庫的時候,主要需要考慮以下幾點:

  1. 元件命名、引數命名等規範,方便元件後續維護;
  2. 元件引數定義;
  3. 元件樣式隔離;

當然,這幾個是最基礎需要考慮的點,隨著實際業務的複雜,還需要考慮更多,比如:工程化相關、元件解耦、元件主題等等。

針對前面提到這 3 點,這邊約定幾個命名規範:

  1. 元件名稱以 exe-功能名稱 進行命名,如 exe-avatar表示頭像元件;
  2. 屬性引數名稱以 e-引數名稱 進行命名,如 e-src 表示 src 地址屬性;
  3. 事件引數名稱以 on-事件型別 進行命名,如 on-click表示點選事件;

3. 元件庫元件設計

這邊我們主要設計 exe-avatarexe-buttonexe-user-avatar三個元件,前兩個為簡單元件,後一個為複雜元件,其內部使用了前兩個元件進行組合。這邊先定義這三個元件支援的屬性:

這邊屬性命名看著會比較複雜,大家可以按照自己和團隊的習慣進行命名。

這樣我們思路就清晰很多,實現對應元件即可。

三、EXE-Components 元件庫準備工作

本文示例最終將對實現的元件進行組合使用,實現下面「使用者列表」效果:

體驗地址:https://blog.pingan8787.com/exe-components/demo.html

1. 統一開發規範

首先我們先統一開發規範,包括:

  1. 目錄規範

  1. 定義元件規範

  1. 元件開發模版

元件開發模版分 index.js元件入口檔案template.js 元件 HTML 模版檔案

// index.js 模版
const defaultConfig = {
    // 元件預設配置
}

const Selector = "exe-avatar"; // 元件標籤名

export default class EXEAvatar extends HTMLElement {
    shadowRoot = null;
    config = defaultConfig;

    constructor(){
        super();
        this.render(); // 統一處理元件初始化邏輯
    }

    render() {
        this.shadowRoot = this.attachShadow({mode: 'closed'});
        this.shadowRoot.innerHTML = renderTemplate(this.config);
    }
}

// 定義元件
if (!customElements.get(Selector)) {
    customElements.define(Selector, EXEAvatar)
}
// template.js 模版

export default config => {
    // 統一讀取配置
    const { avatarWidth, avatarRadius, avatarSrc } = config;
    return `
        <style>
            /* CSS 內容 */
        </style>
        <div class="exe-avatar">
            /* HTML 內容 */
        </div>
    `
}

2. 開發環境搭建和工程化處理

為了方便使用 EXE-Components 元件庫,更接近實際元件庫的使用,我們需要將元件庫打包成一個 UMD 型別的 js 檔案。這邊我們使用 rollup 進行構建,最終打包成 exe-components.js 的檔案,使用方式如下:

<script src="./exe-components.js"></script>

接下來通過 npm init -y生成 package.json檔案,然後全域性安裝 rollup 和 http-server(用來啟動本地伺服器,方便除錯):

npm init -y
npm install --global rollup http-server

然後在 package.jsonscript 下新增 "dev""build"指令碼:

{
    // ...
  "scripts": {
    "dev": "http-server -c-1 -p 1400",
    "build": "rollup index.js --file exe-components.js --format iife"
  },
}

其中:

  • "dev" 命令:通過 http-server 啟動靜態伺服器,作為開發環境使用。新增 -c-1 引數用來禁用快取,避免重新整理頁面還會有快取,詳細可以看 http-server 文件
  • "build"命令:將 index.js 作為 rollup 打包的入口檔案,輸出 exe-components.js 檔案,並且是 iife 型別的檔案。

這樣就完成簡單的本地開發和元件庫構建的工程化配置,接下來就可以進行開發了。

四、EXE-Components 元件庫開發

1. 元件庫入口檔案配置

前面 package.json 檔案中配置的 "build" 命令,會使用根目錄下 index.js 作為入口檔案,並且為了方便 components 通用基礎元件和 modules 通用複雜元件的引入,我們建立 3 個 index.js,建立後目錄結構如下:

三個入口檔案內容分別如下:

// EXE-Components/index.js
import './components/index.js';
import './modules/index.js';

// EXE-Components/components/index.js
import './exe-avatar/index.js';
import './exe-button/index.js';

// EXE-Components/modules/index.js
import './exe-attachment-list/index.js.js';
import './exe-comment-footer/index.js.js';
import './exe-post-list/index.js.js';
import './exe-user-avatar/index.js';

2. 開發 exe-avatar 元件 index.js 檔案

通過前面的分析,我們可以知道 exe-avatar元件需要支援引數:

  • e-avatar-src:頭像圖片地址,例如:./testAssets/images/avatar-1.png
  • e-avatar-width:頭像寬度,預設和高度一致,例如:52px
  • e-button-radius:頭像圓角,例如:22px,預設:50%
  • on-avatar-click:頭像點選事件,預設無

接著按照之前的模版,開發入口檔案 index.js

// EXE-Components/components/exe-avatar/index.js
import renderTemplate from './template.js';
import { Shared, Utils } from '../../utils/index.js';

const { getAttributes } = Shared;
const { isStr, runFun } = Utils;

const defaultConfig = {
    avatarWidth: "40px",
    avatarRadius: "50%",
    avatarSrc: "./assets/images/default_avatar.png",
    onAvatarClick: null,
}

const Selector = "exe-avatar";

export default class EXEAvatar extends HTMLElement {
    shadowRoot = null;
    config = defaultConfig;

    constructor(){
        super();
        this.render();
    }

    render() {
        this.shadowRoot = this.attachShadow({mode: 'closed'});
        this.shadowRoot.innerHTML = renderTemplate(this.config);// 生成 HTML 模版內容
    }

        // 生命週期:當 custom element首次被插入文件DOM時,被呼叫。
    connectedCallback() {
        this.updateStyle();
        this.initEventListen();
    }

    updateStyle() {
        this.config = {...defaultConfig, ...getAttributes(this)};
        this.shadowRoot.innerHTML = renderTemplate(this.config); // 生成 HTML 模版內容
    }

    initEventListen() {
        const { onAvatarClick } = this.config;
        if(isStr(onAvatarClick)){ // 判斷是否為字串
            this.addEventListener('click', e => runFun(e, onAvatarClick));
        }
    }
}

if (!customElements.get(Selector)) {
    customElements.define(Selector, EXEAvatar)
}

其中有幾個方法是抽取出來的公用方法,大概介紹下其作用,具體可以看原始碼:

  • renderTemplate 方法

來自 template.js 暴露的方法,傳入配置 config,來生成 HTML 模版。

  • getAttributes 方法

傳入一個 HTMLElement 元素,返回該元素上所有屬性鍵值對,其中會對 e-on- 開頭的屬性,分別處理成普通屬性和事件屬性,示例如下:

// input
<exe-avatar
    e-avatar-src="./testAssets/images/avatar-1.png"
    e-avatar-width="52px"
    e-avatar-radius="22px"
    on-avatar-click="avatarClick()"
></exe-avatar>
  
// output
{
  avatarSrc: "./testAssets/images/avatar-1.png",
  avatarWidth: "52px",
  avatarRadius: "22px",
  avatarClick: "avatarClick()"
}
  • runFun方法

由於通過屬性傳遞進來的方法,是個字串,所以進行封裝,傳入 event 和事件名稱作為引數,呼叫該方法,示例和上一步一樣,會執行 avatarClick() 方法。

另外,Web Components 生命週期可以詳細看文件:使用生命週期回撥函式

3. 開發 exe-avatar 元件 template.js 檔案

該檔案暴露一個方法,返回元件 HTML 模版:

// EXE-Components/components/exe-avatar/template.js
export default config => {
  const { avatarWidth, avatarRadius, avatarSrc } = config;
  return `
    <style>
      .exe-avatar {
        width: ${avatarWidth};
        height: ${avatarWidth};
        display: inline-block;
        cursor: pointer;
      }
      .exe-avatar .img {
        width: 100%;
        height: 100%;
        border-radius: ${avatarRadius};
        border: 1px solid #efe7e7;
      }
    </style>
    <div class="exe-avatar">
      <img class="img" src="${avatarSrc}" />
    </div>
  `
}

最終實現效果如下:

開發完第一個元件,我們可以簡單總結一下建立和使用元件的步驟:

4. 開發 exe-button 元件

按照前面 exe-avatar元件開發思路,可以很快實現 exe-button 元件。
需要支援下面引數:

  • e-button-radius:按鈕圓角,例如:8px
  • e-button-type:按鈕型別,例如:default, primary, text, dashed
  • e-button-text:按鈕文字,預設:開啟
  • on-button-click:按鈕點選事件,預設無
// EXE-Components/components/exe-button/index.js
import renderTemplate from './template.js';
import { Shared, Utils } from '../../utils/index.js';

const { getAttributes } = Shared;
const { isStr, runFun } = Utils;
const defaultConfig = {
    buttonRadius: "6px",
    buttonPrimary: "default",
    buttonText: "開啟",
    disableButton: false,
    onButtonClick: null,
}

const Selector = "exe-button";

export default class EXEButton extends HTMLElement {
    // 指定觀察到的屬性變化,attributeChangedCallback 會起作用
    static get observedAttributes() { 
        return ['e-button-type','e-button-text', 'buttonType', 'buttonText']
    }

    shadowRoot = null;
    config = defaultConfig;

    constructor(){
        super();
        this.render();
    }

    render() {
        this.shadowRoot = this.attachShadow({mode: 'closed'});
    }

    connectedCallback() {
        this.updateStyle();
        this.initEventListen();
    }

    attributeChangedCallback (name, oldValue, newValue) {
        // console.log('屬性變化', name)
    }

    updateStyle() {
        this.config = {...defaultConfig, ...getAttributes(this)};
        this.shadowRoot.innerHTML = renderTemplate(this.config);
    }

    initEventListen() {
        const { onButtonClick } = this.config;
        if(isStr(onButtonClick)){
            const canClick = !this.disabled && !this.loading
            this.addEventListener('click', e => canClick && runFun(e, onButtonClick));
        }
    }

    get disabled () {
        return this.getAttribute('disabled') !== null;
    }

    get type () {
        return this.getAttribute('type') !== null;
    }

    get loading () {
        return this.getAttribute('loading') !== null;
    }
}

if (!customElements.get(Selector)) {
    customElements.define(Selector, EXEButton)
}

模版定義如下:

// EXE-Components/components/exe-button/tempalte.js
// 按鈕邊框型別
const borderStyle = { solid: 'solid', dashed: 'dashed' };

// 按鈕型別
const buttonTypeMap = {
    default: { textColor: '#222', bgColor: '#FFF', borderColor: '#222'},
    primary: { textColor: '#FFF', bgColor: '#5FCE79', borderColor: '#5FCE79'},
    text: { textColor: '#222', bgColor: '#FFF', borderColor: '#FFF'},
}

export default config => {
    const { buttonRadius, buttonText, buttonType } = config;

    const borderStyleCSS = buttonType 
        && borderStyle[buttonType] 
        ? borderStyle[buttonType] 
        : borderStyle['solid'];

    const backgroundCSS = buttonType 
        && buttonTypeMap[buttonType] 
        ? buttonTypeMap[buttonType] 
        : buttonTypeMap['default'];

    return `
        <style>
            .exe-button {
                border: 1px ${borderStyleCSS} ${backgroundCSS.borderColor};
                color: ${backgroundCSS.textColor};
                background-color: ${backgroundCSS.bgColor};
                font-size: 12px;
                text-align: center;
                padding: 4px 10px;
                border-radius: ${buttonRadius};
                cursor: pointer;
                display: inline-block;
                height: 28px;
            }
            :host([disabled]) .exe-button{ 
                cursor: not-allowed; 
                pointer-events: all; 
                border: 1px solid #D6D6D6;
                color: #ABABAB;
                background-color: #EEE;
            }
            :host([loading]) .exe-button{ 
                cursor: not-allowed; 
                pointer-events: all; 
                border: 1px solid #D6D6D6;
                color: #ABABAB;
                background-color: #F9F9F9;
            }
        </style>
        <button class="exe-button">${buttonText}</button>
    `
}

最終效果如下:

5. 開發 exe-user-avatar 元件

該元件是將前面 exe-avatar 元件和 exe-button 元件進行組合,不僅需要支援點選事件,還需要支援插槽 slot 功能

由於是做組合,所以開發起來比較簡單~先看看入口檔案:

// EXE-Components/modules/exe-user-avatar/index.js

import renderTemplate from './template.js';
import { Shared, Utils } from '../../utils/index.js';

const { getAttributes } = Shared;
const { isStr, runFun } = Utils;

const defaultConfig = {
    userName: "",
    subName: "",
    disableButton: false,
    onAvatarClick: null,
    onButtonClick: null,
}

export default class EXEUserAvatar extends HTMLElement {
    shadowRoot = null;
    config = defaultConfig;

    constructor() {
        super();
        this.render();
    }

    render() {
        this.shadowRoot = this.attachShadow({mode: 'open'});
    }

    connectedCallback() {
        this.updateStyle();
        this.initEventListen();
    }

    initEventListen() {
        const { onAvatarClick } = this.config;
        if(isStr(onAvatarClick)){
            this.addEventListener('click', e => runFun(e, onAvatarClick));
        }
    }

    updateStyle() {
        this.config = {...defaultConfig, ...getAttributes(this)};
        this.shadowRoot.innerHTML = renderTemplate(this.config);
    }
}

if (!customElements.get('exe-user-avatar')) {
    customElements.define('exe-user-avatar', EXEUserAvatar)
}

主要內容在 template.js 中:

// EXE-Components/modules/exe-user-avatar/template.js

import { Shared } from '../../utils/index.js';

const { renderAttrStr } = Shared;

export default config => {
    const { 
        userName, avatarWidth, avatarRadius, buttonRadius, 
        avatarSrc, buttonType = 'primary', subName, buttonText, disableButton,
        onAvatarClick, onButtonClick
    } = config;
    return `
        <style>
            :host{
                color: "green";
                font-size: "30px";
            }
            .exe-user-avatar {
                display: flex;
                margin: 4px 0;
            }
            .exe-user-avatar-text {
                font-size: 14px;
                flex: 1;
            }
            .exe-user-avatar-text .text {
                color: #666;
            }
            .exe-user-avatar-text .text span {
                display: -webkit-box;
                -webkit-box-orient: vertical;
                -webkit-line-clamp: 1;
                overflow: hidden;
            }
            exe-avatar {
                margin-right: 12px;
                width: ${avatarWidth};
            }
            exe-button {
                width: 60px;
                display: flex;
                justify-content: end;
            }
        </style>
        <div class="exe-user-avatar">
            <exe-avatar
                ${renderAttrStr({
                    'e-avatar-width': avatarWidth,
                    'e-avatar-radius': avatarRadius,
                    'e-avatar-src': avatarSrc,
                })}
            ></exe-avatar>
            <div class="exe-user-avatar-text">
                <div class="name">
                    <span class="name-text">${userName}</span>
                    <span class="user-attach">
                        <slot name="name-slot"></slot>
                    </span>
                </div>
                <div class="text">
                    <span class="name">${subName}<slot name="sub-name-slot"></slot></span>
                </div>
            </div>
            ${
                !disableButton && 
                `<exe-button
                    ${renderAttrStr({
                        'e-button-radius' : buttonRadius,
                        'e-button-type' : buttonType,
                        'e-button-text' : buttonText,
                        'on-avatar-click' : onAvatarClick,
                        'on-button-click' : onButtonClick,
                    })}
                ></exe-button>`
            }

        </div>
    `
}

其中 renderAttrStr 方法接收一個屬性物件,返回其鍵值對字串:

// input
{
  'e-avatar-width': 100,
  'e-avatar-radius': 50,
  'e-avatar-src': './testAssets/images/avatar-1.png',
}
  
// output
"e-avatar-width='100' e-avatar-radius='50' e-avatar-src='./testAssets/images/avatar-1.png' "

最終效果如下:

6. 實現一個使用者列表業務

接下來我們通過一個實際業務,來看看我們元件的效果:


其實實現也很簡單,根據給定資料,然後迴圈使用元件即可,假設有以下使用者資料:

const users = [
  {"name":"前端早早聊","desc":"幫 5000 個前端先跑 @ 前端早早聊","level":6,"avatar":"qdzzl.jpg","home":"https://juejin.cn/user/712139234347565"}
  {"name":"來自拉夫德魯的碼農","desc":"誰都不救我,誰都救不了我,就像我救不了任何人一樣","level":2,"avatar":"lzlfdldmn.jpg","home":"https://juejin.cn/user/994371074524862"}
  {"name":"黑色的楓","desc":"永遠懷著一顆學徒的心。。。","level":3,"avatar":"hsdf.jpg","home":"https://juejin.cn/user/2365804756348103"}
  {"name":"captain_p","desc":"目的地很美好,路上的風景也很好。今天增長見識了嗎","level":2,"avatar":"cap.jpg","home":"https://juejin.cn/user/2532902235026439"}
  {"name":"CUGGZ","desc":"文章聯絡微信授權轉載。微信:CUG-GZ,新增好友一起學習~","level":5,"avatar":"cuggz.jpg","home":"https://juejin.cn/user/3544481220801815"}
  {"name":"政採雲前端團隊","desc":"政採雲前端 ZooTeam 團隊,不摻水的原創。 團隊站點:https://zoo.team","level":6,"avatar":"zcy.jpg","home":"https://juejin.cn/user/3456520257288974"}
]

我們就可以通過簡單 for 迴圈拼接 HTML 片段,然後新增到頁面某個元素中:

// 測試生成使用者列表模版
const usersTemp = () => {
    let temp = '', code = '';
    users.forEach(item => {
        const {name, desc, level, avatar, home} = item;
        temp += 
`
<exe-user-avatar 
    e-user-name="${name}"
    e-sub-name="${desc}"
    e-avatar-src="./testAssets/images/users/${avatar}"
    e-avatar-width="36px"
    e-button-type="primary"
    e-button-text="關注"
    on-avatar-click="toUserHome('${home}')"
    on-button-click="toUserFollow('${name}')"
>
${
    level >= 0 && `<span slot="name-slot">
        <span class="medal-item">(Lv${level})</span>
    </span>`}
</exe-user-avatar>
`
})
    return temp;
}

document.querySelector('#app').innerHTML = usersTemp;

到這邊我們就實現了一個使用者列表的業務,當然實際業務可能會更加複雜,需要再優化。

五、總結

本文首先簡單回顧 Web Components 核心 API,然後對元件庫需求進行分析設計,再進行環境搭建和開發,內容比較多,可能沒有每一點都講到,還請大家看看我倉庫的原始碼,有什麼問題歡迎和我討論。

寫本文的幾個核心目的:

  1. 當我們接到一個新任務的時候,需要從分析設計開始,再到開發,而不是盲目一上來就開始開發;
  2. 帶大家一起看看如何用 Web Components 開發簡單的業務元件庫;
  3. 體驗一下 Web Components 開發元件庫有什麼缺點(就是要寫的東西太多了)。

最後看完本文,大家是否覺得用 Web Components 開發元件庫,實在有點複雜?要寫的太多了。
沒關係,下一篇我將帶大家一起使用 Stencil 框架開發 Web Components 標準的元件庫,畢竟整個 ionic 已經是使用 Stencil 重構,Web Components 大勢所趨~!

擴充閱讀

相關文章