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 分成了几类:视觉样式、状态控制、内容、事件、原生属性。这样别人看代码的时候,一眼就能知道每个属性是干嘛的。
小技巧:variant 和 size 这种枚举值,尽量用联合类型而不是字符串。这样 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 组件,加入更多交互细节和动画效果。嗯,到时候你会看到,一个看似简单的按钮,其实可以玩出很多花样。