antd-style:业务应用中的 antd CSSinJS 最佳实践

antd 在 v5 中通过 CSSinJS 的技术带来了无与伦比的主题自定义能力,这也是我们所认为的未来方向。但截至目前来看,无论是内部落地应用,还是社区的相关反馈,关于 antd v5 token 系统如何使用、less 怎么迁移、应用如何集成 cssinjs 等问题,都让应用开发者较难开始使用 CSSinJS 的技术来书写样式。

上一篇《 聊聊 Ant Design V5 的主题(上):CSSinJS 动态主题的花活 》 中,我从点的角度和大家介绍了 v5 中的主题使用方案,这一篇将会正式和大家介绍在业务应用中,结合 antd 的 CSSinJS 的最佳用法。

从组件库到业务应用

antd 作为一个组件库,它的职责和边界只在于提供高品质的基础组件。因此应用层如何使用样式方案, antd 并不限制,开发者可以使用 less/sass、styled-components、tailwindcss 等方案,都是没有任何问题的。但为了将 v5 的 token 系统的推行变得更加顺利,同时考虑到相关的应用生态,我们需要提供一个使用 antd token 系统的最佳实践,帮助应用开发者更低门槛在应用中集成 CSSinJS 技术方案,享受新技术所带来的 UX 和 DX 升级。

从开发者需求的角度来梳理,现代化的 CSSinJS 样式开发会遇到那些问题?根据我们自己的业务实践和实际的反馈,大致看到过这些:

  • 如何自定义全局主题?

  • 如何使用自定义 antd 的组件?

  • 如何在业务应用中自定义 token?

  • 如何实现亮暗色主题切换?

  • 如何使用 antd 的 token ?

  • 如何基于 antd 的 token 做上层业务组件库?

  • 如何从 less 迁移?

  • 如何兼容微应用?

  • 如何在 SSR 中使用?

  • 使用 CSSinJS 如何保障性能?

  • ......

从以上问题来看,CSSinJS 的应用主要是在主题应用、主题能力实现、兼容性、性能等几个方面。我们完整考虑了上述几个方面,并推出了 antd-style 这个方案。

设计理念与实施策略

方案定位的抉择

在 antd-style 构建之初,我们有两个基本假设:

  • 在 CSSinJS 世界中,核心 API 会收敛为 styled 和 css,社区生态(lint、format、语法高亮等)也将只围绕这两个 API 做文章,且当 cssinjs 库到成熟阶段后所提供的 API 能力会基本对等;

  • CSSinJS 库本身不会限制开发者使用这个库的姿势,因此库会提供尽量多的能力,但是开发者往往仍然需要一个使用 CSSinJS 的最佳实践;

基于上述两大基本假设,我们可以给出两种完全不同的分类标准:

  1. 样式引擎: 在 CSSinJS 世界中提供样式写法的底层样式库,例如 styled-componentsemotiongooberlinaria 等都属于此类;

  2. 样式实践: 为业务应用中提供最佳实践的方案,它可能是个库,也可能只是一种思想。例如 CSS Modules、BEM 、Tailwind CSS, antd-style 则属于此类。

如果我们拿传统 CSS 世界来类比,那么样式引擎等同于 LESS、SASS、PostCSS 这样的 CSS 预处理语言/库。而样式实践则是 CSS Modules、BEM 这样的东西。不同的样式实践通过各自不同的思想,为开发者提供一种各自统一的样式写法,帮助开发者解决书写样式中的各种问题。

样式引擎的目标在于为开发者提供各种强大的能力,为样式书写提供新的可能性,它的趋向性是扩张的。 而样式实践的目标是为开发者提供统一简洁的样式解决方案,它的趋向性是收敛的。

因此,将「样式引擎」与「样式实践」切割干净后,antd-style的定位就非常清晰了: antd-style是一个 「样式实践」 库,为 antd 的开发者用户提供一套具有确定性的样式书写方案,并在绝大部分样式书写场景都提供了最佳实践的方案:包括但不限:1)应用样式书写 、2)组件库样式书写 、 3)less 迁移、4)响应式、4)动态主题、5)自定义主题、6)token 扩展、7)设计协同等。

所以,antd-style 会在社区优秀的 cssinjs 样式引擎上层封装出一套 api,为应用开发者、组件开发者提供更加易写的语法,且能获得更优的性能。

因此 antd-style 理论上可以实现与某个具体的样式引擎脱钩。譬如: 静态化编译方案 或 Atomtic CSSinJS 成熟,用户只需将样式引擎替换为新的库,在获得原子化样式的能力的同时,仍然可以消费 antd-style 的标准 API。

基础语法的抉择

由于 CSS in JS 的写法过多,所以我们需要给出一种最佳实践的写法,能兼容 V5 的 Token System、自定义主题、较低的研发心智和良好的扩展性。我们分析和对比了不同写法之间的差异。并最终确定了 antd-style 的写法。

我们先来回顾一下在之前(antd v4)的写法,也就是 Less + CSS Modules,这是我们与 CSS in JS 写法的对比基础。

大部分应用一旦使用 antd,一般都会搭配 less 和 css modules 来书写样式。页面书写的样式方案如下:

less 复制代码
@import '~antd/es/style/themes/default';

.list {
  border: 1px solid @primary-color;
  border-radius: 2px;
  box-shadow: 0 8px 20px @shadow-color;
}

import styles from './style.less';

const App = ({ list }) => {
  return <List dataSource={list} className={styles.list} />;
};

1. styled 写法

javascript 复制代码
import styled from 'styled-component';
import { List } from 'xxx';

// 创建样式组件
const StyledList = styled(List)`
  border: 1px solid ${(p) => p.theme.colorPrimary};
  border-radius: 2px;
  box-shadow: 0 8px 20px ${(p) => p.theme.colorShadow};
`;

const App: FC = ({ list }) => {
  return (
    // 引用组件
    <StyledList dataSource={list} />
  );
};

styled 的写法从体感上看完全是另一套代码。 由于我们在业务中已经持续使用了两年,所以相对来说经验也算丰富。

先来说说 styled 的优势。由于 styled 的写法可以保证每一个样式都能形成标准的 React 组件,且样式与样式之间的组合比较方便。因此,它非常适合制作一个从 0 开始建设的业务风格化组件库,或者制作一些具有统一风格的样式组件。

通过 styled 来声明一系列标准的样式组件,可以极大程度地减少重复的样式代码,并且帮助开发者形成明确的样式语义认知。

再来说说使用 styled 的一些痛点。

首先,开发者需要重新学习 styled 的基础语法和相关的使用方式。并且如果使用 antd v4 less 的项目,要迁移到这种写法,基本上和重写没有区别。迁移的 codemod 基本上也实现不出来。

在实际项目研发上,我们经历最痛的地方主要在于组件样式的覆写。 譬如需要对 antd 的 Button 进行样式覆盖,需要这么写:

javascript 复制代码
import { Button } from 'antd';
import { styled } from 'styled-component';

// 引入 antd 的 Button 后做重命名
const ButtonBox = styled(Button)`
  display: flex;
  align-items: center;
  height: 80px;
`;

export default () => {
  // 再使用
  return <ButtonBox>Button</ButtonBox>;
};

这种覆盖方式对于设计完备的组件库来说,会极大地破坏代码的语义化。一旦以这种方式进行铺开,那么以后大家再也分不清代码中到底哪个 Button 组件才是 antd 的 Button。接手不同项目的研发心智成本会大大提升。

此外, Modal、Drawer 这样的组件存在多个 className 的情况,styled 由于写法的局限性是无法覆盖到的。因为 styled 包裹的组件默认只会把样式插入到 className 上,而像 Drawer 这种存在 rootClassName 的组件,要给 rootClassName 挂载样式会很困难。

antd 在今年一个重头戏就是推出 classNames(RFC),以更好地满足自定义样式的诉求。而 styled 的这种写法则天生不匹配。

最后,我们在实践中还会偶尔出现用 styled 包裹的 antd 组件,类型定义无法正常被提示出来,这也降低了研发效率。

因此在我们看来,styled 是不适用 antd 的,尤其是 antd v5 本身已经具有很强的动态主题能力。

2. css props 写法

javascript 复制代码
import { css } from '@emotion/react';

const App: FC = ({ list }) => {
  return (
    <List dataSource={list} css={css`
        color: #1677ff;
`} />
  );
};

这种写法是 emotion 主推的方案,而且 emotion 的核心维护者在自己的业务中也大量使用这种写法(详见:Why We're Breaking Up with CSS-in-JS)。

但这种写法存在两个很大的问题:1)代码可读性 ,2)性能缺陷。

