// Created At 2026-05-07// P3
// Vue · Design System · Engineering · Architecture

Vue 3 简单组件开发实战:从 Button 组件看 API 设计

极简不是简陋,克制不是缺失——从 Nuxt UI v4 汲取灵感,如何设计一个好用的组件 API

一、背景与参考

在之前的文章中,我们讨论了设计令牌优先于原子化 CSS 的理念,以及 CSS 优先 + 组件薄封装的架构。但有一个问题始终没有深入:具体到单个组件,API 到底该怎么设计?

设计初期,我深度参考了 Nuxt UI v4 的设计思路。Nuxt UI v4 将对复杂样式和交互的封装收束为 variant/color/size 几个核心维度,这正是我想要的——把复杂逻辑内聚于组件内部,对外只暴露最精简的 API。

下文以 Button 组件为例,一步步展示我的设计取舍和思考过程。

本篇聚焦于简单组件的 API 设计,下一篇文章将探讨 Select、Pagination 等复杂组件的实现思路与状态管理。

二、从需求出发:Button 需要什么?

一个按钮组件最基本的功能:

  • 显示文字
  • 点击触发事件
  • 禁用状态
  • 不同样式(主要、次要、危险等)

但只有这些够吗?我们看看实际使用场景:

<!-- 带图标的按钮 -->
<Button>
  <template #icon>🔍</template>
  搜索
</Button>

<!-- 加载状态 -->
<Button loading>提交中</Button>

<!-- 块级按钮(占满宽度) -->
<Button block>全宽按钮</Button>

<!-- 不同尺寸 -->
<Button size="sm">小号</Button>

经过分析,Button 组件需要支持:

需求实现方式
文字内容默认插槽 或 label prop
点击事件click 事件
禁用状态disabled prop
加载状态loading prop
不同样式variant + color
不同尺寸size prop
块级宽度block prop
图标#icon 插槽

三、Props 设计:类型、默认值、优先级

3.1 基础 Props

interface Props {
  label?: string; // 按钮文字
  disabled?: boolean; // 是否禁用
  loading?: boolean; // 是否加载中
  block?: boolean; // 是否为块级
}

默认值设计:

const props = withDefaults(defineProps<Props>(), {
  label: "",
  disabled: false,
  loading: false,
  block: false,
});

3.2 变体系统:variant + color

常见的按钮类型有:主要按钮、次要按钮、边框按钮、幽灵按钮。受 Nuxt UI v4 的 variant + color 设计启发,我选择将“视觉模式”与“语义颜色”完全解耦。

type Variant = "filled" | "outline";
type Color = "primary" | "success" | "warning" | "error";

为什么只保留 filledoutline,删除了 ghost

变体使用频率是否保留
filled🔥🔥🔥🔥🔥 极高✅ 保留
outline🔥🔥🔥🔥 高✅ 保留
ghost🔥 低❌ 删除(可用 outline 替代)

同样,颜色只保留 4 种:

颜色使用频率是否保留
primary🔥🔥🔥🔥🔥 极高✅ 保留
success🔥🔥🔥 中✅ 保留
warning🔥 低✅ 保留
error🔥🔥 中✅ 保留
neutral🔥 低❌ 删除(可用 outline 替代)

3.3 尺寸设计

type Size = "sm" | "md" | "lg";

尺寸选项与 Nuxt UI v4 提供的五种尺寸(xs, sm, md, lg, xl)相比做了精简。我删除了 xsxl,因为极小尺寸可以用 Badge 或其他非按钮组件替代,而个人博客里几乎碰不到超大尺寸的场景。

默认尺寸的选择:主流 UI 库(Naive UI、PrimeVue 等)的默认按钮高度约 32-34px,对应我们的 sm,因此默认尺寸设为 sm

const props = withDefaults(defineProps<Props>(), {
  size: "sm", // 默认小号
});

3.4 图标设计:只使用插槽

为了保持极简,Button 组件不提供 icon prop,只保留 #icon 插槽。用户需要图标时,直接在插槽中放入内容即可,例如:

<Button>
  <template #icon>🔍</template>
  搜索
</Button>

这样写虽然多几个字符,但避免了 prop 与插槽的优先级混乱,也更符合“单一职责”原则。同时,插槽可以传入任何内容(字符串、emoji、图标组件),灵活性完全不受影响。

设计考量:Nuxt UI v4 提供了 icon prop 和 leading-icon/trailing-icon 等多个图标相关属性,但在个人博客的场景下,绝大多数图标按钮仅为简单的“图标+文字”,使用插槽足以覆盖所有需求,且降低了学习和维护成本。

四、插槽设计:默认插槽 vs label prop

为了支持快速写法和自定义内容,同时提供 label prop 和默认插槽:

