Skip to content

CSS in JS 迁移指南

MadCcc edited this page Aug 3, 2022 · 10 revisions

概念

在做 CSS-in-JS 的迁移之前,先了解一下 v5 相对于 v4 新增的一些概念。

Design Token

Design Token 是一个设计系统,简单点说的话可以类比成 v4 就有的 less 变量,是一些原子化的样式变量,比如 border 的线宽、颜色等等,以 JS 变量的形式串联在 antd 组件中。在 v5 中我们用 CSS-in-JS 替代了 less,因此 Design Token 也替代 less 变量支撑起了 v5 的主题系统。

Hashed styles

在 v4 及以前的版本中,我们遵循了一套严格且语义化的 class 命名方法,这为每个组件及其不同状态的样式隔离提供了很好的环境。美中不足的是我们需要考虑的还不仅仅是组件间的,还有不同 antd 版本之间的样式隔离,比如在一个系统中存在多个版本的 antd 时,由于 class 的命名都是相通的,不同版本的样式就会产生污染,导致一些预料之外的显示。 在迁移了 CSS-in-JS 之后,这个问题迎刃而解。我们提供了 hashId,只有在 Design Token 或者版本发生变化时,这个 hashId 才会改变。在组件中,hashId 可以作为 className 赋给 HTML 元素,通常会是 css-[hash] 的样子,同时所有的样式都会和这个 hashed class 强相关,比如原本使用的的 .ant-btn 选择器会变成 .css-[hash].ant-btn。因此不同版本的或者拥有不同 token 的 antd 所产生的样式就被 hashId 隔离开,从而从根本上解决样式污染问题。

如何迁移

启用 CSS-in-JS

这里 Button 组件为例

  1. 注释 components/button/style/index.less 文件,确保该组件的所有样式都已移除。
  2. 用以下代码替换 components/button/style/index.tsx,实际请替换 Button 为相应的组件名:
// deps-lint-skip-all
import { genComponentStyleHook } from '../../_util/theme';
import type { FullToken } from '../../_util/theme';

/** Component only token. Which will handle additional calculation of alias token */
export interface ComponentToken {
  // Component token here
}

interface ButtonToken extends FullToken<'Button'> {
  // Custom token here
}

// ============================== Export ==============================
export default genComponentStyleHook(
  'Button',
  token => [
    // Gen-style functions here
  ],
);

同时在 components/_util/theme/interface.ts 中的 interface OverrideToken 添加相应组件的 ComponentToken:

// ...
import type { ComponentToken as ButtonComponentToken } from '../../button/style';

export interface OverrideToken {
  derivative?: Partial<DerivativeToken & AliasToken>;
  
  // Customize component
  Button?: ButtonComponentToken;
}
// ...
  1. 在组件中引入 components/button/style/index.tsx 导出的 hook,该 hook 要求传入参数是该组件的 prefixCls,返回一个数组,第一个元素是一个 wrapper,要求包裹最终 return 的元素;第二个元素则是上文中提到过的 hashId,我们需要将它作为 className 添加给该组件的最外层元素:
// ...
+ import useStyle from './style';

const InternalButton: React.ForwardRefRenderFunction<unknown, ButtonProps> = (props, ref) => {
  // ...
  const prefixCls = getPrefixCls('btn', customizePrefixCls);
  
+ // Style
+ const [wrapSSR, hashId] = useStyle(prefixCls);
  
  //...
  const classes = classNames(
    prefixCls,
+   hashId,
    {
      // ...
    },
    className,
  );
  
  let buttonNode = (
    // ...
  );

- return buttonNode;
+ return wrapSSR(buttonNode);
};

到这一步为止, CSS-in-JS 的链路已经打通,我们可以试着为组件添加一些样式:

// deps-lint-skip-all
import { genComponentStyleHook } from '../../_util/theme';
import type { FullToken, GenerateStyle } from '../../_util/theme';

/** Component only token. Which will handle additional calculation of alias token */
export interface ComponentToken {
  // Component token here
}

interface ButtonToken extends FullToken<'Button'> {
  // Custom token here
}

const genButtonStyle: GenerateStyle<ButtonToken> = token => {
  const { componentCls } = token;
  
  return {
    [componentCls]: {
      border: '1px solid red',
    }
  };
};

// ============================== Export ==============================
export default genComponentStyleHook(
  'Button',
  token => [
    // Gen-style functions here
    genButtonStyle(token),
  ],
);

随后运行项目,打开 Button 页面,可以看见样式已经生效,同时检查元素,可以看见添加上去的 hashId 和对应的选择器: image image

Token 的组成

