Button按钮开发中那些容易被忽略的细节和最佳实践

程序员卫华 组件 阅读 2,573
赞 15 收藏
二维码
手机扫码查看
反馈

一个Button组件,折腾了我整整一天

昨天下午接到一个需求,要给后台管理系统加个新的Button组件,看起来很简单对吧?结果从下午2点一直搞到晚上9点才搞定,期间各种诡异问题。

Button按钮开发中那些容易被忽略的细节和最佳实践

最开始我以为就是个普通的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,但实际开发中要考虑的东西真的不少。这个组件现在在线上跑得挺稳定,暂时没发现什么问题了。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论