<span class="mg-button-label">
  <slot>{{ label }}</slot>
</span>
  • 如果提供了默认插槽内容,显示插槽内容
  • 否则显示 label prop

这样两种用法都支持:

<!-- 使用 label prop -->
<Button label="提交" />

<!-- 使用默认插槽 -->
<Button>提交</Button>

五、状态处理

5.1 禁用状态

disabledloading 都会禁用按钮:

const isDisabled = computed(() => props.disabled || props.loading);
<button :disabled="isDisabled">

5.2 加载状态

加载时显示旋转动画,隐藏图标和文字:

<template v-if="loading">
  <span class="mg-button-loading-icon" />
</template>
<template v-else>
  <span v-if="hasIconSlot" class="mg-button-icon">
    <slot name="icon" />
  </span>
  <span v-if="hasLabel" class="mg-button-label">
    <slot>{{ label }}</slot>
  </span>
</template>

纯 CSS 实现加载动画(与你提供的 CSS 一致):

.mg-button-loading-icon {
  width: 1rem;
  height: 1rem;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  animation: mg-button-spin 0.6s linear infinite;
}
@keyframes mg-button-spin {
  to {
    transform: rotate(360deg);
  }
}

六、CSS 样式设计

6.1 基础样式

按钮使用 inline-flex 布局,内容水平和垂直居中,直角边框(--ui-radius-none),内边距和字体大小使用设计令牌。

.mg-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--ui-spacing-sm);
  font-weight: 500;
  transition: all var(--ui-motion-duration-neural) ease;
  cursor: pointer;
  border-radius: var(--ui-radius-none);
  border: none;
  background: transparent;
  white-space: nowrap;
  padding: var(--ui-spacing-sm) var(--ui-spacing-md);
  font-size: var(--ui-typography-size-body);
}

6.2 尺寸变体

尺寸内边距字体大小
smsm / md--ui-typography-size-code (13px)
mdmd / lg--ui-typography-size-body (15px)
lglg / xl1.125rem (18px)
.mg-button-sm {
  padding: var(--ui-spacing-sm) var(--ui-spacing-md);
  font-size: var(--ui-typography-size-code);
}
.mg-button-md {
  padding: var(--ui-spacing-md) var(--ui-spacing-lg);
  font-size: var(--ui-typography-size-body);
}
.mg-button-lg {
  padding: var(--ui-spacing-lg) var(--ui-spacing-xl);
  font-size: 1.125rem;
}

6.3 块级按钮

.mg-button-block {
  width: 100%;
}

6.4 图标与文字容器

图标容器使用 inline-flex 并设置 line-height: 0 来消除行高影响,内部的 SVG 或 iconify 图标强制块级并设置宽高为 1em

.mg-button-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  line-height: 0;
}
.mg-button-icon svg,
.mg-button-icon .iconify {
  display: block;
  width: 1em;
  height: 1em;
}
.mg-button-label {
  display: inline-flex;
  align-items: center;
}

6.5 解决纯图标按钮居中问题

当按钮只有图标没有文字时,空的 .mg-button-label 会占据空间导致图标不居中。通过 :empty 伪类隐藏空标签:

.mg-button-label:empty {
  display: none;
}

同时在组件中判断是否有真实内容再渲染标签:

const hasLabel = computed(() => !!props.label || !!slots.default);
<span v-if="hasLabel" class="mg-button-label">
  <slot>{{ label }}</slot>
</span>

6.6 变体样式

filledoutline 变体分别对应填充背景和透明背景加边框,颜色通过 color prop 动态组合,例如 .mg-button-filled-primary 使用 --ui-primary 作为背景色。

完整样式可见你提供的 CSS 文件,这里不再赘述。

七、无障碍设计

添加 ARIA 属性提升可访问性:

<button
  :aria-busy="loading"
  :aria-disabled="disabled || loading"
>

屏幕阅读器能正确读出按钮的加载和禁用状态。

八、属性透传

使用 v-bind="$attrs" 透传原生属性:

<button v-bind="$attrs">

用户可以直接传入 idnamedata-*aria-* 等属性:

<Button id="submit-btn" name="submit" data-testid="submit">
  提交
</Button>

九、最终代码

<template>
  <button
    v-bind="$attrs"
    class="mg-button"
    :class="[
      `mg-button-${variant}-${color}`,
      `mg-button-${size}`,
      { 'mg-button-block': block, 'mg-button-loading': loading },
    ]"
    :disabled="disabled || loading"
    :aria-busy="loading"
    :aria-disabled="disabled || loading"
    @click="handleClick"
  >
    <template v-if="loading">
      <span class="mg-button-loading-icon" />
    </template>

    <template v-else>
      <span v-if="hasIconSlot" class="mg-button-icon">
        <slot name="icon" />
      </span>
      <span v-if="hasLabel" class="mg-button-label">
        <slot>{{ label }}</slot>
      </span>
    </template>
  </button>
