「Ant Design 组件库探索」二:Button组件

上期分享了,Ant Design的工程化结构内容,本期就从组件库的第一个组件Button开始,来进行Button组件的研究和学习;在开始之前,也同样希望能够同时把源代码打开对照阅读。OK,那么正文开始:

一、Button 组件概览

在开始深入分析之前,我们先来看一下 Button 组件的整体结构。Ant Design 的 Button 组件主要由以下几个文件组成:

  • button.tsx:Button 组件的核心实现
  • button-group.tsx:ButtonGroup 组件的实现
  • buttonHelpers.tsx:辅助函数和类型定义
  • DefaultLoadingIcon.tsx:加载图标的实现
  • IconWrapper.tsx:图标包装器
  • style/index.ts:样式定义

这种文件组织结构非常清晰,每个文件都有明确的职责,这也是大型组件库代码组织的一个典型范例。

二、Button 组件的核心实现

2.1 组件接口设计

首先,我们来看 Button 组件的接口设计。在 button.tsx 中,定义了 BaseButtonPropsButtonProps 两个接口:

tsx 复制代码
export interface BaseButtonProps {
  type?: ButtonType;
  color?: ButtonColorType;
  variant?: ButtonVariantType;
  icon?: React.ReactNode;
  iconPosition?: 'start' | 'end';
  shape?: ButtonShape;
  size?: SizeType;
  disabled?: boolean;
  loading?: boolean | { delay?: number; icon?: React.ReactNode };
  prefixCls?: string;
  className?: string;
  rootClassName?: string;
  ghost?: boolean;
  danger?: boolean;
  block?: boolean;
  children?: React.ReactNode;
  [key: `data-${string}`]: string;
  classNames?: { icon: string };
  styles?: { icon: React.CSSProperties };
}

export interface ButtonProps extends BaseButtonProps, MergedHTMLAttributes {
  href?: string;
  htmlType?: ButtonHTMLType;
  autoInsertSpace?: boolean;
}

这种接口设计有几个值得注意的点:

  1. 将基础属性和 HTML 属性分开定义,然后通过继承合并,这样可以更清晰地管理属性
  2. 使用类型别名和联合类型来限制属性的可选值,提高类型安全性
  3. 支持 data-* 属性的动态传递,增强了组件的扩展性

2.2 组件实现

Button 组件的核心实现是一个 React.forwardRef 包装的函数组件:

tsx 复制代码
const InternalCompoundedButton = React.forwardRef<
  HTMLButtonElement | HTMLAnchorElement,
  ButtonProps
>((props, ref) => {
  // 组件实现...
});

使用 forwardRef 可以将 ref 转发到实际的 DOM 元素,这对于需要直接操作 DOM 的场景非常有用。

2.3 状态管理

Button 组件内部使用了多个 useState 和 useEffect 钩子来管理状态:

tsx 复制代码
const [innerLoading, setLoading] = useState<boolean>(loadingOrDelay.loading);
const [hasTwoCNChar, setHasTwoCNChar] = useState<boolean>(false);

特别是 loading 状态的处理非常精细:

tsx 复制代码
useEffect(() => {
  let delayTimer: ReturnType<typeof setTimeout> | null = null;
  if (loadingOrDelay.delay > 0) {
    delayTimer = setTimeout(() => {
      delayTimer = null;
      setLoading(true);
    }, loadingOrDelay.delay);
  } else {
    setLoading(loadingOrDelay.loading);
  }

  function cleanupTimer() {
    if (delayTimer) {
      clearTimeout(delayTimer);
      delayTimer = null;
    }
  }

  return cleanupTimer;
}, [loadingOrDelay]);

这段代码实现了延迟加载的功能,当 loading.delay 大于 0 时,会延迟显示加载状态,这可以避免闪烁问题,提升用户体验。同时,在组件卸载时会清理定时器,防止内存泄漏。

三、Button 的样式处理

3.1 样式生成

Ant Design 使用了 CSS-in-JS 的方式来处理样式,Button 组件的样式定义在 style/index.ts 中:

tsx 复制代码
const useStyle = genComponentStyleHook('Button', (token) => {
  const buttonToken = mergeToken<ButtonToken>(token, {
    buttonPaddingHorizontal: token.paddingSM,
    buttonPaddingVertical: Math.max(
      0,
      (token.controlHeight - token.fontSize * token.lineHeight) / 2 - token.lineWidth,
    ),
    buttonPaddingHorizontalSM: token.paddingXS,
    buttonPaddingVerticalSM: Math.max(
      0,
      (token.controlHeightSM - token.fontSize * token.lineHeight) / 2 - token.lineWidth,
    ),
    buttonPaddingHorizontalLG: token.padding,
    buttonPaddingVerticalLG: Math.max(
      0,
      (token.controlHeightLG - token.fontSizeLG * token.lineHeight) / 2 - token.lineWidth,
    ),
    buttonIconOnlyWidth: token.controlHeight - token.lineWidth * 2,
    buttonIconOnlySizeSM: token.controlHeightSM - token.lineWidth * 2,
    buttonIconOnlySizeLG: token.controlHeightLG - token.lineWidth * 2,
    buttonBlockBorderWidth: 0,
  });

  return [
    genSharedButtonStyle(buttonToken),
    genButtonColorStyle(buttonToken),
    genButtonSizeStyle(buttonToken),
    genGroupStyle(buttonToken),
  ];
});