可以注意到上文中注入 CSS-in-JS 样式时,我们利用 genButtonStyle 方法返回了一个 CSSObject,这个 function 的唯一参数就是 tokentoken 是一些变量的集合,其中就包括了上文中提到的 Design Token,在 antd 中我们对这些 Design Token 作了二次封装,产生了一系列 AliasToken,比如 token.padding 等等,我们可以利用 token 消费 Design Token。除了 Design Token 之外,当前组件中定义的 ComponentToken 也会存在于 token 中,同样作为设计资源消费。 为了辅助注入样式,我们还在 token 中提供了一些常用的变量:

{
  prefixCls: string;
  componentCls: string;
  iconCls: string;
  antCls: string;
};
  • prefixCls:即为调用 useStyle 时传入的 prefixCls
  • componentCls:实现为 .${prefixCls} ,即做了 prefixCls 到 CSS 选择器的转化。
  • iconCls:值为 .anticon,是 ant icon 的固定 className。
  • antCls:值为 .ant,是 antd 的 className 前缀,常用于在组件中处理非当前组件的样式。

添加 ComponentToken

我们可以为组件添加一些组件级别的 token,这些 token 只能在当前组件被消费,同时可以被用户覆盖。ComponentToken 在上文中的同名 interface 中声明,并且在 genComponentStyleHook 的第三个参数中初始化,随后就会被注入到 token 中,继续消费。

// deps-lint-skip-all
import { genComponentStyleHook } from '../../_util/theme';
import type { FullToken, GenerateStyle } from '../../_util/theme';

/** Component only token. Which will handle additional calculation of alias token */
export interface ComponentToken {
  // Component token here
  buttonBorderColor: string;
}

interface ButtonToken extends FullToken<'Button'> {
  // Custom token here
}

const genButtonStyle: GenerateStyle<ButtonToken> = token => {
  const { componentCls } = token;
  
  return {
    [componentCls]: {
      border: `1px solid ${token.buttonBorderColor}`,
    }
  };
};

// ============================== Export ==============================
export default genComponentStyleHook(
  'Button',
  token => [
    // Gen-style functions here
    genButtonStyle(token),
  ],
  {
    buttonBorderColor: 'cyan'
  }
);

效果:

image

我们也可以在初始化 ComponentToken 时利用原有的 token,方法为将 genComponentStyleHook 第三个参数由传入对象改为 function,token 会作为参数提供(仅提供基本的 token):

// deps-lint-skip-all
import { genComponentStyleHook } from '../../_util/theme';
import type { FullToken, GenerateStyle } from '../../_util/theme';

/** Component only token. Which will handle additional calculation of alias token */
export interface ComponentToken {
  // Component token here
  buttonBorderColor: string;
}

interface ButtonToken extends FullToken<'Button'> {
  // Custom token here
}

const genButtonStyle: GenerateStyle<ButtonToken> = token => {
  const { componentCls } = token;
  
  return {
    [componentCls]: {
      border: `1px solid ${token.buttonBorderColor}`,
    }
  };
};

// ============================== Export ==============================
export default genComponentStyleHook(
  'Button',
  token => [
    // Gen-style functions here
    genButtonStyle(token),
  ],
  token => ({
    buttonBorderColor: token.colorPrimary,
  }),
);

效果:

image

添加自定义 token

一些复杂组件需要利用已有 token 进行计算得出一些复杂的计算值,或者需要自定义一些 token 复用,但是这些 token 并不希望暴露给用户,那么我们就可以在组件内添加一些自定义 token,并且需要自行组装并传入 genStyle 方法中。

// deps-lint-skip-all
import { genComponentStyleHook, mergeToken } from '../../_util/theme';
import type { FullToken, GenerateStyle } from '../../_util/theme';

/** Component only token. Which will handle additional calculation of alias token */
export interface ComponentToken {
  // Component token here
}

interface ButtonToken extends FullToken<'Button'> {
  // Custom token here
  customBorderWidth: number;
}

const genButtonStyle: GenerateStyle<ButtonToken> = token => {
  const { componentCls } = token;

  return {
    [componentCls]: {
      border: `${token.customBorderWidth}px solid green`,
    },
  };
};

// ============================== Export ==============================
export default genComponentStyleHook(
  'Button',
  token => {
    // Use mergeToken to merge token, DO NOT use { ...token, prop: xxx }
    const buttonToken = mergeToken<ButtonToken>(token, {
      customBorderWidth: 2,
    });
    return [
      // Gen-style functions here
      genButtonStyle(buttonToken),
    ]
  },
);

