Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

【实战】从 0 到 1 上手 Web Components 业务组件库开发 #52

Open
pingan8787 opened this issue Dec 23, 2021 · 0 comments
Open

Comments

@pingan8787
Copy link
Owner

组件化是前端发展的一个重要方向,它一方面提高开发效率,另一方面降低维护成本。主流的 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,但目前已废弃,所以不具体介绍,其作用是用来控制组件的依赖加载。

image

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 可以看到下面内容:
image

3. 兼容性介绍

MDN | Web Components 章节中介绍了其兼容性情况:

  • Firefox(版本63)、Chrome和Opera都默认支持Web组件。
  • Safari支持许多web组件特性,但比上述浏览器少。
  • Edge正在开发一个实现。

关于兼容性,可以看下图:
image
图片来源:https://www.webcomponents.org/

这个网站里面,有很多关于 Web Components 的优秀项目可以学习。

4. 小结

这节主要通过一个简单示例,简单回顾基础知识,详细可以阅读文档:

image

二、EXE-Components 组件库分析设计

1. 背景介绍

假设我们需要实现一个 EXE-Components 组件库,该组件库的组件分 2 大类:

  1. components 类型

通用简单组件为主,如exe-avatar头像组件、 exe-button按钮组件等;

  1. modules 类型

复杂、组合组件为主,如exe-user-avatar用户头像组件(含用户信息)、exe-attachement-list附件列表组件等等。

详细可以看下图:

image

接下来我们会基于上图进行 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三个组件,前两个为简单组件,后一个为复杂组件,其内部使用了前两个组件进行组合。这边先定义这三个组件支持的属性:

image

这边属性命名看着会比较复杂,大家可以按照自己和团队的习惯进行命名。

这样我们思路就清晰很多,实现对应组件即可。

三、EXE-Components 组件库准备工作

本文示例最终将对实现的组件进行组合使用,实现下面「用户列表」效果:

image

体验地址:https://blog.pingan8787.com/exe-components/demo.html

1. 统一开发规范

首先我们先统一开发规范,包括:

  1. 目录规范

image

  1. 定义组件规范

image

  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,创建后目录结构如下:

image

三个入口文件内容分别如下:

// 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>
  `
}

最终实现效果如下:

image

开发完第一个组件,我们可以简单总结一下创建和使用组件的步骤:

image

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>
    `
}

最终效果如下:

image

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' "

最终效果如下:

image

6. 实现一个用户列表业务

接下来我们通过一个实际业务,来看看我们组件的效果:

image
其实实现也很简单,根据给定数据,然后循环使用组件即可,假设有以下用户数据:

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 大势所趋~!

拓展阅读

@pingan8787 pingan8787 changed the title 从 0 到 1 上手 Web Components 业务组件库开发 【实战】从 0 到 1 上手 Web Components 业务组件库开发 Dec 25, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant