ssh的博客

react-component源码学习(2) rc-steps

October 10, 2018 • ☕️ 6 min read

rc-steps是antd的步骤组件所依赖的底层组件,先看官方给的用法示例。

<Steps current={1}>
  <Steps.Step title="first" />
  <Steps.Step title="second" />
  <Steps.Step title="third" />
</Steps>

简洁明了的父子嵌套组件。 先从父组件的源码看起。

Steps.jsx

/* eslint react/no-did-mount-set-state: 0 */
import React, { cloneElement, Children, Component } from 'react';
import PropTypes from 'prop-types';
import { findDOMNode } from 'react-dom';
import classNames from 'classnames';
import debounce from 'lodash/debounce';
import { isFlexSupported } from './utils';

export default class Steps extends Component {
  static propTypes = {
    prefixCls: PropTypes.string,
    className: PropTypes.string,
    iconPrefix: PropTypes.string,
    direction: PropTypes.string,
    labelPlacement: PropTypes.string,
    children: PropTypes.any,
    status: PropTypes.string,
    size: PropTypes.string,
    progressDot: PropTypes.oneOfType([
      PropTypes.bool,
      PropTypes.func,
    ]),
    style: PropTypes.object,
    initial: PropTypes.number,
    current: PropTypes.number,
    icons: PropTypes.shape({
      finish: PropTypes.node,
      error: PropTypes.node,
    }),
  };
  static defaultProps = {
    prefixCls: 'rc-steps',
    iconPrefix: 'rc',
    direction: 'horizontal',
    labelPlacement: 'horizontal',
    initial: 0,
    current: 0,
    status: 'process',
    size: '',
    progressDot: false,
  };
  constructor(props) {
    super(props);
    this.state = {
      flexSupported: true,
      lastStepOffsetWidth: 0,
    };
    this.calcStepOffsetWidth = debounce(this.calcStepOffsetWidth, 150);
  }
  componentDidMount() {
    this.calcStepOffsetWidth();
    if (!isFlexSupported()) {
      this.setState({
        flexSupported: false,
      });
    }
  }
  componentDidUpdate() {
    this.calcStepOffsetWidth();
  }
  componentWillUnmount() {
    if (this.calcTimeout) {
      clearTimeout(this.calcTimeout);
    }
    if (this.calcStepOffsetWidth && this.calcStepOffsetWidth.cancel) {
      this.calcStepOffsetWidth.cancel();
    }
  }
  calcStepOffsetWidth = () => {
    if (isFlexSupported()) {
      return;
    }
    // Just for IE9
    const domNode = findDOMNode(this);
    if (domNode.children.length > 0) {
      if (this.calcTimeout) {
        clearTimeout(this.calcTimeout);
      }
      this.calcTimeout = setTimeout(() => {
        // +1 for fit edge bug of digit width, like 35.4px
        const lastStepOffsetWidth = (domNode.lastChild.offsetWidth || 0) + 1;
        // Reduce shake bug
        if (this.state.lastStepOffsetWidth === lastStepOffsetWidth ||
          Math.abs(this.state.lastStepOffsetWidth - lastStepOffsetWidth) <= 3) {
          return;
        }
        this.setState({ lastStepOffsetWidth });
      });
    }
  }
  render() {
    const {
      prefixCls, style = {}, className, children, direction,
      labelPlacement, iconPrefix, status, size, current, progressDot, initial,
      icons,
      ...restProps,
    } = this.props;
    const { lastStepOffsetWidth, flexSupported } = this.state;
    const filteredChildren = React.Children.toArray(children).filter(c => !!c);
    const lastIndex = filteredChildren.length - 1;
    const adjustedlabelPlacement = !!progressDot ? 'vertical' : labelPlacement;
    const classString = classNames(prefixCls, `${prefixCls}-${direction}`, className, {
      [`${prefixCls}-${size}`]: size,
      [`${prefixCls}-label-${adjustedlabelPlacement}`]: direction === 'horizontal',
      [`${prefixCls}-dot`]: !!progressDot,
    });

    return (
      <div className={classString} style={style} {...restProps}>
        {
          Children.map(filteredChildren, (child, index) => {
            if (!child) {
              return null;
            }
            const stepNumber = initial + index;
            const childProps = {
              stepNumber: `${stepNumber + 1}`,
              prefixCls,
              iconPrefix,
              wrapperStyle: style,
              progressDot,
              icons,
              ...child.props,
            };
            if (!flexSupported && direction !== 'vertical' && index !== lastIndex) {
              childProps.itemWidth = `${100 / lastIndex}%`;
              childProps.adjustMarginRight = -Math.round(lastStepOffsetWidth / lastIndex + 1);
            }
            // fix tail color
            if (status === 'error' && index === current - 1) {
              childProps.className = `${prefixCls}-next-error`;
            }
            if (!child.props.status) {
              if (stepNumber === current) {
                childProps.status = status;
              } else if (stepNumber < current) {
                childProps.status = 'finish';
              } else {
                childProps.status = 'wait';
              }
            }
            return cloneElement(child, childProps);
          })
        }
      </div>
    );
  }
}

