Skip to content

组件的编写

这一篇故意不从“怎么把按钮写出来”开始,而是从“组件应该先想清什么”开始。

因为真正决定组件能不能长期维护的,往往不是第一版能不能跑,而是:

  • 组件契约是不是清楚
  • 扩展方向是不是留够了
  • 交互、样式、类型和可访问性是不是一开始就有基本意识

这里会用一个 Button 组件做例子,但重点不是按钮本身,而是展示一种更稳的组件设计起点。

一、需求拆解

真正开始写代码之前,先拆需求。
这一步看起来慢,但它会直接决定后面这个组件会不会越写越乱。

  1. 基础功能

    • 支持不同状态(正常、悬浮(hover)、按下(focus/active)、禁用(disable))。
    • 可通过 props 控制样式变体(primary / secondary / danger 等)。
    • 支持不同尺寸 size(小sm 、中md、大lg)。
    • 支持点击事件。
  2. 可插槽(slot)

    • 默认 slot 渲染按钮文本或自定义内容。
  3. 扩展性

    • 支持图标(icon)和加载态(loading)。
    • 支持加载中禁用。
    • 增加动画(比如过渡、点击水波纹效果(antd就做了这个效果))。
  4. 主题化 & 样式隔离

    • 使用 CSS 变量或预处理器便于全局定制。
    • scoped 样式避免冲突。
  5. (进阶)可访问性(a11y)

    • role="button"aria-pressedaria-disabled 等属性。
    • 键盘可聚焦与回车/空格触发。

二、初始版本:BaseButton.vue

vue
<script setup lang="ts">
import { computed } from 'vue'

export interface Props {
  label?: string
  variant?: 'primary' | 'secondary' | 'danger' | 'warning' | 'default'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  loading?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  disabled: false,
  loading: false,
  label: '',
  size: 'md',
  variant: 'default',
})

const emit = defineEmits<{
  (e: 'click', event: MouseEvent): void
}>()

const sizes = {
  sm: 'px-2 py-1 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg',
}

const variants = {
  primary: 'bg-blue-600 text-white hover:bg-blue-700',
  secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
  danger: 'bg-red-600 text-white hover:bg-red-700',
  warning: 'bg-yellow-600 text-white hover:bg-yellow-700',
  default: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
}

const buttonClasses = computed(() => [
  'inline-flex items-center justify-center font-medium rounded transition duration-150',
  sizes[props.size],
  variants[props.variant],
  (props.disabled || props.loading) && 'opacity-50 cursor-not-allowed',
])

function handleClick(e: MouseEvent) {
  if (props.disabled || props.loading) {
    return
  }
  emit('click', e)
}
</script>

<template>
  <button
    :class="buttonClasses" :disabled="disabled || loading" role="button" :aria-disabled="disabled || loading"
    @click="handleClick"
  >
    <slot>{{ label }}</slot>
  </button>
</template>

<style scoped>
button:focus {
  outline: none;
  box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.6);
}
</style>

说明

  • 使用 <script setup> + TypeScript。
  • 通过 computed 动态拼接类名,利用 Tailwind CSS 实现快速样式。
  • 已考虑禁用与加载态。

这个初始版本的重点不是“功能够不够多”,而是先把组件最核心的契约打出来:

  • 输入有哪些
  • 输出事件是什么
  • 插槽如何扩展
  • 禁用态和加载态怎么约束行为

三、迭代增强

等基础契约稳定之后,再考虑增强能力。
顺序上更推荐这样推进:

  1. 图标 & 加载指示
  2. 多主题 & 配色
  3. 波纹 / 动画效果
  4. 更多可定制接口

1. 添加 Icon 与 Loading

css
/* loading 动画 */
.spinner {
  border: 2px solid rgba(255, 255, 255, 0.3);
  border-top-color: white;
  border-radius: 50%;
  width: 1em;
  height: 1em;
  animation: spin 0.8s linear infinite;
  display: inline-block;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

2. 支持多主题与全局定制

  • 在全局引入一组 CSS 变量(例如 --btn-bg-primary--btn-text-primary)。
  • 组件切换为引用变量,用户可在根节点或主题包里覆盖。
css
:root {
  --btn-bg-primary: #3b82f6;
  --btn-text-primary: #fff;
  /* … */
}
vue
<!-- Button.vue 中 -->
:style="{
  '--bg': `var(--btn-bg-${variant})`,
  '--text': `var(--btn-text-${variant})`
}"
class="bg-[var(--bg)] text-[var(--text)]"

3. 动画与波纹效果

利用 CSS 或简单 JS 在点击时添加一个水波纹动画:

css
.btn-ripple {
  position: relative;
  overflow: hidden;
}
.btn-ripple .ripple {
  position: absolute;
  border-radius: 50%;
  transform: scale(0);
  animation: ripple 0.6s linear;
  background: rgba(255, 255, 255, 0.7);
}
@keyframes ripple {
  to {
    transform: scale(4);
    opacity: 0;
  }
}

在点击时往按钮元素中插入 .ripple 元素并自动清理。

4. 更多可定制接口

  • as 属性:支持渲染为不同标签(<a><router-link>)。
  • loadingText:加载时显示不同文案。
  • block:一行占满宽度。
  • rounded:不同圆角等级。

下一步

在完成组件开发之后,我们就要进入下一步,组件的构建

一句话理解

组件的编写不是先堆功能,而是先把组件契约、可扩展边界和可维护性想清楚,再逐步把视觉、交互、类型和可访问性补齐。

建议继续阅读

  1. 组件的构建
  2. 组件的测试
  3. 下个时代的组件库

Released under the CC BY-NC-SA 4.0 License.