css props 的写法会让样式代码直接与逻辑代码直接耦合在了一起,这样会导致样式代码的可维护性降低,而且在代码中难以区分出哪些是样式代码,哪些是逻辑代码(如下所示)。

css 复制代码
const Command = () => {
  const { styles, cx } = useStyles();
  const [hover, setHover] = useState('');

  return (
    <div
      css={{
        background: '#0e0f11',
        height: '100%',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        padding: 40,
        color: 'white',
      }}
    >
      <div
        css={css`
          padding: 1px;
          width: 600px;
          border-radius: 1rem;
          background-color: #262626;
          background-image: linear-gradient(135deg, #ff4593, #ffe713 32%, #17d7ff 66%, #077bff);
          position: relative;
          box-shadow: rgba(255, 69, 146, 0.2) -40px -40px 200px, rgba(255, 231, 18, 0.2) -4px 0px
              200px, rgba(23, 216, 255, 0.2) 0px 20px 200px, rgba(8, 123, 255, 0.2) 0px 20px 200px;
        `}
      >
        <div
          css={css`
            display: flex;
            padding: 1rem;
            align-items: center;
            border-bottom: 1px solid #444;
            border-top-left-radius: 1rem;
            border-top-right-radius: 1rem;
            background-color: #262626;
          `}
        >
          ...
          <div
            css={css`
              padding-left: 8px;
              flex: 1;
              color: hsla(0, 0%, 100%, 0.4);
            `}
          >
            Trigger Macro by Name
          </div>
          <SearchOutlined />
        </div>
        <div
          css={{
            padding: '8px 0',
            borderBottomLeftRadius: 8,
            borderBottomRightRadius: 8,
            backgroundColor: '#262626',
          }}
        >
          {items.map(({ label, shortcut }) => {
            return (
              <div
                key={label}
                onMouseEnter={() => {
                  setHover(label);
                }}
              >
                <div>{label}</div>
                <div>{shortcut}</div>
              </div>
            );
          })}
        </div>

        <div css={{ backgroundColor: '#363636' }} />
      </div>
    </div>
  );
};

这对于代码的可维护性来说是个灾难。 但如果是样式和逻辑代码分离,则清晰易懂(如下所示):

javascript 复制代码
import styles from './style.less';

const Command = () => {
  return (
    <div className={styles.layout}>
      <div className={styles.container}>
        <div className={styles.searchBox}>
          <div className={styles.placeholder}>Trigger Macro by Name</div>

          <SearchOutlined />
        </div>
        <div className={styles.menuContainer}>
          {items.map(({ label, shortcut }) => {
            return (
              <div
                key={label}
                onMouseEnter={() => {
                  setHover(label);
                }}
              >
                <div>{label}</div>
                <div>{shortcut}</div>
              </div>
            );
          })}
        </div>

        <div className="gradient-bg"></div>
        <div className={styles.mask} />
      </div>
    </div>
  );
};

emotion 的前维护者在自己的业务中大量使用这种写法,但是他也在自己的博客 中提到了这种写法的问题:性能缺陷。

由于 react 的渲染机制, 将样式对象直接传入 css 属性时,由于每次渲染都会将 object 作为一个新对象处理,因此会造成 react 的 re-render,这样就会造成不必要的性能开销。而作者推荐的用法是,将 css props 中的对象放到组件外部静态化。但同时这样也就失去了 css-in-js 的动态化能力。

php 复制代码
const myCss = css({
  backgroundColor: 'blue',
  width: 100,
  height: 100,
});

function MyComponent() {
  return <div css={myCss} />;
}

在迁移成本上,这种写法比 styled 稍微好一些,至少不需要额外定义组件,但是还是需要动组件中的 DOM 代码,并且需要引入 @emotion/react 的 jsx 对象,迁移成本还是高了一些。

3. css 配合 className 写法

javascript 复制代码
import { css } from '@emotion/css';

// 创建样式名
const className = css`
  color: #1677ff;
`;
// -> css-abcdef

const App: FC = ({ list }) => {
  return (
    // 引用 classname
    <List dataSource={list} className={className} />
  );
};

css 配合 className 写法,是体感上非常简单的写法,同时也是这几种方案里面最容易迁移的写法。但这个方案,还是存在一些问题:

纯 css 创建静态样式的方案,无法搭配使用 antd 的 token,也就不能享受到 css-in-js 的动态化能力。

而使用动态化能力,必须要使用 hooks 的方式,如下所示。

javascript 复制代码
import { css } from '@emotion/css';
import { theme } from 'antd';

const useStyles = () => {
  const { token } = theme.useToken();
  return {
    container: css`
      background: ${token.colorBgLayout};
    `,
    list: css`
      border: 1px solid ${token.colorPrimary};
      border-radius: 2px;
      box-shadow: 0 8px 20px ${token.colorShadow};
    `,
  };
};

const App: FC = ({ list }) => {
  const styles = useStyles();
  return (
    <div className={styles.container}>
      <List dataSource={list} className={styles.list} />
    </div>
  );
};

这样的写法,一方面每个函数都需要手动使用 theme.useToken 获取 token,量级一大非常麻烦,另一方面 hooks 返回的对象每次都会重新创建,因此一定会造成不必要的 re-render,如果每个 return 都包一下 useMemo,那么代码量还会更大。最后,再如果叠加自定义主题、动态主题等,这种写法就会变得非常复杂和啰嗦,而且也不易维护。

小结

结合上述写到的几种方式最终整理,而 antd-style 希望再各项指标上做到最优,成为基于 antd 进行应用样式研发的最佳实践。

学习成本 动态化能力 自定义主题难度 样式开发心智 组件覆写心智 性能 迁移成本
CSS Modules ✅ 无 ❌ 无 ❌ 难 ✅ 低 ✅ 低 ✅ 最优 ✅ -
styled ⭕️️ 高 ✅ 高 ⚠️ 一般 ✅ 低 ⭕️️ 高 ✅ 优 ⭕️️ 高
css props ⚠️ 中 ✅ 高 ⚠️ 一般 ⚠️ 高 ✅ 低 ⭕️️ 差 ⚠️ 中
css + className ✅ 低 ✅ 高 ⚠️ 一般 ⚠️ 高 ✅ 低 ⭕️️ 中到差 ✅ 低
antd-style ✅ 低 ✅ 高 ✅ 低 ✅ 低 ✅ 低 ✅ 优 ✅ 低

antd-style 快闪演示

由于我们已经提供了一份相对完整的使用文档,因此这一趴将快速带大家看看目前已经完备的功能。

1. 创建样式

一个包含基础用法的 demo 示例,看懂了这个 demo 就会使用 antd-style 的基础功能了。

javascript 复制代码
import { createStyles } from 'antd-style';

const useStyles = createStyles(({ token, css }) => ({
  // 支持 css object 的写法
  container: {
    backgroundColor: token.colorBgLayout,
    borderRadius: token.borderRadiusLG,
    maxWidth: 400,
    width: '100%',
    height: 180,
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    flexDirection: 'column',
    marginLeft: 'auto',
    marginRight: 'auto',
  },
  // 也支持通过 css 字符串模板获得和 普通 css 一致的书写体验
  card: css`
    color: ${token.colorTextTertiary};
    box-shadow: ${token.boxShadow};
    &:hover {
      color: ${token.colorTextSecondary};
      box-shadow: ${token.boxShadowSecondary};
    }

    padding: ${token.padding}px;
    border-radius: ${token.borderRadius}px;
    background: ${token.colorBgContainer};
    transition: all 100ms ${token.motionEaseInBack};

    margin-bottom: 8px;
    cursor: pointer;
  `,
}));

export default () => {
  // styles 对象在 useStyles 方法中默认会被缓存,所以不用担心 re-render 问题
  const { styles, cx, theme } = useStyles();

  return (
    // 使用 cx 可以组织 className
    <div className={cx('a-simple-create-style-demo-classname', styles.container)}>
      <div className={styles.card}>createStyles Demo</div>
      {/* theme 对象包含了所有的 token 与主题等信息 */}
      <div>当前主题模式:{theme.appearance}</div>
    </div>
  );
};

2. 主题切换

通过引入容器组件 ThemeProvider ,修改 apperance props,即可实现主题切换,这是也是动态主题最简单的使用方式。

javascript 复制代码
import { ThemeProvider } from 'antd-style';

export default () => {
  return (
    // 自动变为暗色模式
    <ThemeProvider apperance={'dark'}>
      <App />
    </ThemeProvider>
  );
};

3. 自定义主题

antd v5 的 ConfigProvider 提供了 theme 配置,可以传入自定义的 theme 对象来实现自定义主题。

