前言
组件库已成为,前端每日最长打交道的工具。当前无论公司规模大小,他肯定都会使用一套组件库来搭建自己的项目。
-
小公司,他可能会选择一套满足自我功能需要的第三方组件库。也有人选择一套成熟的第三方组件库,再针对个别组件进行二次封装,如常见的表格,表单;
-
再大一点的公司,可能会选择一套巨人肩膀上二次封装的内部组件库。表面上已经是自己公司的组件库,其实底层还是内藏一个优质的第三方。
-
而专业的互联网公司,会有一个专门的团队,来搭建自己的内部组件库。满足自己所有的内需的同时,开源出来让整个社区进行认可。
那么,为什么通常只有"专业的互联网公司",才会选择自己搭建内部的组件库呢?中小公司,搭建的难点在哪里?笔者今天分析一下,最简单的Button,需要考虑什么。
背景
本文使用使用 vue3 + vite + typescript,以移动端组件库为目标。
笔者将利用额外的时间,分享整个组件库的搭建。本文先分享一个最简单的Button。
下边是笔者文章对应开源项目的截图:
Button按钮案例:
需求分析
此时如果你不是一个技术(或者非前端人员),脑海中一个原生button控件,加个点击事情解决。
然后事情可能没有这么简单。我们看看一个成熟的第三方组件库,button到底做了什么?
1)成熟组件库参考
我们先看看其他相对成熟的组件库,处理了什么。
Ant Design提供的API
Element提供的API
Vant提供的API
Varlet提供的API
2)确认需求目标
此时,看到一个成熟组件库的提供的Api, 我们可以考虑一下我们最终需要实现的一个Button, 笔者结合第三方组件库为参考,以及日常工作所需,确定本章想要的Button:
-
- 支持文本,图标等
-
- 支持不同的大小,小号,中号,大号等。
-
- 支持不同的类型,主题按钮,危险按钮,警告按钮等
-
- 支持加载效果
-
- 支持透明的模式
-
- 支持圆角
-
- 支持禁用
-
- 支持块级展示
-
- 支持触发中状态
-
- 展示上的优化
-
- 点击事件,防重,支持异步等
3)整体图例
4)相关技能与规范
由上述的需求确认,我们进而分析部分我们使用我们需要的技能与规范。
本文主要描述button的构建,规范等后续会有专文汇总,敬请关注。
这里简单提及button几个涉及的技能与规范:Scss, PostCss兼容处理, Bem规范,CSS原子类等
5)声明
本文已引导Button的搭建为准,案例的本意是帮助大家更好的理解。具体源码还需看github。
实现分析
根据上边的需求,我们先手写一个最简单的Button, 再根据需求逐步推进:
1)手写最简单的Button
<template>
<button class="cb-button"><slot /></button>
</template>
<script lang="ts">
export default {
name: "Button",
setup() {
return {
}
}
}
</script>
<style>
...省略
</style>
复制代码
- 效果图
2)支持图标
上述已经支持插槽,理论上已经满足图标的显示。
但这样很容易造成图标的大小不统一,位置不统一等问题。如果我们由组件去控制常用的按钮内置图标的样式,这样会使项目更加的规范。
<template>
<div class="cb-button">
<img v-if="icon" :class="cb-button__img" :src="icon" />
<slot />
</div>
</template>
<script lang="ts">
export default {
name: "Button",
props:{
//嵌入图标
icon: {
type: String,
default: ""
},
},
setup() {
return {
}
}
}
</script>
复制代码
- 效果图
3)支持不同的字号
在项目的搭建过程,展示上按钮有大小之分已经是十分常见的需求。常见的项目一般有5号字体,一般定义为: smaller, small, normal, large, larger.
笔者这里用small, normal, large为案例:
<template>
<div :class="['cb-button', [`cb-button__size-${size}`]]">
<img v-if="icon" :class="cb-button__img" :src="icon" />
<slot />
</div>
</template>
<script lang="ts">
import { PropType } from "vue"
export type SizeItem = "small" | "normal" | "large"
export default {
name: "Button",
props:{
...,
// 字体大小
size: {
type: String as PropType<SizeItem>,
default: "normal",
validator: (str: string) => {
return ["small", "normal", "large"].includes(str)
}
},
},
setup() {
return {
}
}
}
</script>
<style lang="scss">
$size-array: (
key: "small",
fontSize: 12,
minWidth: 30,
height: 20,
borderRadius: 10,
padding: "0 4px"
),
(
key: "normal",
fontSize: 14,
minWidth: 70,
height: 30,
borderRadius: 15,
padding: "0 6px"
),
(
key: "large",
fontSize: 16,
minWidth: 80,
height: 40,
borderRadius: 20,
padding: "0 8px"
);
@include b(button) {
@for $i from 1 through length($size-array) {
$item: nth($size-array, $i);
@include e("size") {
@include m(map-get($item, key)) {
font-size: #{map-get($item, fontSize)}px;
min-width: #{map-get($item, minWidth)}px;
height: #{map-get($item, height)}px;
line-height: #{map-get($item, height)}px;
border-radius: #{map-get($item, borderRadius)}px;
padding: #{map-get($item, padding)};
flex: 0 0 auto;
}
}
}
}
</style>
复制代码
- 效果图
4)支持不同的主题
同理,按钮也需要支持不同的主题: "primary" , "info" , "danger" , "warning " , "link"
<template>
<div :class="['cb-button', [`cb-button__type-${type}`]]">
<img v-if="icon" :class="cb-button__img" :src="icon" />
<slot />
</div>
</template>
<script lang="ts">
import { PropType } from "vue"
export type ThemeType = "primary" | "info" | "danger" | "warning " | "link"
export default {
name: "Button",
props:{
...,
// 类型
type: {
type: String as PropType<ThemeType>,
default: "primary",
validator: (str: string) => {
return ["primary", "info", "danger", "warning", "link"].includes(str)
}
},
},
setup() {
return {
}
}
}
</script>
<style lang="scss">
...后续样式省略,具体看下方源码。
</style>
复制代码
5)支持加载效果
点击加载效果。这里通过异步任务Promise去控制是否加载状态更加合理,不过有些场景的确需要页面用loading去控制,笔者这里先暴露为api供页面控制:
<template>
<div :class="['cb-button', ...]">
<span v-if="loading" :class="`${be}loading`" />
<img v-else-if="icon" :class="`${be}img`" :src="icon" />
<slot />
</div>
</template>
<script lang="ts">
...
export default {
name: "Button",
props:{
...,
// 是否加载状态
loading: {
type: Boolean,
default: false
},
},
}
</script>
<style lang="scss">
...后续样式省略,具体看下方源码。
</style>
复制代码
- 效果图
6)支持透明模式
透明模式,也是现在的UI常用的标准,跟主题常规模式混搭,可以达到更理想的展示效果。
源码跟"主题模式"差不多,这里不再重复。需要看github、
7)支持圆角设置
圆角如果全局统一,应该通过config文件进行控制。但是如果个别圆角特殊,我们也需要支持自定义:
<template>
<div
:class="[...]"
:style="[baseStyles]"
>
<span v-if="loading" :class="`${be}loading`" />
<img v-else-if="icon" :class="`${be}img`" :src="icon" />
<slot></slot>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, toRefs } from "vue"
import { useCommon } from "../common/hooks/index"
import { props } from "./props"
// 按钮
export default defineComponent({
// 按钮组件,用于事件触发
name: "Button",
props,
setup(props, { emit }) {
const baseStyles = ref(props.radius !== -1 ? { borderRadius: props.radius + "px" } : {})
return {
...,
baseStyles,
}
}
})
</script>
复制代码
8)支持禁用
禁用模式,源码跟"主题模式"差不多,这里不再重复。需要看github。
9)支持块级展示
块级模式。同理,实践中,我们还需要占满整个快。源码跟"loading"差不多,需要看github。
10)支持点击(click)事件
如果我们不处理click,页面也会触发。假设不声明 emits: ["click"], 页面还会触发两次。
那么是否要包装click呢?
答案是肯定的。不然loading,disabled, 防重复点击等,如何处理?
笔者这里先出个初版, 且处理防重,具体看github源码:
<template>
<div
...
@click="onClick"
>
...
<slot></slot>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, toRefs } from "vue"
import { useCommon } from "../common/hooks/index"
import { props } from "./props"
// 按钮
export default defineComponent({
// 按钮组件,用于事件触发
name: "Button",
props:{
...,
onClick: {
type: Function as PropType<(e: Event) => void | Promise<any>>,
},
},
setup(props, { emit }) {
const pending: Ref<boolean> = ref(false)
const onClick = e => {
const { loading, disabled, onClick } = props
if (!onClick || loading || disabled || pending.value) {
return
}
props.onClick(e)
pending.value = false;
}
return {
...,
baseStyles,
}
}
})
</script>
复制代码
11)支持触发(touchstart)事件
touchstart事件还是有一定场景的。这里简单普及一下跟click的区别:
- touchstart:
手指触碰开始就能触发
- click:
1.手指触碰
2.手指未在屏幕上移动
3.在这个dom上手指离开屏幕
4.触摸和离开屏幕之间的时间间隔较短\
具体代码实现可参考click。
12)事件是否支持冒泡
常用的事件,都不支持冒泡。但是有一些特殊的需求,如区域埋点等,他需要冒泡。我们也把该事件暴露
porps:{
...,
//是否支持冒泡
isStopPropagation: {
type: Boolean,
default: true
},
}
const onClick = e => {
const { loading, disabled, onClick } = props
if (!onClick || loading || disabled || pending.value) {
return
}
props.onClick(e)
pending.value = false;
if (props.isStopPropagation) {
e.stopPropagation()
}
}
复制代码
13)支持异步loading
常见等待后台执行异步任务,或者等待前端处理,需要一定的等待时间方可二次触发。此时我们用autoLoading一个属性支持:
porps:{
...,
// 自动loading模式
autoLoading: {
type: Boolean,
default: false
},
}
// setup:
...
const loading = ref(props.loading)
const onClick = async e => {
if (loading.value) {
return
}
if (props.autoLoading) {
loading.value = true
await props.onClick(e)
loading.value = false
// 最长防重复点击
setTimeout(() => {
if (loading.value) {
loading.value = false
}
}, 5000)
} else {
props.onClick(e)
}
if (props.isStopPropagation) {
e.stopPropagation()
}
}
复制代码
14)参数控制
一个开放的组件库,毫无疑问样式上,颜色上,永远无法满足所有的业务场景。此时就需要通过配置一些参数,来控制按钮的展示。
如:主题色如何控制等。这里后续再提供专文讲解。
15)其他样式效果实现
此时,你会发现按钮体验上还缺了一点什么?
是的。例如hover效果,active效果等,鼠标聚焦事件等还未处理。还要进行额外的细节优化。这里不再描述。有兴趣看源码
源码链接
github: github.com/zhuangweizh…
gitee: 后续留意置顶评论。
预览:zhuangweizhan.github.io/cb-ui/dist/…
结语
看到这里,手写button。还觉得是一个非常简单事情么?
如果文章对你有帮忙,欢迎持续关注,下一篇:如何写好一个Input组件。