4、Button组件(一):组件需求分析、Props类型定义、基础样式实现、点击事件处理

好,咱们正式开始动手搭建组件库。

第一个要做的组件,我选了 Button。为什么?因为它是所有交互组件里最基础、最常用的。你想想看,一个页面里要是连按钮都没有,那用户基本就啥也干不了。所以 Button 组件虽然看起来简单,但它背后涉及的设计思路、类型定义、样式方案,几乎可以覆盖整个组件库搭建的核心方法论。

这一节,咱们先搞定 Button 组件的需求分析、Props 类型定义、基础样式实现,以及点击事件处理。说白了,就是让一个按钮能正常显示、能正常响应点击。

4.1 组件需求分析:别小看一个按钮

很多人做组件库,上来就写代码。我个人习惯是,先花点时间想清楚:这个组件到底要解决什么问题?

Button 组件,核心需求其实就几条:

  • 视觉反馈:用户点击时,按钮要有状态变化(比如颜色、阴影)。
  • 交互反馈:点击后要触发某个动作,比如提交表单、跳转页面。
  • 可配置性:不同场景下,按钮的大小、颜色、形状可能都不一样。
  • 可访问性:键盘操作、屏幕阅读器支持,这些不能丢。

我在项目中遇到过一种情况:设计师给了 5 种按钮样式,开发直接写了 5 个不同的组件。结果后面需求一变,每个组件都要改,维护成本直接爆炸。所以,咱们从一开始就要把 Button 设计成「一个组件,多种形态」。

核心原则:Button 组件应该是一个「配置驱动的组件」。通过不同的 Props 组合,生成不同的视觉和交互效果。

4.2 Props 类型定义:用 TypeScript 把规矩定好

需求理清了,接下来就是定义 Props。这一步很关键,因为 Props 就是组件的「接口文档」。你定义得越清晰,别人用起来就越不容易出错。

我个人习惯用 TypeScript 的 interface 来定义。嗯,这里要注意:不要把所有属性都塞到一个类型里,最好按功能分组。

// Button 组件的 Props 类型定义
interface ButtonProps {
  // 视觉样式
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
  size?: 'small' | 'medium' | 'large';
  shape?: 'default' | 'round' | 'circle';
  
  // 状态控制
  disabled?: boolean;
  loading?: boolean;
  fullWidth?: boolean;
  
  // 内容
  children: React.ReactNode;
  icon?: React.ReactNode;
  iconPosition?: 'left' | 'right';
  
  // 事件
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  
  // HTML 原生属性透传
  type?: 'button' | 'submit' | 'reset';
  className?: string;
  style?: React.CSSProperties;
}

你看,我把 Props 分成了几类:视觉样式、状态控制、内容、事件、原生属性。这样别人看代码的时候,一眼就能知道每个属性是干嘛的。

小技巧variantsize 这种枚举值,尽量用联合类型而不是字符串。这样 TypeScript 能帮你做类型检查,避免传错值。

这里有个坑,我曾经踩过:onClick 的类型定义。很多人直接写 () => void,但这样会丢失事件对象。如果你需要阻止默认行为或者获取点击坐标,那就麻烦了。所以,一定要把事件对象传出来。

4.3 基础样式实现:从零开始搭骨架

样式这块,我选择用 CSS Modules + CSS 变量。为什么不用 styled-components?嗯,我个人觉得 CSS Modules 更轻量,而且和 TypeScript 配合起来很舒服。当然,这个看团队习惯,没有绝对的对错。

先写基础样式。说白了,就是让按钮看起来像个按钮:

/* Button.module.css */
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  
  font-family: inherit;
  font-size: 14px;
  font-weight: 500;
  line-height: 1;
  
  border: 1px solid transparent;
  border-radius: 6px;
  
  cursor: pointer;
  transition: all 0.2s ease;
  
  /* 默认尺寸 */
  padding: 8px 16px;
  min-height: 36px;
}

/* 禁用状态 */
.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  pointer-events: none;
}

这里有几个细节要注意:

  • display: inline-flex:让按钮既能自适应宽度,又能方便地排列图标和文字。
  • gap: 8px:图标和文字之间的间距,用 gap 比用 margin 更干净。
  • transition: all 0.2s ease:所有状态变化都加上过渡,体验更顺滑。
  • pointer-events: none:禁用状态下,连鼠标事件都别触发,省得用户瞎点。