ini 复制代码
/**
 * 自定义主题算法
 */
const customDarkAlgorithm = (seedToken, mapToken) => {
  const mergeToken = theme.darkAlgorithm(seedToken, mapToken);

  return {
    ...mergeToken,
    colorBgLayout: '#20252b',
    colorBgContainer: '#282c34',
    colorBgElevated: '#32363e',
  };
};

export default () => (
  <ThemeProvider
    themeMode={'dark'}
    // 支持传入方法,来动态响应外观
    theme={(appearance) =>
      appearance === 'dark'
      ? // 暗色自定义
      {
        token: { borderRadius: 2 },
        algorithm: [customDarkAlgorithm],
      }
      : // 亮色采用默认值
      undefined
    }
    >
    <App />
  </ThemeProvider>
);

4. Less 迁移 codemod

为方便业务应用的统一升级,我们提供了 less to antd-style 的一键迁移 codemod。在项目根目录,执行以下指令即可:

ruby 复制代码
$ npx @chenshuai2144/less2cssinjs less2js -i src

然后你会看到输出,正常情况下,你的应用就完成了 less 到 cssinjs 的迁移。如果迁移过程中有报错,欢迎来提 issue

5. 响应式语法糖

提供了一个快捷创建响应式媒体查询的方法,可以快捷实现响应式的媒体查询断点:

javascript 复制代码
import { createStyles, useResponsive } from 'antd-style';

const useStyles = createStyles(({ css, responsive }) => ({
  container: css`
    background-color: lightskyblue;
    color: darkblue;

    ${responsive.tablet} {
      background: darkseagreen;
      color: darkgreen;
    }

    ${responsive.desktop} {
      background: darksalmon;
      color: saddlebrown;
    }

    ${responsive.mobile} {
      background: pink;
      color: deeppink;
    }
  `,
}));

const App = () => {
  const { styles } = useStyles();

  const { laptop, desktop, mobile } = useResponsive();
  return (
    <div className={styles.container}>
      {mobile ? 'mobile' : desktop ? 'desktop' : laptop ? 'laptop' : 'tablet'}
    </div>
  );
};

export default App;

6. 研发体验升级

得益于 TS 良好的类型编程能力,在拥有动态化主题能力的同时,我们还获得了类名自动提示与下钻跳转的能力,进一步提升研发体验。

上手文档

我们从用户视角构建了 antd-style 的上手指南,帮助大家降低 antd-style 的上手门槛,并给出书写 CSSinJS 的样式最佳实践。(当然,其中还有几篇仍然处于施工状态,使用 🚧 进行了标记,也将会于近期补全)

如果心动的话,立即安装 antd-style 开始愉快编码吧~

css 复制代码
$ npm i antd-style -S

文档链接:ant-design.github.io/antd-style/

GitHub :github.com/ant-design/...

One More Thing

在开发 Ant Design Style 过程中,为了测试与验证的自定义主题、动态主题算法等能力,在 antd 基础组件基础上,特意尝试了诸多风格化样式,例如渐变、毛玻璃模糊等效果。由于不少同学对此表达了喜爱,因此单独抽取了一个 dumi2 主题包,属于是 antd-style 研发的一个副产物了。

如果希望换一个文档站点主题风格的朋友,不妨可以尝试下,承诺长期维护~ dumi-theme-antd-style.arvinx.app/

相关推荐
袋鼠云数栈UED团队2 个月前
浅谈数栈产品里的 Descriptions 组件
前端·react.js·ant design
自白2 个月前
关于我是如何二次开发了 antd-vue 的a-range-picker组件,同时还添加了 vscod智能提示这件事
vue.js·visual studio code·ant design
袋鼠云数栈UED团队4 个月前
在 React 项目中 Editable Table 的实现
前端·react.js·ant design
孟宪磊mxl4 个月前
Element Plus& Ant Design(react) 表格的分页封装
vue.js·react.js·ant design·editplus
程序员也要学好英语4 个月前
搭建 react + antd 技术栈的测试框架
react.js·jest·ant design
Point4 个月前
[源码分析] Antd-RC-Notification
前端·源码阅读·ant design
Wxh161445 个月前
Ant Design 自定义组件空状态速记
前端·javascript·ant design
不喝酒不会写代码6 个月前
react使用antd-useWatch的坑(react太难了)
react.js·ant design
花笙_6 个月前
antd form表单赋值Switch不生效
前端·ant design