「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 组件,也可以应用到其他组件的开发中。

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

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

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

相关推荐
mldong3 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排4 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60614 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角5 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!5 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰6 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi7 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen8 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马8 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒9 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端