Antd Button 源码分析(一)

446 阅读8分钟

antd 版本:5.12.5

本文是对 Button 组件整体流程进行分析。后续会对 useStyle 的实现进行分析。

在分析前先对 Token 和样式进行一些说明。

Token 与 Theme

Token

Token 可以看作是一组语义化的样式值。它类似于程序中对数值或字面量的处理方式,即为了提高可读性,也便于后续修改,会定义一个常量或变量来间接引用。

Token 常见类型:

  • SeedToken(基础变量)
    可以理解为,它为主题定义了所需的基本样式。其他样式会通过具体的派生算法生成。

  • MapToken(梯度变量)
    它是主题对 SeedToken 进行派生后生成。也可以进一步看作为它包含主题下所有通用样式。

例:MapToken 定义有小号,标准,大号,超大号等字号。主题以 SeedToken 中的标准字号为基础,计算出其他字号。

{
fontSizeSm
fontSize
fontSizeLg
fontSizeXl
...
}
  • Alias Token(别名变量)
    对 MapToken 进一步处理后得到。如:通过自定义样式覆盖默认样式。

  • Component Token
    它定义了组件独有的样式,每个组件都会定义一个。

Theme

主题包含一组派生函数。派生函数接收一个 SeedToken 通过派生算法生成 MapToken。

默认派生函数如下:

export default function derivative(token) {
  // 为每个预制颜色,生成一系列梯度颜色
  // {blue-1:#xxxx, blue1, purple-2:xxx, purple2:xxx}
  const colorPalettes = Object.keys(defaultPresetColors).map(colorKey => {
    // 生成给定颜色的梯度颜色。
    // 如 输入 #1890ff
    // 输出 ['#E6F7FF', '#BAE7FF', '#91D5FF', ''#69C0FF', '#40A9FF', '#1890FF', '#096DD9', '#0050B3', '#003A8C', '#002766']
    const colors = generate(token[colorKey]);
    // 将生成的这些梯度颜色放入一个对象中。
    return new Array(10).fill(1).reduce((prev, _, i) => {
      prev[`${colorKey}-${i + 1}`] = colors[i];
      prev[`${colorKey}${i + 1}`] = colors[i];
      return prev;
    }, {});
  }).reduce((prev, cur) => { // 再次将所有对象组合为一个对象。
    prev = Object.assign(Object.assign({}, prev), cur);
    return prev;
  }, {});
  return Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({}, token), colorPalettes), genColorMapToken(token, {
    generateColorPalettes,
    generateNeutralColorPalettes
    // genFontMapToken 根据 fontSize 传入的字号生成一系列不同大小的字号和行高等。
    // genSizeMapToken 根据 sizeUnit 和 sizeStep 生成一系列的 size。如:sizeXXL,sizeXL,sizeLG
    // genControlHeight 根据 controlHeight 生成 controlHeightSM,controlHeightXS 和 controlHeightLG
    // genCommonMapToken 根据 motionUnit,motionBase,borderRadius,lineWidth 生成
    // 动效的时间(fast,mid,slow),弧度(XS,SM,LG 等)和 lineWidthBold。
  })), genFontMapToken(token.fontSize)), genSizeMapToken(token)), genControlHeight(token)), genCommonMapToken(token));
}

对字号和行高的推导:Ant Design 3.0 背后的故事

样式的生成

Button 组件的样式的插入主要在 useStyle 和它返回的 wrapCSSVar 函数中处理。

样式生成大体过程:

  1. 经过主题派生和样式覆盖,生成别名变量。
  2. 由别名变量生成组件相关的 CSS Object 对象。
  3. 解析 CSS Object 对象,生成样式字符串。
  4. 使用 stylis 编译样式字符串,并缓存。
  5. 在组件挂载时,插入样式。

例:会生成如下样式:

<style data-rc-order="prependQueue" data-rc-priority="-999" data-css-hash="1a20nhr" 
    data-token-hash="pi6o7t" data-cache-path="pi6o7t|Button-Button|ant-btn|anticon">
        :where(.css-dev-only-do-not-override-gzal6t).ant-btn{outline:none;position:relative;
        display:inline-block;font-weight:400;white-space:nowrap;text-align:center;
        background-image:none;background:transparent;border:1px solid transparent;
        cursor:pointer;transition:all 0.2s cubic-bezier(0.645, 0.045, 0.355, 1);user-select:none;
        touch-action:manipulation;line-height:1.5714285714285714;color:rgba(0, 0, 0, 0.88);}
