Ant Design 源码分析(一): 按钮

在电影里,会有这样的桥段,某人捡了一个遥控器,按下了按钮,一个妹子莫名倒地

按钮可以说是网页上最常见的元素之一了,简单的按钮,可能就是一个<button/>标签,再加上一些样式与事件。而复杂的,则会对按钮的常用功能进行各种封装,以应对各种业务场景。Ant Design 中,仅仅是一个按钮组件,就能管中窥豹,可见一斑。

按钮的 loading 处理

Ant Design 的按钮有loading属性,他的定义如下

export interface BaseButtonProps {
  // 此处省略其它props
  loading?: boolean | { delay?: number };
}

loading的类型为boolean时,组件的 loading 状态受控。当loading{ delay?: number }时,按钮会进入加载动画,并且在delay秒之后加载动画结束。那么因此可以推断,按钮中本身也有一个 loading 的 state,这个 state 既会受到props.loading的影响,也会在内部由 delay 来 setTimeout。 因此我们在ButtonState这个接口中,就能发现这个 loading

interface ButtonState {
  loading?: boolean | { delay?: number };
  hasTwoCNChar: boolean;
}

进一步往下看,通过 React 的getDerivedStateFromProps生命周期,在组件挂载后(didMount)和更新后(didUpdate)将 props 映射到 state 中。

  static getDerivedStateFromProps(nextProps: ButtonProps, prevState: ButtonState) {
    // 当props的loading为`boolean`时会同步`props.loading`到`state.loading`
    if (nextProps.loading instanceof Boolean) {
      return {
        ...prevState,
        loading: nextProps.loading,
      };
    }
    return null;
  }

那么,在componentDidUpdate中,自然会有处理{delay: number}loading的逻辑。

  componentDidUpdate(prevProps: ButtonProps) {
    this.fixTwoCNChar();

    // 清理上一个timer
    if (prevProps.loading && typeof prevProps.loading !== 'boolean') {
      clearTimeout(this.delayTimeout);
    }

    const { loading } = this.props;

    // 当前props中有delay的loading时,在delay秒之后设置loading状态为true
    if (loading && typeof loading !== 'boolean' && loading.delay) {
      // 显然,直接将loading直接设置为对象,确实可以做到loading
      this.delayTimeout = window.setTimeout(() => this.setState({ loading }), loading.delay);
    } else if (prevProps.loading === this.props.loading) {
      // `prevProps.loading`和`this.props.loading`相同时跳过setState,避免进入无限更新
      return;
    } else {
      this.setState({ loading });
    }
  }

最后还有个在 willUnmount 时的扫尾工作,在组件卸载的时候清除 loading 的 timeout 避免空指针引用等问题

  componentWillUnmount() {
    if (this.delayTimeout) {
      clearTimeout(this.delayTimeout);
    }
  }

按钮的渲染

与按钮渲染相关的主要有 2 个函数,其中一个是react生命周期自带的 render,render 的实现非常简单,用一个<ConfigCustomer>包裹了this.renderButton,这个ConfigCustomer是由 React context 创建出的一部分,用于 context 的消费

  render() {
    return <ConfigConsumer>{this.renderButton}</ConfigConsumer>;
  }

接下来看看最关键的this.renderButton的实现

