Button按钮开发中那些容易被忽略的细节和最佳实践
一个Button组件,折腾了我整整一天
昨天下午接到一个需求,要给后台管理系统加个新的Button组件,看起来很简单对吧?结果从下午2点一直搞到晚上9点才搞定,期间各种诡异问题。
最开始我以为就是个普通的button样式调整,结果发现这个项目用的Tailwind + React,而且还要支持loading状态、禁用状态、不同尺寸、不同颜色主题…越写越复杂。最主要的是,线上环境有个诡异bug,某些情况下点击事件会触发两次。
先说loading状态的处理
这里我踩了个坑。最开始写loading状态的时候,直接用了一个loading变量控制显示隐藏,结果发现即使loading为true,按钮还是可以被多次点击。后来想了一下,应该在loading状态下同时设置disabled状态:
import React, { useState } from 'react';
const Button = ({
children,
onClick,
loading = false,
disabled = false,
variant = 'primary',
size = 'md',
className = ''
}) => {
const [isLoading, setIsLoading] = useState(loading);
const handleClick = async (e) => {
if (loading || disabled) {
e.preventDefault();
return;
}
try {
setIsLoading(true);
await onClick?.(e);
} finally {
setIsLoading(false);
}
};
const baseClasses = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 disabled:bg-blue-400',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500 disabled:bg-gray-100',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 disabled:bg-red-400',
success: 'bg-green-600 text-white hover:bg-green-700 focus:ring-green-500 disabled:bg-green-400'
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base'
};
return (
<button
className={${baseClasses} ${variants[variant]} ${sizes[size]} ${className}}
onClick={handleClick}
disabled={loading || disabled}
>
{isLoading && (
<span className="mr-2 h-4 w-4 animate-spin">
<svg className="h-full w-full" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
)}
{children}
</button>
);
};
export default Button;
上面这段代码看起来没问题,但在实际使用中发现有时候click事件还是会触发多次。折腾了半天发现,原来是我忘记在onClick函数内部做防抖处理了。虽然设置了disabled状态,但是在网络请求的瞬间,如果用户快速点击,还是可能触发多次。
双重保险的防抖机制
后来我在项目的utils文件夹下加了个防抖函数:
// utils/debounce.js
export const debounce = (func, wait) => {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
};
然后修改Button组件,加入防抖:
import React, { useState, useCallback } from 'react';
import { debounce } from '../utils/debounce';
const Button = ({
children,
onClick,
loading = false,
disabled = false,
variant = 'primary',
size = 'md',
debounceTime = 300,
className = ''
}) => {
const [isLoading, setIsLoading] = useState(loading);
const debouncedClick = useCallback(
debounce(async (e) => {
if (isLoading || disabled) {
e.preventDefault();
return;
}
try {
setIsLoading(true);
await onClick?.(e);
} finally {
setIsLoading(false);
}
}, debounceTime),
[isLoading, disabled, onClick, debounceTime]
);
// ... 其他代码保持不变
const baseClasses = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500 disabled:bg-blue-400 disabled:cursor-not-allowed',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300 focus:ring-gray-500 disabled:bg-gray-100 disabled:cursor-not-allowed',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500 disabled:bg-red-400 disabled:cursor-not-allowed',
success: 'bg-green-600 text-white hover:bg-green-700 focus:ring-green-500 disabled:bg-green-400 disabled:cursor-not-allowed'
};
const sizes = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base'
};
return (
<button
className={${baseClasses} ${variants[variant]} ${sizes[size]} ${className}}
onClick={debouncedClick}
disabled={isLoading || disabled}
>
{isLoading && (
<span className="mr-2 h-4 w-4 animate-spin">
<svg className="h-full w-full" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</span>
)}
{children}
</button>
);
};
export default Button;
这里要注意几个地方:disabled状态下的光标也要相应改变(加上disabled:cursor-not-allowed),还有useCallback的依赖数组要包含所有相关的变量,不然可能会有闭包问题。
移动端点击延迟的问题
上线测试的时候又发现了新问题,在移动端环境下,按钮点击会有明显的300ms延迟。这是移动端浏览器为了区分单击和双击而引入的默认行为。后来查资料发现需要引入fastclick库,或者自己实现类似的功能:
/* 针对移动端的优化 */
.button-tap-highlight {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
/* 去除移动端点击延迟 */
@media (hover: none) and (pointer: coarse) {
.no-delay {
touch-action: manipulation;
}
}
不过最后我没用CSS方案,而是直接在Button组件上加了touch-action属性:
return (
<button
className={${baseClasses} ${variants[variant]} ${sizes[size]} ${className}}
onClick={debouncedClick}
disabled={isLoading || disabled}
style={{ touchAction: 'manipulation' }} // 解决移动端点击延迟
>
{/* ... */}
</button>
);
最后的小细节
还有一个细节需要注意,就是按钮的无障碍访问(accessibility)。在loading状态下,最好给屏幕阅读器提供一些提示:
return (
<button
className={${baseClasses} ${variants[variant]} ${sizes[size]} ${className}}
onClick={debouncedClick}
disabled={isLoading || disabled}
aria-busy={isLoading}
aria-disabled={isLoading || disabled}
style={{ touchAction: 'manipulation' }}
>
{isLoading && (
<span className="mr-2 h-4 w-4 animate-spin" aria-hidden="true">
<svg className="h-full w-full" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
{isLoading && <span className="sr-only">Loading...</span>}
</svg>
</span>
)}
{children}
</button>
);
当然这里还有个小问题,就是loading的文字提示在某些场景下可能不够明确,比如”提交中…”、”删除中…”这种具体的提示会更好。不过目前这个实现已经能满足大部分场景了。
踩坑提醒:这三个地方一定要注意
总结一下这次开发Button组件遇到的几个坑:
- 多重保障防抖:只靠disabled属性还不够,必须配合防抖函数,防止网络请求间隙的重复点击
- 移动端体验:别忘了touch-action: manipulation解决点击延迟,用户体验很重要
- 状态同步问题:loading状态要在finally里重置,确保异常情况下的状态恢复
虽然看起来就是一个简单的Button,但实际开发中要考虑的东西真的不少。这个组件现在在线上跑得挺稳定,暂时没发现什么问题了。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

暂无评论