</style>

style 元素属性

以上面示例中的类选择器为说明

  • data-rc-order 表示样式的插入方式。
    取值如下:
    prependQueue: 根据优先级插入,优先级高的在后面。在值为 "prependQueue" 和 "prepend" 的元素之间比较。
    prepend: 插入到值为 "prependQueue" 和 "prepend" 的元素之前。
    append: 与 "prepend" 相反。
  • data-rc-priority 元素的优先级。通常是值为 "prependQueue" 的元素才有,但不是必须。默认为 0。
  • data-css-hash 值为 styleId。styleId 由 css 样式生成。在使用 css 变量时,值为 tokenKey 或 cssKey 与特定前缀生成。
  • data-token-hash 值为 tokenKey 即由 Token 生成。
  • data-cache-path 样式缓存的 key。

css 类选择器

以上面示例中的类选择器为说明

:where(.css-dev-only-do-not-override-gzal6t).ant-btn
  • :where()
    用来降低优先级,默认开启,可以通过配置关闭。
  • hashId
    是由前缀与经过哈希后的 tokenKey 构成。
    在生产环境下前缀为"css",其他环境下前缀为"css-dev-only-do-not-override"。
    由于 tokenKey 和 token 对应,所以不同主题的样式可以通过 hashId 区分。
    同样 hashId 也可以通过配置指定开启和关闭。
    如:
<ConfigProvider theme={{ hashed: false }}>
...
</ConfigProvider>
  • ant-btn
    是组件的默认类选择名。也可以自定义。

css 变量

antd 还支持使用 css 变量,默认不开启。

通过如下配置开启

<ConfigProvider theme={{ cssVar: true }}>
...
</ConfigProvider>

开启后会将 Token 中的样式值转换为 css 变量方式,并生成一个包含变量声明的样式。

例:

 aliasToken = {
  colorLink: #1677ff,
 }

转换为

aliasToken = {
  colorLink: var(--ant-color-link),
}

cssStr = ".xxxx{--ant-color-link: #1677ff}"
<style data-rc-order="prependQueue" data-rc-priority="-999" data-css-hash="ssvk7y" 
  data-token-hash="css-var-r1">
      .css-var-r1{--ant-color-link:#1677ff;
          ...}
</style>

<style data-rc-order="prependQueue" data-rc-priority="-999" data-css-hash="15hi3pz" 
data-token-hash="mnvxs0" data-cache-path="mnvxs0|Shared|ant">
    :where(.css-dev-only-do-not-override-15xh0f8) a{color:var(--ant-color-link);...}
</style>

在使用 css 变量时,声明变量的类选择器前缀和它所属元素的 data-token-hash 属性值为 cssStr.key,而不是 hashId。

如果没有指定 cssStr.key, 使用默认的 "css-var-" + useId().replace(/:/g, '')。
注:useId 为 React18 新增。

源码分析

const Button = /*#__PURE__*/forwardRef(InternalButton);
  1. 解析属性
  2. 获取 ConfigContext 中为 Button 配置的样式(class 和行样式),自定义的样式前缀,是否允许插入空格和布局方向等。
  3. 调用 useStyle,插入通用样式和组件样式,并缓存。其中返回的 wrapCSSVar 函数主要用来在开启 css 变量时,插入组件样式相关的 css 变量的声明。
  4. 处理加载逻辑。加载图标根据参数 loading 可以立即显示或者延迟显示。
  5. 如果按钮带边框,且只有一个是两个中文字符的子元素,当 autoInsertSpaceInButton 为 true 时,插入一个空格。
  6. 为容器和 icon 设置样式(class 和 style)。
    • 根据参数生成
    • ConfigContext 中配置的
    • 父类 Space.Compact 设置样式
  7. 创建 icon 或 loading 子元素。icon 和 loading 为互斥。
  8. 创建容器。
    如果有设置 href 参数则创建 a 标签,否则创建 button 标签。
  9. 如果容器是带边框的 button,添加水波纹。
  10. 调用 wrapCSSVar。