</template>

<script setup lang="ts">
import { useSlots, computed } from "vue";

const slots = useSlots();
const hasIconSlot = computed(() => !!slots.icon);
const hasLabel = computed(() => !!props.label || !!slots.default);

type Variant = "filled" | "outline";
type Color = "primary" | "success" | "warning" | "error";
type Size = "sm" | "md" | "lg";

interface Props {
  label?: string;
  variant?: Variant;
  color?: Color;
  size?: Size;
  disabled?: boolean;
  loading?: boolean;
  block?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  label: "",
  variant: "filled",
  color: "primary",
  size: "sm",
  disabled: false,
  loading: false,
  block: false,
});

const emit = defineEmits<{ click: [event: MouseEvent] }>();

const handleClick = (event: MouseEvent) => {
  if (props.disabled || props.loading) return;
  emit("click", event);
};
</script>

十、与其他主流 UI 库的 API 对比

为了更直观地展示 Moongate UI Button 与当今主流、活跃的 UI 库在设计哲学上的异同,下表对比了 Nuxt UI v4、Naive UI 和 PrimeVue 的 API 风格。

API 特性Moongate UI (本章节)✨ Nuxt UI v4 (灵感之源)Naive UI (NButton)PrimeVue (Button)
核心风格variant (filled/outline)variant + color (solid/outline/soft/subtle/ghost/link)type (primary/success/warning/error/info)severity (primary/secondary/success/info/warning/danger/help/contrast) + variant
尺寸size (sm/md/lg)size (xs/sm/md/lg/xl)size (small/medium/large)size (small/medium/large)
禁用disableddisableddisableddisabled
加载loadingloading / loadingAutoloadingloading
块级blockblockblockfluid
图标#icon 插槽icon / leading-icon / trailing-icon + 插槽icon + iconPos
额外样式squaredashed, circle, roundrounded, raised, outlined
Vue Router 集成不支持 (由用户包装)原生支持 (to/href)不支持通过 as 属性间接支持

说明:表格中 Naive UIPrimeVue 的 API 信息均来自其官方文档 (2026 年版本)。

通过这张表,可以清晰地看到:

  • variantcolor 的解耦variant 专注“视觉模式”,color 专注“语义颜色”,逻辑更清晰。
  • 尺寸的精简:Nuxt UI 提供了 5 种尺寸选择,而在个人博客的场景下,3 种尺寸已经足够,删除 xsxl 有助于简化用户选择。
  • 图标支持的灵活性:虽然 Moongate UI 不提供 icon prop,但通过 #icon 插槽可以实现同样的效果,且更加灵活。
  • 语义化命名:使用 filledoutline 来命名变体,比单纯的 primarysecondary 更准确地描述了视觉样式。

十一、补充细节:加载状态下的宽度变化陷阱

使用 loading 属性时,有一个常见但容易被忽视的问题——“按钮加载时宽度会变化”。当加载动画(如旋转图标)出现时,会改变按钮内部的子元素,通常导致按钮容器扩宽,造成布局晃动。这其实就是 Cumulative Layout Shift (CLS)

一个优雅的解决方案是:使用 min-width 预留给文字加载前后的空间,同时将加载图标绝对定位,使其不参与布局。

.mg-button {
  /* 1. 使用 min-width 预留足够空间,确保加载和正常状态宽度一致 */
  min-width: 88px;
}

.mg-button-loading .mg-button-label {
  opacity: 0;
}

.mg-button-loading-icon {
  /* 2. 绝对定位加载图标居中,不影响布局 */
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

这个优化可以有效防止页面布局偏移,提升用户体验。

十二、设计决策总结

决策原因
删除 xs 尺寸使用频率低,简化 API
删除 ghost 变体可用 outline 替代
删除 neutral 颜色可用 outline + 默认色替代
默认尺寸为 sm主流 UI 库默认按钮约 32px
只使用 #icon 插槽避免 prop 与插槽混乱,保持简洁
label prop + 默认插槽两种写法都支持
隐藏空标签解决纯图标按钮居中问题
v-bind="$attrs"透传原生属性,保持灵活性
min-width + 绝对定位加载图标防止加载状态布局偏移

本篇以 Button 为例梳理了简单组件的设计要点。下一篇将深入复杂组件,涵盖数据适配、内部状态、逻辑复用等更高级的话题。

十三、相关文章

如果这篇文档对你有帮助,可以请我喝杯咖啡 ☕️
Ali PayWechat Pay
评论区
© 2026 MOONGATE