从零开始手写基于Web Components组件

2,121 阅读10分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情

先展示结果

image.png

最近Web Components又流行了起来,基于原生能力开发,能够运行在任何框架,听起来很不错,今天就来试试~

前言

不使用任何Web Components框架,只使用最基本的typescriptviteless构建基于Web Components的组件库,目前已实现ButtonIconImageTabsInputMessage

本文主要内容如下:

  • 初始化项目结构
  • 开发第一个Web Components组件——Button
  • 增加水波纹特效
  • 实现按钮的disabledtype属性功能。

看完一定有所收获

一.建立项目

初始化项目,进行开发和调试

1.初始化项目

pnpm init

安装typescript,vite,less,vite-plugin-dts(用于生成ts类型声明文件)

pnpm i typescript vite less vite-plugin-dts -D

项目目录结构

image.png

  • utils:存放一些公共函数
  • index.ts: 打包入口
// index.ts
import './components/button/index.ts';
  • components: 组件目录,新建了button目录,button目录下有index.tsindex.less
// components/button/index.ts
  class CustomElement extends HTMLElement {
      constructor() {
        super();
      }
  }
  customElements.define("r-button", CustomElement);
  • vite.config.ts: vite的配置文件
import { defineConfig } from "vite";
import { resolve } from "path";
import dts from 'vite-plugin-dts'

export default defineConfig({
  build: {
    minify: 'terser',
    sourcemap: true,
    lib: {
      entry: resolve(__dirname, "index.ts"),  // 配置入口文件路径
      name: "ranui",
      fileName: "index",
      formats: ["es", "umd"], // 打包生成的格式
    },
  },
  plugins: [dts()]
});

2.开发和调试

项目根目录中创建index.html,用于开发和调试,在head标签里面加入

 <script src="./index.ts" type="module"></script>

配置package.json脚步命令

"scripts": {
    "dev": "vite",
    "build": "vite build",
  },

使用pnpm dev启动服务时,会默认访问根目录下index.html,同时index.html又引入组件库的入口文件index.ts。如果执行无误的话,body标签里面就可以使用我们自定义的标签了。这就可以开发和调试了。

<body>
    <r-button>这是按钮</r-button>
</body>

自此项目结构搭建完毕,接下来就开始编写button组件代码。 开始写之前先简单回顾下Web Components的一些基础知识。

二.Web Components简述

简单总结开发Web Components的前置知识,首先是创建一个自定义的标签。完全掌握的建议跳过,直接跳到第三章。

1.注册自定义组件

    class CustomElement extends HTMLElement {
        constructor() {
            super(); // 必需
            // 组件被创建时执行的逻辑
        }
        // 组件的功能
        
    }
    customElements.define("r-button", CustomElement);

使用这个自定义组件

<r-button></r-button>

自定义组件名称必须使用中横线x-x。创建完了自定义标签,肯定需要加上一些自定义的逻辑和功能,这就会用到组件的生命周期,在对应的时刻,编写相应的逻辑。

2. 组件的生命周期

可以把一些逻辑放到对应的生命周期里,以便在特定的时刻执行。Web Components生命周期有constructor,attributeChangedCallback,connectedCallback,adoptedCallback,disconnectCallback

    class CustomElement extends HTMLElement {
        constructor() {
            super(); // 必需
            // 组件被创建时执行的逻辑
        }
        // 组件的功能
        attributeChangedCallback(){}
        connectedCallback(){}
        adoptedCallback(){}
        disconnectCallback(){}
    }
    customElements.define("r-button", CustomElement);

使用方法:

  • constructor: 元素创建时触发。
  • attributeChangedCallback: 当observedAttributes数组中的属性发生变化时触发。可获得发生变化的属性名,更新后的值,更新前的值。比如
 class CustomElement extends HTMLElement {
        static get observedAttributes() {
            return ['type'];
        }
        constructor() {
            super();
        }
        attributeChangedCallback(attribute, oldValue, newValue) {
            if(attribute === 'type') {
                console.log(newValue, oldValue);
            }
        }
    }
    customElements.define("r-button", CustomElement);
  • connectedCallback: 当元素被插入DOM树时触发,此时所有属性和子元素都可用。类似于React中的componentDidMount
  • disconnectCallback:元素从DOM中移除时会调用它。但用户关闭浏览器或者网页的时候,不会触发。
  • adoptedCallback元素通过调用document.adoptNode(element)被插入到文档时触发。目前没用到过。