const InternalButton = (props, ref) => {
  var _a, _b;
  const {
      loading = false, // 当为对象类型时,可设置 delay 属性,用来在延迟 xxx 毫秒后变为加载状态
      prefixCls: customizePrefixCls,
      type = 'default',
      danger,
      shape = 'default',
      size: customizeSize,
      styles,
      disabled: customDisabled,
      className,
      rootClassName,
      children,
      icon,
      ghost = false,
      block = false,
      // React does not recognize the `htmlType` prop on a DOM element. Here we pick it out of `rest`.
      htmlType = 'button',
      classNames: customClassNames,
      style: customStyle = {}
    } = props,
    // 获取数组之外的属性
    rest = __rest(props, ["loading", "prefixCls", "type", "danger", "shape", "size", "styles", "disabled", "className", "rootClassName", "children", "icon", "ghost", "block", "htmlType", "classNames", "style"]);
  const {
    getPrefixCls, // 函数,用来获取生成样式的前缀。
    autoInsertSpaceInButton, // 是否插入空格符
    direction, // 布局方向
    button // 为 button 组件配置的普通样式和行内样式
  } = useContext(ConfigContext);
  // 如果有指定自定义前缀 customizePrefixCls,则直接返回 customizePrefixCls
  // 否则返回 "ant-btn",如果没有传第一个参数,返回 "ant"。
  const prefixCls = getPrefixCls('btn', customizePrefixCls);
  const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls);
  const disabled = useContext(DisabledContext);
  // 按钮是否是禁用状态,优先使用 customDisabled。
  const mergedDisabled = customDisabled !== null && customDisabled !== void 0 ? customDisabled : disabled;
  const groupSize = useContext(GroupSizeContext);
  // loadingOrDelay = {loading, delay} 
  // 如果 delay > 0 则会在延迟 delay 毫秒后变为加载状态。
  // 否则立即变为指定状态
  const loadingOrDelay = useMemo(() => getLoadingConfig(loading), [loading]);
  const [innerLoading, setLoading] = useState(loadingOrDelay.loading);
  const [hasTwoCNChar, setHasTwoCNChar] = useState(false);
  const internalRef = /*#__PURE__*/createRef();
  const buttonRef = composeRef(ref, internalRef);
  // 按钮带边框,且只有一个子元素,且没有配置 icon 元素
  const needInserted = Children.count(children) === 1 && !icon && !isUnBorderedButtonType(type); // type 不属于 'text' 或 'link' 类型 即按钮带边框 
  useEffect(() => {
    let delayTimer = null;
    if (loadingOrDelay.delay > 0) { // 如果有设置延迟,在 delay 毫秒后变为加载状态
      delayTimer = setTimeout(() => {
        delayTimer = null;
        setLoading(true);
      }, loadingOrDelay.delay);
    } else { // 立即改变状态
      setLoading(loadingOrDelay.loading);
    }
    function cleanupTimer() {
      if (delayTimer) {
        clearTimeout(delayTimer);
        delayTimer = null;
      }
    }
    return cleanupTimer;
  }, [loadingOrDelay]);
  useEffect(() => {
    // FIXME: for HOC usage like <FormatMessage />
    if (!buttonRef || !buttonRef.current || autoInsertSpaceInButton === false) {
      return;
    }
    const buttonText = buttonRef.current.textContent;
    // 仅两个中文
    if (needInserted && isTwoCNChar(buttonText)) {
      if (!hasTwoCNChar) {
        setHasTwoCNChar(true);
      }
    } else if (hasTwoCNChar) {
      setHasTwoCNChar(false);
    }
  }, [buttonRef]);
  const handleClick = e => {
    const {
      onClick
    } = props;
    // 按钮是加载状态或禁用状态,拦截事件
    // FIXME: https://github.com/ant-design/ant-design/issues/30207
    if (innerLoading || mergedDisabled) {
      e.preventDefault();
      return;
    }
    onClick === null || onClick === void 0 ? void 0 : onClick(e);
  };
  if (process.env.NODE_ENV !== 'production') {
    const warning = devUseWarning('Button');
    process.env.NODE_ENV !== "production" ? 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`) : void 0;
    process.env.NODE_ENV !== "production" ? warning(!(ghost && isUnBorderedButtonType(type)), 'usage', "`link` or `text` button can't be a `ghost` button.") : void 0;
  }
  const autoInsertSpace = autoInsertSpaceInButton !== false;
  const {
    compactSize,
    compactItemClassnames
  } = useCompactItemContext(prefixCls, direction); // 配合父类 Space.Compact,调整样式。
  const sizeClassNameMap = {
    large: 'lg',
    small: 'sm',
    middle: undefined
  };
  const sizeFullName = useSize(ctxSize => {
    var _a, _b;
    return (_b = (_a = customizeSize !== null && customizeSize !== void 0 ? customizeSize : compactSize) !== null && _a !== void 0 ? _a : groupSize) !== null && _b !== void 0 ? _b : ctxSize;
  });
  const sizeCls = sizeFullName ? sizeClassNameMap[sizeFullName] || '' : '';
  const iconType = innerLoading ? 'loading' : icon;
  // 移除 navigate 属性
  const linkButtonRestProps = omit(rest, ['navigate']);
  const classes = classNames(prefixCls, hashId, cssVarCls, {
    [`${prefixCls}-${shape}`]: shape !== 'default' && shape,
    [`${prefixCls}-${type}`]: type,
    [`${prefixCls}-${sizeCls}`]: sizeCls,
    [`${prefixCls}-icon-only`]: !children && children !== 0 && !!iconType,
    [`${prefixCls}-background-ghost`]: ghost && !isUnBorderedButtonType(type),
    [`${prefixCls}-loading`]: innerLoading,
    [`${prefixCls}-two-chinese-chars`]: hasTwoCNChar && autoInsertSpace && !innerLoading,
    [`${prefixCls}-block`]: block,
    [`${prefixCls}-dangerous`]: !!danger,
    [`${prefixCls}-rtl`]: direction === 'rtl'
  }, compactItemClassnames, className, rootClassName, button === null || button === void 0 ? void 0 : button.className);
  // 行内样式
  const fullStyle = Object.assign(Object.assign({}, button === null || button === void 0 ? void 0 : button.style), customStyle);
  // icon 样式
  const iconClasses = classNames(customClassNames === null || customClassNames === void 0 ? void 0 : customClassNames.icon, (_a = button === null || button === void 0 ? void 0 : button.classNames) === null || _a === void 0 ? void 0 : _a.icon);
  // icon 行内样式
  const iconStyle = Object.assign(Object.assign({}, (styles === null || styles === void 0 ? void 0 : styles.icon) || {}), ((_b = button === null || button === void 0 ? void 0 : button.styles) === null || _b === void 0 ? void 0 : _b.icon) || {});
  // 当按钮是加载状态时,不展示 icon。
  const iconNode = icon && !innerLoading ? ( /*#__PURE__*/React.createElement(IconWrapper, {
    prefixCls: prefixCls,
    className: iconClasses,
    style: iconStyle
  }, icon)) : ( /*#__PURE__*/React.createElement(LoadingIcon, {
    existIcon: !!icon,
    prefixCls: prefixCls,
    loading: !!innerLoading
  }));
  // 如果按钮有边框且只有一个子元素(没有设置 icon),如果子元素是两个中文或子元素的子元素是两个中文
  // 插入一个空格。
  const kids = children || children === 0 ? spaceChildren(children, needInserted && autoInsertSpace) : null;
  // 如果有设置 a 标签的 href 属性,创建 a 标签
  if (linkButtonRestProps.href !== undefined) {
    return wrapCSSVar( /*#__PURE__*/React.createElement("a", Object.assign({}, linkButtonRestProps, {
      className: classNames(classes, {
        [`${prefixCls}-disabled`]: mergedDisabled
      }),
      href: mergedDisabled ? undefined : linkButtonRestProps.href,
      style: fullStyle,
      onClick: handleClick,
      ref: buttonRef,
      tabIndex: mergedDisabled ? -1 : 0
    }), iconNode, kids));
  }
  // 创建 button
  let buttonNode = /*#__PURE__*/React.createElement("button", Object.assign({}, rest, {
    type: htmlType,
    className: classes,
    style: fullStyle,
    onClick: handleClick,
    disabled: mergedDisabled,
    ref: buttonRef
  }), iconNode, kids, compactItemClassnames && /*#__PURE__*/React.createElement(CompactCmp, {
    key: "compact",
    prefixCls: prefixCls
  }));
  // 按钮带边框,添加水波纹组件
  if (!isUnBorderedButtonType(type)) {
    buttonNode = /*#__PURE__*/React.createElement(Wave, {
      component: "Button",
      disabled: !!innerLoading
    }, buttonNode);
  }
  return wrapCSSVar(buttonNode);
};