注意:合并 token 请务必使用 mergeToken 方法,不要使用解构语法,会导致 token 的统计不准确

效果:

image

CSS-in-JS 要点

CSS-in-JS 库: @ant-design/cssinjs

CSS-in-JS 也会有相应的检查,平常在 dev 时会在 console 中打印 warning,这些 warning 都是需要被解决的。

优先级调整

在 v5 中,由于 hash 的存在,我们需要对 v4 的一部分样式优先级作调整。所有的 root class 之外的选择器都需要作为其 root class 的子选择器,保证 hash 对所有样式都有控制作用。举个例子:

.btn {
  width: 100px;
  
  &-inner {
    fontSize: 14px;
  }
}

迁移后需要修改为:

const genStyle = () => ({
  '.btn': {
    width: 100,
    
    '.btn-inner': {
      fontSize: 14;
    }
  }
});

根据具体场景的不同处理方式可能不同,这里仅作举例

CSS Logical Properties and Values

参考链接:https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Logical_Properties

Ant Design 目前支持 RTL 模式,在 v4 版本中,对 RTL 的支持通过覆写样式实现,比如:

.ant-component {
  paddingLeft: 12px;
  
  &-rtl {
    direction: rtl;
    paddingLeft: 0;
    paddingRight: 12px;
  }
}

这样的写法会让我们的代码变得非常臃肿,并且经常会有样式优先级的问题,导致样式不生效等问题。在 v5 中,我们全面使用 CSS Logical Properties and Values,让 RTL 支持只需要改变 direction 就可以实现。所以如果你在编写过程中看到如下 warning,就代表需要用 CSS Logical Properties and Values 替换原有的写法:

Warning: [Ant Design CSS-in-JS] Error in 'ant-btn -> .css-dev-only-do-not-override-1kw74a7.ant-btn': You seem to be using non-logical property 'paddingLeft' which is not compatible with RTL mode. Please use logical properties and values instead. For more information: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Logical_Properties.

简单解释下 CSS Logical Properties and Values,代表方位的 properties 和 values 可以用另一种形式指定,比如 marginLeft 可以用 marginInlineStart 代替,会被 direction 所影响,在 LTR 下就相当于 marginLeft,在 RTL 下相当于 marginRight。基本所有方位相关的 CSS 属性和值都可以被替代,具体请查看 MDN 文档。 在 antd 中,我们仅对 RTL 作支持,所以只会对 left 和 right 两个方位作检查,top 和 bottom 则可以忽略。

Content

在传统 CSS 及其他类似于 less 的预处理器中,我们写 content 时是这样的:

.demo::before {
  content: "";
}

但是在 CSS-in-JS 的 CSSObject 中,如果这样写是不会得到预期效果的:

const genStyle = () => ({
  content: '',
});

因为 CSS 需要的值是 "",而 CSS-in-JS 编译后就只剩一个空字符串了,所以这里需要包裹一层引号:

const genStyle = () => ({
  content: '""',
});

同理,如果你想使用 utf-8 编码如 '\a0' 作为 content 的值,在 CSS-in-JS 中需要对 \ 做一次转译:

const genStyle = () => ({
  content: '"\\a0"',
});

Keyframes 和 Animation

@ant-design/cssinjs 中我们提供了 Keyframes 类用于产出 CSS 中的 @keyframes

import { Keyframes } from '@ant-design/cssinjs';

const zoomIn = new Keyframes('antZoomIn', {
  '0%': {
    transform: 'scale(0.2)',
    opacity: 0,
  },

  '100%': {
    transform: 'scale(1)',
    opacity: 1,
  },
});

之后我们就可以直接把 Keyframes当作 animationName的值传入,@keyframes也会被自动注入 :

// deps-lint-skip-all
import { genComponentStyleHook, mergeToken } from '../../_util/theme';
import type { FullToken, GenerateStyle } from '../../_util/theme';
import { Keyframes } from '@ant-design/cssinjs';

/** Component only token. Which will handle additional calculation of alias token */
export interface ComponentToken {
  // Component token here
}

interface ButtonToken extends FullToken<'Button'> {
  // Custom token here
}

const zoomIn = new Keyframes('antZoomIn', {
  '0%': {
    transform: 'scale(0.2)',
    opacity: 0,
  },

  '100%': {
    transform: 'scale(1)',
    opacity: 1,
  },
});

const genButtonStyle: GenerateStyle<ButtonToken> = token => {
  const { componentCls } = token;

  return {
    [componentCls]: {
      animationName: zoomIn,
      animationDuration: token.motionDurationMid,
      animationTimingFunction: token.motionEaseOutBack,
    },
  };
};