执行顺序:

constructor->attributeChangedCallback->connectedCallback

3.template模版

如果经常用到同一段HTML代码时,可以用template标签包裹它。通过引用template标签的内容的达到复用的效果。

注意:1.一般都需要用cloneNode方法进行复制,避免引用的都是同一个。2.template标签包裹的内容不会显示在页面中。

下面是两种使用举例。

(1).typescript使用举例:

const template = document.createElement('template');
const slot = document.createElement('slot');
const div = document.createElement('div');
div.setAttribute('class', 'class');
slot.setAttribute('name', 'name');
div.appendChild(slot);
template.appendChild(div);

// 对template进行复制,就可以到处使用了
const content = div.cloneNode(true) as HTMLElement",

(2).HTML使用举例:

<template id="template">
  <div>template</div>
</template>
const template = document.getElementById('template');
const content = template.content.cloneNode(true);
document.body.appendChild(content);

4. 插槽slots

为了方便向组件内的指定位置传递内容,可以使用slot标签。使用方式也有两种,具名插槽和不具名插槽。

(1).具名插槽

具名插槽会有name属性,根据name属性来匹配插入的位置。

创建一个具有具名插槽的自定义组件r-button

 class Button extends HTMLElement {
    constructor() {
      super();
      const btn = document.createElement('div');
      btn.setAttribute('class', 'r-btn');
      const slot = document.createElement('slot')
      slot.setAttribute('name', 'r-btn_content')
      btn.appendChild(slot)
      const shadowRoot = this.attachShadow({ mode: 'closed' });
      shadowRoot.appendChild(btn);
     }
 }
 window.customElements.define('r-button', Button);

使用r-button标签

<r-button>
  <span slot="r-btn_content">这就是具名插槽的作用,根据name属性来匹配插入</span>
</r-button>

(2).不具名插槽

具名插槽用name属性来匹配总感觉不太灵活,尤其是编写button组件的时候,更希望是

<r-button>这是内容</r-button>

而不是

<r-button>
     <span slot="r-btn_content">这是内容</span>
</r-button>

因此,我们还可以这样使用

 class Button extends HTMLElement {
    _btn: HTMLDivElement;
    _iconElement?: HTMLElement;
    _slot: HTMLSlotElement;
    constructor() {
      super();
      this._slot = document.createElement("slot");
      this._btn = document.createElement("div");
      this._btn.setAttribute('class', 'btn')
      this._btn.appendChild(this._slot);
      this._slot.setAttribute("class", "slot");
      const shadowRoot = this.attachShadow({ mode: "closed" });
      shadowRoot.appendChild(this._btn);
     }
 }
 window.customElements.define('r-button', Button);

上述slot标签没有设置name属性,就可以这样使用

<r-button >这是内容</r-button>

5.Shadow DOM

在给组件增加功能时,往往会编写复杂的DOM结构。这时候就会用到Shadow DOM。它的作用是将一个隐藏的,独立的DOM附加到一个元素上,且具有良好的密封性。

(1).创建一个Shadow DOM到指定的元素上

使用例子:创建一个Shadow DOMCustomElement

 const shadowRoot = CustomElement.attachShadow({ mode: "closed" });

attachShadow方法

参数说明类型描述
optionsoptions是一个Object,有一个mode属性,值可以是open或者closed{mode: string}open表示可以通过页面JavaScript来获取shadow DOM ,closed表示不让用JavaScript获取

(2).如何操作shadow DOM

  1. 获取shadowRoot去操作shadow DOM。操作方式和操作普通DOM的方式一致,都是使用appendChildDOM方法。
const shadowRoot = CustomElement.shadowRoot;
  1. 利用css3变量去影响shadow DOM内的样式。

在开发button的水波纹特效时,就会用这个方式。

image.png

  1. shadow DOM中添加link或者script去链接其他资源

(3).shadow DOM在页面的样子:

image.png

6.customElements兼容性

customElements是创建自定义组件的必备方法,兼容性如下

image.png

三.编写最基本的Button组件