renderButton = ({ getPrefixCls }: ConfigConsumerProps) => {
  const {
    prefixCls: customizePrefixCls,
    type,
    shape,
    size,
    className,
    children,
    icon,
    ghost,
    loading: _loadingProp, // 这个_loadingProp提取出来只是为了不让他进入...rest中,无其它用处
    block,
    ...rest
  } = this.props;

  const { loading, hasTwoCNChar } = this.state;

  const prefixCls = getPrefixCls('btn', customizePrefixCls); // 从context中获取class的前缀

  // 按钮尺寸
  // large => lg
  // small => sm
  let sizeCls = '';
  switch (size) {
    case 'large':
      sizeCls = 'lg';
      break;
    case 'small':
      sizeCls = 'sm';
    default:
      break;
  }

  const classes = classNames(prefixCls, className, {
    [`${prefixCls}-${type}`]: type,
    [`${prefixCls}-${shape}`]: shape,
    [`${prefixCls}-${sizeCls}`]: sizeCls,
    [`${prefixCls}-icon-only`]: !children && children !== 0 && icon, //是否是仅有图标的按钮,这种按钮是圆的
    [`${prefixCls}-loading`]: loading, // 加载中
    [`${prefixCls}-background-ghost`]: ghost, //幽灵按钮
    [`${prefixCls}-two-chinese-chars`]: hasTwoCNChar, // 按钮文字是否是两个中文
    [`${prefixCls}-block`]: block // 是否为块状按钮
  });

  const iconType = loading ? 'loading' : icon; // 按钮加载时icon强制为loading图标
  const iconNode = iconType ? <Icon type={iconType} /> : null; // icon组件
  const kids =
    children || children === 0 // 此处特判0是因为0是falsy但不是nullish,也需要渲染出来
      ? React.Children.map(children, child =>
          insertSpace(child, this.isNeedInserted())
        )
      : null;

  const linkButtonRestProps = omit(rest as AnchorButtonProps, ['htmlType']); // 当是link的时候从props中忽略'htmlType'属性
  if (linkButtonRestProps.href !== undefined) {
    // a标签的时候返回一个a标签
    return (
      <a
        {...linkButtonRestProps}
        className={classes}
        onClick={this.handleClick}
        ref={this.saveButtonRef}
      >
        {iconNode}
        {kids}
      </a>
    );
  }

  // React认不得htmlType,所以需要将它从`rest`提取出来
  const { htmlType, ...otherProps } = rest as NativeButtonProps;

  // 下面的`<Wave/>`组件,是按钮被点击时从四周扩散的波纹效果
  return (
    <Wave>
      <button
        {...otherProps as NativeButtonProps}
        type={htmlType || 'button'}
        className={classes}
        onClick={this.handleClick}
        ref={this.saveButtonRef}
      >
        {iconNode}
        {kids}
      </button>
    </Wave>
  );
};

内容为两个中文字时的空格添加

进一步观察 Button 的源码,发现当按钮内容为两个中文字时会在其中插入一个空格

首先看看插入空格的实现

// 自动地在两个中文字之间插入空格
function insertSpace(child: React.ReactChild, needInserted: boolean) {
  // 为null直接返回
  if (child == null) {
    return;
  }

  // 是否需要插入
  const SPACE = needInserted ? ' ' : '';

  // child为浏览器原生标签时,判断child.props.children是否为两个中文字符
  if (
    typeof child !== 'string' &&
    typeof child !== 'number' &&
    isString(child.type) &&
    isTwoCNChar(child.props.children)
  ) {
    return React.cloneElement(
      child,
      {},
      child.props.children.split('').join(SPACE)
    );
  }

  // child为string时,往两个中文字之中插入
  if (typeof child === 'string') {
    if (isTwoCNChar(child)) {
      child = child.split('').join(SPACE);
    }
    return <span>{child}</span>;
  }

  // 其它情况,直接返回传入的组件
  return child;
}

按钮组

按钮组的实现非常简单,下面直接贴代码,注释会在代码中

const ButtonGroup: React.SFC<ButtonGroupProps> = props => (
  // 从context中接收config
  <ConfigConsumer>
    {({ getPrefixCls }: ConfigConsumerProps) => {
      const {
        prefixCls: customizePrefixCls,
        size,
        className,
        ...others
      } = props;
      const prefixCls = getPrefixCls('btn-group', customizePrefixCls);

      // large => lg
      // small => sm
      let sizeCls = ''; // 按钮大小
      switch (size) {
        case 'large':
          sizeCls = 'lg';
          break;
        case 'small':
          sizeCls = 'sm';
        default:
          break;
      }

      const classes = classNames(
        prefixCls,
        {
          [`${prefixCls}-${sizeCls}`]: sizeCls // 当size位指定时,`${prefixCls}-${sizeCls}`不会出现在最终的className中
        },
        className
      );

      return <div {...others} className={classes} />;
    }}
  </ConfigConsumer>
);

export default ButtonGroup;