首先看到在componentDidMount, componentDidUpdate阶段都调用了calcStepOffsetWidth这个方法,这个方法其实就是计算lastStepOffsetWidth最后一个步骤条的偏移距离 用来调整子组件的间距到正好撑满容器的效果。

calcStepOffsetWidth

在这个方法的开头,我们看到

if (isFlexSupported()) {
   return;
}

如果浏览器支持flex,就直接return,因为flex本身就是弹性自适应布局,

export function isFlexSupported() {
  if (typeof window !== 'undefined' && window.document && window.document.documentElement) {
    const { documentElement } = window.document;
    return 'flex' in documentElement.style ||
      'webkitFlex' in documentElement.style ||
      'Flex' in documentElement.style ||
      'msFlex' in documentElement.style;
  }
  return false;
}

如果不支持flex, 则先用React.findDomNode(this)拿到当前组件的dom节点,然后用了一个类似debouce的处理,利用setTimout在下一个事件循环里处理,并且保证一个事件循环里触发的多次此方法被归并成一次, 拿到children中lastChild的offsetWidth并且赋给state的lastStepOffsetWidth。

render

filteredChildren是利用React.Children.toArray把子节点转成数组且过滤掉空节点,然后拿到lastIndex最后一项的序号,在最后的return中调用React.Children.map循环子节点数组,在这个循环中,stepNumber是props.initial + index,childProps在child原有的props基础上扩展了 stepNumber步骤序号和一系列样式,

if (!flexSupported && direction !== 'vertical' && index !== lastIndex) {
      childProps.itemWidth = `${100 / lastIndex}%`;
      childProps.adjustMarginRight = -Math.round(lastStepOffsetWidth / lastIndex + 1);
}

在不支持flex的情况下继续扩展 itemWidth为 100除以最后一项的下标 adjustMarginRight 是上面计算的lastStepOffsetWidth除以子元素数量并取负。

// fix tail color
   if (status === 'error' && index === current - 1) {
  childProps.className = `${prefixCls}-next-error`;
}

status代表props中传入的当前步骤的状态,如果是错误并且这时候的step是当前步骤的前一个的话,加一个next-error的class

          if (!child.props.status) {
              if (stepNumber === current) {
                childProps.status = status;
              } else if (stepNumber < current) {
                childProps.status = 'finish';
              } else {
                childProps.status = 'wait';
              }
            }

这段是假设用户不传入status的情况下自动计算当前应该的状态, current之前是finished 之后是wait

 return cloneElement(child, childProps);

最后调用React.cloneElement把child和childProps合并成一个新节点返回。

Step.jsx

import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';

function isString(str) {
  return typeof str === 'string';
}