掌握了Web Components的基本知识,便开始开发最简单的button组件了。接下来分为,

  • 最基础的默认按钮,
  • 水波纹特效
  • 按钮typedisabled属性

1.默认按钮

<r-button>默认按钮</r-button>

image.png

首先按钮要能传入内容,所以肯定需要slot标签,具体操作如下

// components/button/index.ts

import styles from './index.less'
    class CustomElement extends HTMLElement {
      _slot: HTMLSlotElement;
      constructor() {
        super();
        this._slot = document.createElement("slot"); // 创建slot标签
        this._slot.setAttribute("class", "slot");
        const shadowRoot = this.attachShadow({ mode: "closed" });
        const style = document.createElement('style') // 创建样式标签
        style.textContent = styles // 将导入的less样式加入style标签
        shadowRoot.appendChild(style) // 将样式加入shadow DOM
        shadowRoot.appendChild(this._slot); // 将slot加入shadow DOM
      }
    }

编写基本样式,使用css伪类:host选择当前的自定义元素,样式分为下面几块。


:host {

  position: relative;
  display: inline-block;
  line-height: 22px;
  overflow: hidden;
  
  font-size: 14px;
  font-weight: 400;
  white-space: nowrap;
  text-align: center;
  
  border: 1px solid transparent;
  box-shadow: 0 2px #00000004;
  border-radius: 2px;
  border-color: #d9d9d9;

  color: #000000d9;
  background: #fff;
  
  cursor: pointer;
  pointer-events: auto;
  
}

index.htmlbody标签中加入

<r-button >默认按钮</r-button>

pnpm dev执行项目,查看页面和控制台,是否正确生效

image.png

至此,最简单的默认按钮开发完成

2.水波纹特效

button2.gif

(1). 获取点击的位置

由于水波纹特效需要从点击的中心往周围扩散,所以需要获取当前点击的位置。 通过mousedown方法,获取点击的坐标,设置在元素的style属性中。利用css3中的变量,传递到shadow DOM中的样式。

// components/button/index.ts
// 写在CustomElement类中
      mousedown = (event: MouseEvent) => {
          const { left, top } = this.getBoundingClientRect();
          this.style.setProperty("--ran-x", event.clientX - left + "px");
          this.style.setProperty("--ran-y", event.clientY - top + "px");
      };
      connectedCallback() {
        this._btn.addEventListener("mousedown", this.mousedown);
      }

点击的时候,可以看到页面效果

image.png

(2). 编写less样式