这种方式有几个优点:

  1. 样式与组件紧密结合,便于维护
  2. 支持主题定制和样式变量
  3. 可以根据不同的条件生成不同的样式

3.2 类名生成

Button 组件使用 classNames 库来生成类名:

tsx 复制代码
const classes = classNames(
  prefixCls,
  hashId,
  cssVarCls,
  {
    [`${prefixCls}-${shape}`]: shape !== 'default' && shape,
    [`${prefixCls}-${mergedType}`]: mergedType,
    [`${prefixCls}-dangerous`]: danger,
    [`${prefixCls}-color-${mergedColorText}`]: mergedColorText,
    [`${prefixCls}-variant-${mergedVariant}`]: mergedVariant,
    [`${prefixCls}-${sizeCls}`]: sizeCls,
    [`${prefixCls}-icon-only`]: !children && children !== 0 && !!iconType,
    [`${prefixCls}-background-ghost`]: ghost && !isUnBorderedButtonVariant(mergedVariant),
    [`${prefixCls}-loading`]: innerLoading,
    [`${prefixCls}-two-chinese-chars`]: hasTwoCNChar && mergedInsertSpace && !innerLoading,
    [`${prefixCls}-block`]: block,
    [`${prefixCls}-rtl`]: direction === 'rtl',
    [`${prefixCls}-icon-end`]: iconPosition === 'end',
  },
  compactItemClassnames,
  className,
  rootClassName,
  contextClassName,
);

这种方式可以根据组件的不同状态和属性生成对应的类名,非常灵活。

四、Button 的特殊处理

4.1 中文字符间距

Ant Design 的 Button 组件有一个特殊的功能:自动在两个中文字符之间插入空格,以提升视觉效果。这是通过 isTwoCNCharspaceChildren 两个函数实现的:

tsx 复制代码
// 检测是否为两个中文字符
const rxTwoCNChar = /^[\u4E00-\u9FA5]{2}$/;
export const isTwoCNChar = rxTwoCNChar.test.bind(rxTwoCNChar);

// 在子元素之间插入空格
export function spaceChildren(children: React.ReactNode, needInserted: boolean) {
  let isPrevChildPure = false;
  const childList: React.ReactNode[] = [];

  React.Children.forEach(children, (child) => {
    const type = typeof child;
    const isCurrentChildPure = type === 'string' || type === 'number';
    if (isPrevChildPure && isCurrentChildPure) {
      const lastIndex = childList.length - 1;
      const lastChild = childList[lastIndex];
      childList[lastIndex] = `${lastChild}${child}`;
    } else {
      childList.push(child);
    }

    isPrevChildPure = isCurrentChildPure;
  });

  return React.Children.map(childList, (child) =>
    splitCNCharsBySpace(child as React.ReactElement | string | number, needInserted),
  );
}

这个功能看似小,但体现了 Ant Design 对细节的极致追求。

4.2 Wave 效果

Button 组件还集成了 Wave 效果,当用户点击按钮时会有一个波纹动画:

tsx 复制代码
if (!isUnBorderedButtonVariant(mergedVariant)) {
  buttonNode = (
    <Wave component="Button" disabled={innerLoading}>
      {buttonNode}
    </Wave>
  );
}

这个效果增强了用户的交互体验,让用户能够明确感知到点击行为。

五、ButtonGroup 组件(已被弃用)

ButtonGroup 组件用于将多个按钮组合在一起,形成一个按钮组:

tsx 复制代码
const ButtonGroup: React.FC<ButtonGroupProps> = (props) => {
  const { getPrefixCls, direction } = React.useContext(ConfigContext);

  const { prefixCls: customizePrefixCls, size, className, ...others } = props;
  const prefixCls = getPrefixCls('btn-group', customizePrefixCls);

  const [, , hashId] = useToken();

  const sizeCls = React.useMemo<string>(() => {
    switch (size) {
      case 'large':
        return 'lg';
      case 'small':
        return 'sm';
      default:
        return '';
    }
  }, [size]);

  // 警告处理...

  const classes = classNames(
    prefixCls,
    {
      [`${prefixCls}-${sizeCls}`]: sizeCls,
      [`${prefixCls}-rtl`]: direction === 'rtl',
    },
    className,
    hashId,
  );

  return (
    <GroupSizeContext.Provider value={size}>
      <div {...others} className={classes} />
    </GroupSizeContext.Provider>
  );
}

这个组件的实现相对简单,主要是提供了一个 Context 来共享 size 属性,并应用了相应的样式。

六、代码格式

