分享Vue3组件库搭建(1):如何写好一个Button组件(附源码以及预览)

2,585 阅读6分钟

前言

组件库已成为,前端每日最长打交道的工具。当前无论公司规模大小,他肯定都会使用一套组件库来搭建自己的项目。

  • 小公司,他可能会选择一套满足自我功能需要的第三方组件库。也有人选择一套成熟的第三方组件库,再针对个别组件进行二次封装,如常见的表格,表单;

  • 再大一点的公司,可能会选择一套巨人肩膀上二次封装的内部组件库。表面上已经是自己公司的组件库,其实底层还是内藏一个优质的第三方。

  • 而专业的互联网公司,会有一个专门的团队,来搭建自己的内部组件库。满足自己所有的内需的同时,开源出来让整个社区进行认可。

那么,为什么通常只有"专业的互联网公司",才会选择自己搭建内部的组件库呢?中小公司,搭建的难点在哪里?笔者今天分析一下,最简单的Button,需要考虑什么。

背景

本文使用使用 vue3 + vite + typescript,以移动端组件库为目标。

笔者将利用额外的时间,分享整个组件库的搭建。本文先分享一个最简单的Button。

下边是笔者文章对应开源项目的截图:

image.png

Button按钮案例:

image.png

需求分析

此时如果你不是一个技术(或者非前端人员),脑海中一个原生button控件,加个点击事情解决。

然后事情可能没有这么简单。我们看看一个成熟的第三方组件库,button到底做了什么?

1)成熟组件库参考

我们先看看其他相对成熟的组件库,处理了什么。

Ant Design提供的API

image.png

Element提供的API

image.png

Vant提供的API

image.png

Varlet提供的API

image.png

2)确认需求目标

此时,看到一个成熟组件库的提供的Api, 我们可以考虑一下我们最终需要实现的一个Button, 笔者结合第三方组件库为参考,以及日常工作所需,确定本章想要的Button:

    1. 支持文本,图标等
    1. 支持不同的大小,小号,中号,大号等。
    1. 支持不同的类型,主题按钮,危险按钮,警告按钮等
    1. 支持加载效果
    1. 支持透明的模式
    1. 支持圆角
    1. 支持禁用
    1. 支持块级展示
    1. 支持触发中状态
    1. 展示上的优化
    1. 点击事件,防重,支持异步等

3)整体图例

image.png

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>
  • 效果图

image.png

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>
  • 效果图

image.png

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>
  • 效果图

image.png

image.png

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>

image.png

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>
  • 效果图

image.png

6)支持透明模式

透明模式,也是现在的UI常用的标准,跟主题常规模式混搭,可以达到更理想的展示效果。

源码跟"主题模式"差不多,这里不再重复。需要看github、

image.png

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>

image.png

8)支持禁用

禁用模式,源码跟"主题模式"差不多,这里不再重复。需要看github。

image.png

9)支持块级展示

块级模式。同理,实践中,我们还需要占满整个快。源码跟"loading"差不多,需要看github。

image.png

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组件。