:host {
  position: relative; // 父元素需要设置相对定位
  // ...省略部分样式
  &::after {
    content: "";
    display: block;
    width: 100%;
    height: 100%;
    // 设置该元素的位置,搭配translate(-50%, -50%)居中
    position: absolute; 
    left: var(--ran-x, 0); 
    top: var(--ran-y, 0); 
    transform: translate(-50%, -50%) scale(10);
    pointer-events: none;
    // 设置背景颜色
    background-image: radial-gradient(circle, #1890ff 10%, transparent 10.01%);
    background-repeat: no-repeat;
    opacity: 0;
    // 设置动画,搭配transform放大十倍效果,颜色由深到浅
    transition: transform 0.3s, opacity 0.8s; 
  }
}

(3).增加移除监听

// components/button/index.ts
// 写在CustomElement类中
     mouseLeave = () => {
        this.style.removeProperty("--ran-x");
        this.style.removeProperty("--ran-y");
      };
     connectedCallback() {
        this._btn.addEventListener("mousedown", this.mousedown);
        this._btn.addEventListener("mouseleave", this.mouseLeave);
      }

3.增加按钮typedisabled属性

(1).type属性

希望按钮有四种类型

image.png

由于是通过元素的type属性来判断类型,所以首先要获取元素的type属性,且需要监听元素的type属性变化

// components/button/index.ts
    class CustomElement extends HTMLElement {
      static get observedAttributes() {
        return ["type"];
      }
      constructor() {
      // ...
      }
      // 获取元素上的type属性
      get type() {
        return this.getAttribute('type')
      }
      // 设置元素上type属性
      set type(value) {
        if (value) {
          this.setAttribute('type', value)
        }
      }
      attributeChangedCallback(name: string, oldValue: string, newValue: string) {
          if(name === 'type'){
              // 这里写上当触发元素type属性变化是,需要进行的操作
              // 比如新设置的type属性和旧的type属性不一样,进行设置
              // 目前type属性只和样式有关,所以这些不是必须的,可以不用进行设置
              if (newValue && newValue !== oldValue ) {
                this.setAttribute('type', newValue)
              }
        }
      }
    }

其实目前type属性只和样式有关,所以这不是必须的,可以不用进行设置。但后续的复杂属性,可以这样进行操作。本次我们更关心样式,根据不同的属性设置不同的样式,可以通过css来判断

  &([type="primary"]) {
    border-color: #1890ff;
    background-color: #1890ff;
    color: #fff;
  }
  &([type="warning"]) {
    border-color: #ff4d4f;
    background-color: #ff4d4f;
    color: #fff;
  }
  &([type="text"]) {
    border: none;
  }

hover的时候,边框颜色会发生变化,且不同类型的按钮,边框变化颜色也不同

 &(:hover) {
    border-color: #1890ff;
    color: #1890ff;
  }
   &([type="primary"]:hover) {
    background-color: #40a9ff;
    color: #fff;
  }
   &([type="warning"]:hover) {
    border-color: #ff4d4f;
    background-color: #ff4d4f;
    color: #fff;
  }

不同类型的按钮,点击触发的水波纹特效颜色不同,所以这里只需要设置颜色的区别

&([type="primary"]::after {
    background-image: radial-gradient(circle, #fff 10%, transparent 10.01%);
}
&([type="warning"]::after {
    background-image: radial-gradient(circle, #fff 10%, transparent 10.01%);
}

(2).disabled属性

disabled的属性和type属性的开发过程差不多,区别在于,disabled属性是可以不需要值的,比如

 <r-button type="primary" disabled>disable按钮</r-button>

但同时也需要兼容下有值的情况

  • disabled设置true,或者直接给元素添加disabled属性,或者给disabled属性设置任意不等于false的值,都算添加成功。
  • disabled设置false"false",undefined,null,就算是取消按钮的disabled。比如
 <r-button type="primary" disabled="false">disable按钮</r-button>
 <r-button type="primary" disabled="undefined">disable按钮</r-button>

所以这一块我们可以抽取出来

  1. 封装一个判断方法
  2. 统一其他组件disabled功能的判断逻辑

我们先在utils中编写统一判断disabledfalse的情况

// utils/index.ts
export const falseList = [false, "false", null, undefined];
/**
 * @description: 判断这个元素上是否有disabled属性
 * @param {Element} element
 * @return {*}
 */
export const isDisabled = (element: Element) => {
  const status = element.hasAttribute("disabled");
  const value = element.getAttribute("disabled");
  if (status && !falseList.includes(value)) return true;
  return false;
};

在元素上设置disabled和获取disabled的值

// components/button/index.ts
      get disabled() {
        return isDisabled(this)
      }
      set disabled(value: boolean | string | undefined | null) {
        if (!value || value === "false") {
          this.removeAttribute("disabled");
        } else {
          this.setAttribute("disabled", '');
        }
      }

逻辑部分写完,接下来是对disabled的样式处理

// components/button/index.less
&([disabled]) {
    cursor: not-allowed;
    pointer-events: all;
    opacity: 0.6;
  }

由于disabledtype属性是可以并存的,所以这里也需要做一些兼容处理

// components/button/index.less

 &([type="primary"]:not([disabled]))::after {
    background-image: radial-gradient(circle, #fff 10%, transparent 10.01%);
  }
  &([type="primary"]:not([disabled]):hover) {
    background-color: #40a9ff;
    color: #fff;
  }

对于水波纹特效,disabled的时候也肯定没有了


     mousedown = (event: MouseEvent) => {
        if (!this.disabled || this.disabled === 'false') {
          const { left, top } = this.getBoundingClientRect();
          this.style.setProperty("--ran-x", event.clientX - left + "px");
          this.style.setProperty("--ran-y", event.clientY - top + "px");
        }
      };

结束

以上就是本文的全部内容了,目前已经开发了ButtonIconImageTabsInputMessage组件,完整代码如下:github.com/chaxus/ran 且已添加MIT协议,供大家随意参考和使用。如果有所收获的话,希望能点个star,点个赞。同时欢迎issue,pr和评论。一起学习,一起进步~

其他推荐