前言
本文将详细讲解如何用最基本的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.tsclass 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
1.获取shadowRoot
去操作shadow DOM
。操作方式和操作普通DOM
的方式一致,都是使用appendChild
等DOM
方法。
const shadowRoot = CustomElement.shadowRoot;
2.利用css3
变量去影响shadow DOM
内的样式。
在开发button
的水波纹特效时,就会用这个方式。
3.在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 DOMshadowRoot.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.tsclass 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
中编写统一判断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.tsget 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");}};
最后
整理了75个JS高频面试题,并给出了答案和解析,基本上可以保证你能应付面试官关于JS的提问。
有需要的小伙伴,可以点击下方卡片领取,无偿分享