注意pointer-events: none 会阻止所有鼠标事件,包括 hover 和 active。如果你需要在禁用状态下保留某些交互(比如显示 tooltip),那就别用这个属性,改用 aria-disabled

接下来是 variant 的样式。以 primary 为例:

/* Button.module.css */
.primary {
  background-color: #1677ff;
  color: #fff;
  border-color: #1677ff;
}

.primary:hover {
  background-color: #4096ff;
  border-color: #4096ff;
}

.primary:active {
  background-color: #0958d9;
  border-color: #0958d9;
}

其他 variant 的写法类似,就是换颜色。我建议把颜色值抽成 CSS 变量,方便后续主题定制。

4.4 点击事件处理:别让用户白点

事件处理这块,看起来简单,但坑不少。咱们一步步来。

首先,组件内部要处理点击事件:

const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
  // 如果按钮处于禁用或加载状态,直接返回
  if (disabled || loading) {
    event.preventDefault();
    return;
  }
  
  // 调用外部传入的 onClick
  onClick?.(event);
};

这里有个细节:onClick?.(event) 这种写法,是 TypeScript 的可选链调用。如果外部没传 onClick,也不会报错。

我曾经遇到过一个 bug:用户快速点击按钮,结果表单被提交了两次。原因就是没做防抖处理。所以,如果你的按钮是提交表单用的,建议在组件内部加一层防抖:

import { useCallback, useRef } from 'react';

const useDebouncedClick = (callback: Function, delay: number = 300) => {
  const timerRef = useRef<NodeJS.Timeout | null>(null);
  
  return useCallback((...args: any[]) => {
    if (timerRef.current) {
      clearTimeout(timerRef.current);
    }
    timerRef.current = setTimeout(() => {
      callback(...args);
    }, delay);
  }, [callback, delay]);
};

当然,防抖不是万能的。如果你的场景需要「立即响应」但又「防止重复提交」,可以考虑用 loading 状态来控制。用户点击后,按钮进入加载状态,等请求完成再恢复。这样用户体验更好。

最佳实践:点击事件处理的核心逻辑是「先判断状态,再执行回调」。永远不要在点击事件里直接执行副作用,而是通过状态驱动。

4.5 完整组件代码示例

好了,把上面这些整合起来,就是一个完整的 Button 组件:

import React from 'react';
import styles from './Button.module.css';

interface ButtonProps {
  variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
  size?: 'small' | 'medium' | 'large';
  shape?: 'default' | 'round' | 'circle';
  disabled?: boolean;
  loading?: boolean;
  fullWidth?: boolean;
  children: React.ReactNode;
  icon?: React.ReactNode;
  iconPosition?: 'left' | 'right';
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  type?: 'button' | 'submit' | 'reset';
  className?: string;
  style?: React.CSSProperties;
}

const Button: React.FC<ButtonProps> = ({
  variant = 'primary',
  size = 'medium',
  shape = 'default',
  disabled = false,
  loading = false,
  fullWidth = false,
  children,
  icon,
  iconPosition = 'left',
  onClick,
  type = 'button',
  className = '',
  style,
}) => {
  const classNames = [
    styles.button,
    styles[variant],
    styles[size],
    styles[shape],
    fullWidth ? styles.fullWidth : '',
    className,
  ].filter(Boolean).join(' ');

  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    if (disabled || loading) {
      event.preventDefault();
      return;
    }
    onClick?.(event);
  };

  return (
    <button
      className={classNames}
      disabled={disabled || loading}
      onClick={handleClick}
      type={type}
      style={style}
    >
      {loading && <span className={styles.spinner} />}
      {icon && iconPosition === 'left' && <span className={styles.icon}>{icon}</span>}
      {children}
      {icon && iconPosition === 'right' && <span className={styles.icon}>{icon}</span>}
    </button>
  );
};

export default Button;

你看,整个组件不到 60 行代码,但该有的都有了:类型定义、样式控制、事件处理、状态管理。这就是一个合格的 Button 组件雏形。

4.6 小结

这一节咱们把 Button 组件的骨架搭起来了。核心就三件事:

  • 需求分析:想清楚组件要解决什么问题,别急着写代码。
  • 类型定义:用 TypeScript 把规矩定好,让使用者不容易犯错。
  • 样式和事件:基础样式要稳,事件处理要防坑。

下一节,咱们会继续完善 Button 组件,加入更多交互细节和动画效果。嗯,到时候你会看到,一个看似简单的按钮,其实可以玩出很多花样。