开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情
先展示结果
- 组件说明文档:chaxus.github.io/ran/src/ran…
- 完整代码:github.com/chaxus/ran
最近Web Components
又流行了起来,基于原生能力开发,能够运行在任何框架,听起来很不错,今天就来试试~
前言
不使用任何Web Components
框架,只使用最基本的typescript
,vite
,less
构建基于Web Components
的组件库,目前已实现Button
,Icon
,Image
,Tabs
,Input
,Message
。
本文主要内容如下:
- 初始化项目结构
- 开发第一个
Web Components
组件——Button
- 增加水波纹特效
- 实现按钮的
disabled
和type
属性功能。
看完一定有所收获
一.建立项目
初始化项目,进行开发和调试
1.初始化项目
pnpm init
安装typescript
,vite
,less
,vite-plugin-dts
(用于生成ts
类型声明文件)
pnpm i typescript vite less vite-plugin-dts -D
项目目录结构
utils
:存放一些公共函数index.ts
: 打包入口
// index.ts
import './components/button/index.ts';
components
: 组件目录,新建了button
目录,button
目录下有index.ts
和index.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 DOM
在CustomElement
上
const shadowRoot = CustomElement.attachShadow({ mode: "closed" });
attachShadow
方法
参数 | 说明 | 类型 | 描述 |
---|---|---|---|
options | options 是一个Object ,有一个mode 属性,值可以是open 或者closed | {mode: string } | open 表示可以通过页面JavaScript 来获取shadow DOM ,closed 表示不让用JavaScript 获取 |
(2).如何操作shadow DOM
- 获取
shadowRoot
去操作shadow DOM
。操作方式和操作普通DOM
的方式一致,都是使用appendChild
等DOM
方法。
const shadowRoot = CustomElement.shadowRoot;
- 利用
css3
变量去影响shadow DOM
内的样式。
在开发button
的水波纹特效时,就会用这个方式。
- 在
shadow DOM
中添加link
或者script
去链接其他资源
(3).shadow DOM
在页面的样子:
6.customElements
兼容性
customElements
是创建自定义组件的必备方法,兼容性如下
三.编写最基本的Button
组件
掌握了Web Components
的基本知识,便开始开发最简单的button
组件了。接下来分为,
- 最基础的默认按钮,
- 水波纹特效
- 按钮
type
和disabled
属性
1.默认按钮
<r-button>默认按钮</r-button>
首先按钮要能传入内容,所以肯定需要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.html
的body
标签中加入
<r-button >默认按钮</r-button>
pnpm dev
执行项目,查看页面和控制台,是否正确生效
至此,最简单的默认按钮开发完成
2.水波纹特效
(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);
}
点击的时候,可以看到页面效果
(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.增加按钮type
和disabled
属性
(1).type属性
希望按钮有四种类型
由于是通过元素的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>
所以这一块我们可以抽取出来
- 封装一个判断方法
- 统一其他组件
disabled
功能的判断逻辑
我们先在utils
中编写统一判断disabled
为false
的情况
// 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;
}
由于disabled
和type
属性是可以并存的,所以这里也需要做一些兼容处理
// 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");
}
};
结束
以上就是本文的全部内容了,目前已经开发了Button
,Icon
,Image
,Tabs
,Input
,Message
组件,完整代码如下:github.com/chaxus/ran
且已添加MIT
协议,供大家随意参考和使用。如果有所收获的话,希望能点个star
,点个赞。同时欢迎issue
,pr
和评论。一起学习,一起进步~