export default class Step extends React.Component {
  static propTypes = {
    className: PropTypes.string,
    prefixCls: PropTypes.string,
    style: PropTypes.object,
    wrapperStyle: PropTypes.object,
    itemWidth: PropTypes.oneOfType([
      PropTypes.number,
      PropTypes.string,
    ]),
    status: PropTypes.string,
    iconPrefix: PropTypes.string,
    icon: PropTypes.node,
    adjustMarginRight: PropTypes.oneOfType([
      PropTypes.number,
      PropTypes.string,
    ]),
    stepNumber: PropTypes.string,
    description: PropTypes.any,
    title: PropTypes.any,
    progressDot: PropTypes.oneOfType([
      PropTypes.bool,
      PropTypes.func,
    ]),
    tailContent: PropTypes.any,
    icons: PropTypes.shape({
      finish: PropTypes.node,
      error: PropTypes.node,
    }),
  };
  renderIconNode() {
    const {
      prefixCls, progressDot, stepNumber, status, title, description, icon,
      iconPrefix, icons,
    } = this.props;
    let iconNode;
    const iconClassName = classNames(`${prefixCls}-icon`, `${iconPrefix}icon`, {
      [`${iconPrefix}icon-${icon}`]: icon && isString(icon),
      [`${iconPrefix}icon-check`]: !icon && status === 'finish' && (icons && !icons.finish),
      [`${iconPrefix}icon-close`]: !icon && status === 'error' && (icons && !icons.error),
    });
    const iconDot = <span className={`${prefixCls}-icon-dot`}></span>;
    // `progressDot` enjoy the highest priority
    if (progressDot) {
      if (typeof progressDot === 'function') {
        iconNode = (
          <span className={`${prefixCls}-icon`}>
            {progressDot(iconDot, { index: stepNumber - 1, status, title, description })}
          </span>
        );
      } else {
        iconNode = <span className={`${prefixCls}-icon`}>{iconDot}</span>;
      }
    } else if (icon && !isString(icon)) {
      iconNode = <span className={`${prefixCls}-icon`}>{icon}</span>;
    } else if (icons && icons.finish && status === 'finish') {
      iconNode = <span className={`${prefixCls}-icon`}>{icons.finish}</span>;
    } else if (icons && icons.error && status === 'error') {
      iconNode = <span className={`${prefixCls}-icon`}>{icons.error}</span>;
    } else if (icon || status === 'finish' || status === 'error') {
      iconNode = <span className={iconClassName} />;
    } else {
      iconNode = <span className={`${prefixCls}-icon`}>{stepNumber}</span>;
    }

    return iconNode;
  }
  render() {
    const {
      className, prefixCls, style, itemWidth,
      status = 'wait', iconPrefix, icon, wrapperStyle,
      adjustMarginRight, stepNumber,
      description, title, progressDot, tailContent,
      icons,
      ...restProps,
    } = this.props;

    const classString = classNames(
      `${prefixCls}-item`,
      `${prefixCls}-item-${status}`,
      className,
      { [`${prefixCls}-item-custom`]: icon },
    );
    const stepItemStyle = { ...style };
    if (itemWidth) {
      stepItemStyle.width = itemWidth;
    }
    if (adjustMarginRight) {
      stepItemStyle.marginRight = adjustMarginRight;
    }
    return (
      <div
        {...restProps}
        className={classString}
        style={stepItemStyle}
      >
        <div className={`${prefixCls}-item-tail`}>
          {tailContent}
        </div>
        <div className={`${prefixCls}-item-icon`}>
          {this.renderIconNode()}
        </div>
        <div className={`${prefixCls}-item-content`}>
          <div className={`${prefixCls}-item-title`}>
            {title}
          </div>
          {description && <div className={`${prefixCls}-item-description`}>{description}</div>}
        </div>
      </div>
    );
  }
}

子组件里就是根据父组件计算的一些props和本身的props计算出图标和状态进行渲染。