// ============================== Export ==============================
export default genComponentStyleHook(
  'Button',
  token => [
    // Gen-style functions here
    genButtonStyle(token),
  ],
);

注意:推荐将 Keyframes直接赋给 animationName,原因是在 hash 模式下 keyframes 也会带上 hashId,如果直接将 Keyframesname写入 animation将不会有效果。如果不使用上述方法,需要自行注入 keyframes,将 Keyframes实例声明在 style 中即可。

辅助工具

Demo Diff

打开页面右下角的 Dynamic Theme, 上方会有一个 Diff 选项,打开后可以看到当前页面和线上版本的区别。虽然只是简单的对比,有些场景覆盖不到(比如弹窗),但还是有一定意义的,后续会继续迭代优化。所以在迁移时确保打开 Diff 后没有任何会让人眼花的图案出现: image

查看生成的 CSS

打开页面右下角的 Dynamic Theme, 上方会有一个 眼睛 按钮,点击后左侧会有弹窗显示当前页面 CSS-in-JS 的结果: image

VSCode 插件

  • Css to CssInJs: 可用于快速将 CSS 转换为 CSS-in-JS,带有 less 变量的场合下可能不太好用。

  • Token 数值显示: 社区同学 @shezhangzhang 贡献的插件,可以在研发时自动展示 token 对应数值。

FAQ

我应该用 token 里的哪个属性?

一般来说我们可以通过 v4 使用的 less 变量推断出 v5 中应该使用哪一个 token,比如 v4 中的 @primary-color 在 v5 中就是 colorPriamry。但还是会有一些 v4 中有但 v5 中没有的变量,比如很多组件级别的变量在 v5 都废弃了,这时可以直接使用原始 token 中提供变量,比如 v4 中有 @input-height: @control-height,那么在 v5 中就可以直接使用 controlHeight。如果有一些 hard code 的 style,比如 padding: 12px;,就可以直接使用已有的 token 替换;如果没有合适的 token 可以使用,那就打上注释 // FIXME: hard code in v4,后续再做处理。

为什么类似 &-xxx 或者 &&-xxx 的选择器没有生效?

其实这时可以打开 CSS 面板查看到底生成了什么 CSS 在 hash 模式下,我们要求组件的最外层元素带上 hashId 作为 className,同时在 genComponentStyleHook 中第二个参数返回的 CSSInterpolation 中最外层的选择器都会加上 hashId,举个例子:

export default genComponentStyleHook(
  'Button',
  token => [
    {
      [token.componentCls]: {
        fontSize: 14,
        '&&-lg': {
          fontSize: 16,
        },
        '&-inner': {
          padding: token.paddingXS,
        }
      }
    }
  ],
);

最终产生的 CSS 是:

.css-dev-only-do-not-override-1qwsfmr.ant-btn {
  font-size: 14px;
}
.css-dev-only-do-not-override-1qwsfmr.ant-btn.css-dev-only-do-not-override-1qwsfmr.ant-btn-lg {
  font-size: 16px;
}
.css-dev-only-do-not-override-1qwsfmr.ant-btn-inner {
  padding: 8px;
}

可以发现 & 把 hashId 也一起代入进来了,这里 &-inner 就失去了原本的效果,所以根据不同的场景我们需要对 CSS-in-JS 的结构作一定调整。这里就要注意一下哪些 className 是和 hashId 和 componentCls 在同一个元素上的,如果在同一个元素上, &就可以继续沿用;如果不在同一个元素上,一般就是子元素上,就需要将&改为componentCls(或者其他选择器),增加一层嵌套以关联 hashId:

export default genComponentStyleHook(
  'Button',
  token => {
    const { componentCls } = token;
    
    return [
      {
        [componentCls]: {
          fontSize: 14,
          
          // 同级的 className 可以继续使用 &
          '&&-lg': {
            fontSize: 16,
          },
          
          // 子元素上的 className 需要嵌套处理,替换原有的 &
          // &-inner {
          //   padding: @padding-xs;
          // }
          [`${componentCls}-inner`]: {
            padding: token.paddingXS,
          }
        }
      }
    ];
  },
);

编译结果,符合我们的预期:

.css-dev-only-do-not-override-1qwsfmr.ant-btn {
  font-size: 14px;
}
/* 同级 */
.css-dev-only-do-not-override-1qwsfmr.ant-btn.css-dev-only-do-not-override-1qwsfmr.ant-btn-lg {
  font-size: 16px;
}
/* 子元素 */
.css-dev-only-do-not-override-1qwsfmr.ant-btn .ant-btn-inner {
  padding: 8px;
}