如果你去打开了源码的Button.tsx文件进行查看,你会发现,文件中的代码写得非常的规范,无论是注释,还是代码分块逻辑,都是非常清晰的,非常有助于代码的可读性,如下所示:

这样的代码风格非常值得学习。


除此之外,还有一个感受就是要看一个东西在哪里,是需要层层地点进去,代码的封装程度非常高,举个例子:

比如这个引入import omit from 'rc-util/lib/omit';,这个函数的用处是在这里:

tsx 复制代码
 const linkButtonRestProps = omit(rest as ButtonProps & { navigate: any }, ['navigate']);

这里的作用是克隆第一个参数,把第二个参数中的属性去掉之后返回。

源文件其实就是这样的代码:

js 复制代码
"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = omit;
function omit(obj, fields) {
  var clone = Object.assign({}, obj);
  if (Array.isArray(fields)) {
    fields.forEach(function (key) {
      delete clone[key];
    });
  }
  return clone;
}

这只是一个短函数,可以直接定义在当前文件中;但是这里的处理还是把它提出去了,体现了组件库对于封装程度的高要求。

七、联动

组件中除了自由属性之外,还要考虑到被上层受控的情况,尤其是和主题、表单内嵌用法相关的情况,所以可以在代码里面看到非常多的来自上层的context,要对上层的属性进行处理,判断是否被上层的属性控制。类似于以下的引入:

tsx 复制代码
import { ConfigContext, useComponentConfig } from '../config-provider/context';
import DisabledContext from '../config-provider/DisabledContext';
import useSize from '../config-provider/hooks/useSize';
import type { SizeType } from '../config-provider/SizeContext';

只从这一个组件看不出来,整体的context的布局和处理,等到后续理清楚之后,我会进行画图说明,把里面的全局变量的架构讲清楚

八、开发阶段的处理

在源码中,有这样的两个对于开发环境的处理:

tsx 复制代码
 // ========================== Warn ==========================
  if (process.env.NODE_ENV !== 'production') {
    const warning = devUseWarning('Button');

    warning(
      !(typeof icon === 'string' && icon.length > 2),
      'breaking',
      `\`icon\` is using ReactNode instead of string naming in v4. Please check \`${icon}\` at https://ant.design/components/icon`,
    );

    warning(
      !(ghost && isUnBorderedButtonVariant(mergedVariant)),
      'usage',
      "`link` or `text` button can't be a `ghost` button.",
    );
  }

这里是对两个使用情况进行警告提示⚠️

tsx 复制代码
if (process.env.NODE_ENV !== 'production') {
  Button.displayName = 'Button';
}

这里的是为了提供更加清晰的组件名,displayName 属性主要用于调试目的,它会在 React DevTools 中显示组件的名称

九、总结

通过对 Ant Design Button 组件的分析,我们可以学习到以下几点:

  1. 组件接口设计:清晰的接口定义和类型约束,提高了组件的可用性和类型安全性
  2. 状态管理:合理使用 React Hooks 管理组件状态,处理复杂的交互逻辑
  3. 样式处理:采用 CSS-in-JS 方式,实现了样式与组件的紧密结合和主题定制
  4. 细节处理:如中文字符间距、Wave 效果等细节处理,提升了用户体验
  5. 组件组合:通过 ButtonGroup 组件,实现了组件的组合使用
  6. 代码规范:代码书写十分规范,注释清晰明朗,高度封装
  7. 主题一致:通过context接收上层控制信息,处理受控情况,保留主题一致性
  8. 开发阶段处理:对某些用法进行了警告提示以及为了配合ReactTools等工具,对开发阶段的displayName做了加强

总结一下就是:站在组件库的角度来处理组件,需要考虑得更多和更全面。 这些设计和实现思路不仅适用于 Button 组件,也可以应用到其他组件的开发中。

希望这篇文章能对大家有所帮助,让我们在组件开发中能够更加得心应手。

如果你有任何问题或想法,欢迎在评论区留言讨论!

我是李仲轩,下一篇再见👋!

相关推荐
CodeBlossom3 分钟前
javaweb -html -CSS
前端·javascript·html
打小就很皮...36 分钟前
HBuilder 发行Android(apk包)全流程指南
前端·javascript·微信小程序
集成显卡2 小时前
PlayWright | 初识微软出品的 WEB 应用自动化测试框架
前端·chrome·测试工具·microsoft·自动化·edge浏览器
前端小趴菜052 小时前
React - 组件通信
前端·react.js·前端框架
Amy_cx3 小时前
在表单输入框按回车页面刷新的问题
前端·elementui
dancing9993 小时前
cocos3.X的oops框架oops-plugin-excel-to-json改进兼容多表单导出功能
前端·javascript·typescript·游戏程序
HarderCoder3 小时前
学习React的一些知识
react.js
后海 0_o3 小时前
2025前端微服务 - 无界 的实战应用
前端·微服务·架构
Scabbards_3 小时前
CPT304-2425-S2-Software Engineering II
前端
小满zs4 小时前
Zustand 第二章(状态处理)
前端·react.js