拥抱 Atomic CSS-in-JS

拥抱 Atomic CSS-in-JS

技术向约 4.9 千字

当下,Atomic CSS 愈发受到人们的关注。相比于传统 CSS 编写方法中每个组件对应一个 CSS 类,使用了 Atomic CSS 以后,每一个 CSS 类都唯一对应了一条独立的 CSS 规则,随着组件数量逐渐增加、能复用的 CSS 规则越来越多,最终的 CSS 产物体积也会下降许多,使得网页的加载速度能够产生一个质的飞跃。

CSS 编写方法的发展历程

在介绍 Atomic CSS 之前,让我们先来回顾一下 CSS 编写方法的发展历程。

SMACSS

SMACSS(Scalable & Modular Architecture for CSS),是由 Jonathan Snook 提出的 CSS 理论。其主要原则有 3 条:

  • Categorizing CSS Rules(为 CSS 分类)
  • Naming Rules(命名规则)
  • Minimizing the Depth of Applicability(最小化适配深度)

规则分类

SMACSS 将规则分为了五类:Base(基础)、Layout(布局)、Module(模块)、State(状态)、Theme(主题)。

基础(Base) 规则里放置默认样式。这些默认样式基本上都是元素选择器,不过也可以包含属性选择器,伪类选择器,孩子选择器,兄弟选择器。本质上来说,一个基础样式定义了元素在页面的任何位置应该是怎么样的。

布局(Layout) 规则将页面拆分成几个部分,每个部分都可能有一到多个模块。顾名思义,这个分类主要用来做页面的整体或其中一块区域的布局。

模块(Modules) 是我们的设计当中可重用,可模块化的部分。插图,侧边栏,文章列表等等都属于模块。

状态(State) 规则定义了我们的模块或者布局在特殊的状态下应该呈现怎样的效果。它可能定义模块、布局在不同显示屏上应该如何显示。也可能定义一个模块在不同页面(例如主页和内页)中可能呈现怎么样的效果。

主题(Theme) 规则和状态规则类似,定义模块或者布局的外观。很多网站的「深色模式」「换肤」等等功能就是这样实现的。

命名规则

将规则分成五类之后,还需要命名规范。命名规范能够使得我们立刻了解到某个样式属于哪一类,以及它在整个页面中起到的作用。在一个大型项目中,我们可能会将一个样式分割成几个文件,这个时候命名约定能够使得我们更容易知道这个样式属于哪个文件。

推荐使用前缀来区分布局、模块和状态等等规则。比如对布局规则使用 layout- 前缀,对状态规则使用 is- 前缀就是一个不错的选择。

最小化适配深度

尽量不要依赖文档树的结构来编写样式。这样可以让我们的样式更加灵活,并且容易维护。

BEM

BEM( Block Element Modifier)是由 Yandex 团队提出的一种前端 CSS 命名方法论。它是一个简单又非常有用的命名约定。让前端代码更容易阅读和理解,更容易协作,更容易控制,更加健壮和明确,而且更加严密。

BEM 命名约定的模式是:

.block {
}

.block__element {
}

.block--modifier {
}
  • block 代表了「块」,用于组件本体。
  • element 代表了「块」中的某个「元素」(也可以叫做「子组件」),是块构成的主要成员。
  • modifier 代表了「块」的修饰符,表示不同的状态和版本。使用 -- 做区分,适用于「块」和「元素」,分别称之为「块修饰符」和「元素修饰符」。

命名的不同部分之间之所以使用 __-- 分割,是因为如果某部分中如果出现了多个单词需要使用 - 分隔,这样可以避免造成混淆。

CSS Modules

随着时代的发展,一个大型前端工程中的 CSS 类名越来越多,此时难免会出现类名冲突的情况,此时 CSS Modules 应运而生 —— 它通过为 CSS 类名添加 Hash 等方式来产生唯一的名称来防止冲突的产生。

CSS Modules 并不是 CSS 官方的标准,也不是浏览器的特性,而是使用一些构建工具,比如 Webpack,对 CSS 类名和选择器限定作用域的一种方式。

Utility-First CSS

当传统大型项目使用的 CSS 方法论还都大多是上方提到的 OOCSS、SMACSS、BEM 等等主要聚焦在「关注点分离」的「语义化 CSS」方案的时候,Utility-First 的 CSS 概念脱颖而出、逐渐受到社区的关注。而这之中最为被人熟知的、也最典型的就是 Tailwind CSS 了。

Utility-First CSS 不像 Semantic CSS 那样将组件样式放在一个类中,而是为我们提供一个由不同功能类组成的工具箱,我们可以将它们混合在一起应用在页面元素上。这样有几个好处:

  • 不用纠结于类名的命名;
  • 功能越简单的类,复用率越高,可以减小最终的打包大小;
  • 不存在全局样式污染问题;
  • 等等。

但也存在一些不足:

  • class 属性的内容过长;
  • 存在 CSS 规则插入顺序相关的问题;
  • 不能通过语义化类名得知组件的作用;
  • 不压缩的话构建产物体积过大。

新时代,来临了 —— Atomic CSS-in-JS

在前文介绍的 Utility-First CSS 的基础之上更进一步,Atomic CSS 便映入了人们的眼帘。

Atomic CSS 背后的思想与以往的「关注点分离」的思想可以称得上是背道而驰了。使用 Atomic CSS 时实际上将结构层和样式层耦合在了一起,这样的方式在现代 CSS-in-JS 的代码库中基本上得到了广泛认可,下文将会进行进一步的介绍。

Atomic CSS 可以看作是 Utility-First CSS 的极致抽象版本,每一个 CSS 类都对应一条单一的 CSS 规则。可面对如此繁复的 CSS 规则,手写 Atomic CSS 的类名并不是一个好的办法。于是 Atomic CSS-in-JS 应运而生,它可以看作是「自动化的 Atomic CSS」:

  • 无需手动设计 CSS 类名;
  • 能够提取页面的关键 CSS,并进行代码拆分;
  • 可以解决经典的 CSS 规则插入顺序的问题。

传统 CSS 编写方式的缺点

Christopher Chedeau 一直致力于推广 React 生态系统中 CSS-in-JS 理念。在很多次演讲中,他都解释了 CSS 的几大问题:

  1. 全局命名空间
  2. 依赖
  3. 无用代码消除
  4. 代码压缩
  5. 共享常量
  6. 非确定性(Non-Deterministic)解析
  7. 隔离

虽然 Utility-First CSS 和 Atomic CSS 也解决了其中的一些问题,但它们无法解决所有问题(特别是样式的非确定性解析)。

举个例子:Tailwind CSS 会在生成时生成出来许多无用代码,导致样式文件体积的增长,看看下面这份代码:

<div class="before:bg-white before:p-4">content</div>

生成出来的样式文件长这个样子:

.before\:bg-white::before {
  content: var(--tw-content);
  --tw-bg-opacity: 1;
  background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}

.before\:p-4::before {
  content: var(--tw-content);
  padding: 1rem;
}

可以看到这份文件中包括了许多的无用代码,比如重复的 content: var(--tw-content)

更小的构建产物

传统的 CSS 编写方法无法复用组件间重复的 CSS 规则,比如下图中高亮的几条规则各自躺在它们对应的 CSS 类中:

这样会导致 CSS 产物大小与项目的复杂程度和组件数量线性正相关。

但使用 Atomic CSS 之后,这些规则被提取出来进行复用:

随着后期组件数量逐渐增加、能复用的 CSS 规则越来越多、最终 CSS 产物大小与项目复杂程度呈对数关系:

Facebook 分享了他们的数据:在旧网站上,仅登录页就需要加载 413 KiB 的样式文件,而在使用 Atomic CSS-in-JS 重写后,整个站点只有 74 KiB 的样式文件,还包括了深色模式。

虽然在使用 Atomic CSS 之后,HTML 的体积会显著增大,不过由于这些类名的高冗余度,可以利用 gzip 来压缩掉很大一部分体积。

处理 CSS 规则的插入顺序

让我们再来回顾一遍这个经典的 CSS 规则插入顺序的问题:

我们都知道,最后生效的样式不是最后一个类名对应的规则,而是样式表中最后插入的规则。

那么,如何在 CSS-in-JS 中处理这个问题呢?通用的做法是在生成阶段就将冲突的规则过滤掉,以避免产生冲突。比如下面这个组件:

const styles = style9.create({
  card: {
    color: '#000000',
  },
  profileCard: {
    color: '#ffffff',
  },
});

const Component = () => (
  <div className={style9(styles.card, styles.profileCard)} />
);

过滤后组件的实际样式如下:

color: #ffffff;

而如果将组件样式中的 styles.cardstyles.profileCard 调换一下顺序,过滤之后的样式就变成了这样:

color: #000000;

但 CSS 中有一些简写规则,如果只按照规则名称来处理显然是不行的。有的库强制开发者们不使用简写规则来避免这个问题,而另外的一些库则将这些简写规则展开成多条规则后再进行过滤,比如 margin: 10px 可以被拆成 margin-top: 10pxmargin-right: 10pxmargin-bottom: 10pxmargin-left: 10px 四条独立的规则。

经典实现

Atomic CSS-in-JS 实现有运行时(Runtime)和预编译(Pre-Compile)两种。运行时(Runtime)的优点在于可以动态生成样式,相比于下文中采用预编译方法的库来说灵活度高了不止一点半点。其缺点则在于 Vendor Prefix 等操作需要在 Runtime 执行,因此 Bundle 中必须携带相关依赖导致体积增大。预编译(Pre-Compile)的优点则在于无需将相关依赖打包发送给客户端,改善了性能。而缺点则是预编译的过程高度依赖静态代码分析,所以难以实现动态样式生成与组合。

Styletron

Styletron 是 Uber 公司开发的一个较为典型的运行时 Atomic CSS-in-JS 库,驱动了 Uber 的官网和 H5 页面。

Styletron 还提供了一套 Styled Components 的实现,可以通过下面的方式使用:

import { styled } from 'styletron-react';

const Component = styled('div', {
  marginTop: '10px',
  marginBottom: '10px',
});

<Component />;

还可以根据 prop 的值来动态生成样式:

const Component = styled('div', (props) => {
  return { color: props.$fraction < 0.5 ? 'red' : 'green' };
});

<Component $fraction={Math.random()} />;

Fela

与 Styletron 同为运行时 Atomic CSS-in-JS 库的还有沃尔沃汽车前技术主管开发的 Fela,驱动了沃尔沃汽车官网,Cloudflare Dashboard 和 Medium 等众多网站。

vanilla-extract

Stylex 是 Meta(原 Facebook)的一个尚未开源的预编译 Atomic CSS-in-JS 库。不过由于 Meta 迟迟不开源 stylex,社区中已经涌现出了数个基于其思想的开源实现,其中以 vanilla-extract 最为知名。

style9

基于 stylex 思想的预编译 Atomic CSS-in-JS 库除了 vanilla-extract 之外还有 style9styleQ

compiled

将视线从 stylex 系列中转移开来,Atlassian 还编写了一个名为 compiled 的预编译 Atomic CSS-in-JS 库,但在笔者的实际使用过程中坑点较多,可能会导致样式的重复生成,并且其对 TypeScript 的支持也不尽人意,不过其代码实现中的许多技巧还是有借鉴价值的。

Styled Components

compiled 依靠一个 babel transformer 来对代码进行转换以插入样式。

packages/react/src/styled/index.tsx 文件中可以看到,@compiled/react 包含了一个导出了一个名为 styled 的对象,这个对象一旦被访问就会立刻抛出错误,提示 transformer 没有正常工作:

export const styled: StyledComponentInstantiator = new Proxy(
  {},
  {
    get() {
      return () => {
        // Blow up if the transformer isn't turned on.
        // This code won't ever be executed when setup correctly.
        throw createSetupError();
      };
    },
  }
) as any;

那么可以看出,styled 会被 transformer 替换掉,对应的入口逻辑在 packages/babel-plugin/src/babel-plugin.tsx 文件中:

ImportDeclaration(path, state) {
  // 不是从 @compiled/react 导入的包不处理
  if (path.node.source.value !== '@compiled/react') {
    return;
  }

  // 记录导入的模块
  state.compiledImports = {};

  // 遍历导入数组中的所有元素
  path.get('specifiers').forEach((specifier) => {
    if (!state.compiledImports || !specifier.isImportSpecifier()) {
      return;
    }

    (['styled', 'ClassNames', 'css', 'keyframes'] as const).forEach((apiName) => {
      if (
        state.compiledImports &&
        t.isIdentifier(specifier.node?.imported) &&
        specifier.node?.imported.name === apiName
      ) {
        // 记录下导入后 API 的名称
        state.compiledImports[apiName] = specifier.node.local.name;
      }
    });
  });

  // 导入 @compiled/react/runtime 中的 API
  appendRuntimeImports(path);

  path.remove();
},

这段代码记录了 @compiled/react 的引入情况,为下方的处理提供了便利。

TaggedTemplateExpression(path, state) {
  if (t.isIdentifier(path.node.tag) && path.node.tag.name === state.compiledImports?.css) {
    state.pathsToCleanup.push({ path, action: 'replace' });
    return;
  }

  if (
    t.isIdentifier(path.node.tag) &&
    path.node.tag.name === state.compiledImports?.keyframes
  ) {
    state.pathsToCleanup.push({ path, action: 'replace' });
    return;
  }

  if (!state.compiledImports?.styled) {
    return;
  }

  // 处理 styled component
  visitStyledPath(path, { context: 'root', state, parentPath: path });
},
CallExpression(path, state) {
  if (!state.compiledImports) {
    return;
  }

  if (
    t.isIdentifier(path.node.callee) &&
    (path.node.callee.name === state.compiledImports?.css ||
      path.node.callee.name === state.compiledImports?.keyframes)
  ) {
    state.pathsToCleanup.push({ path, action: 'replace' });
    return;
  }

  // 处理 styled component
  visitStyledPath(path, { context: 'root', state, parentPath: path });
},

TaggedTemplateExpressionCallExpression 的处理,正好对应了文档中的两种不同调用方式:

// 模板字符串
styled.a`
  color: blue;
`;

// 函数调用
styled.a({
  color: 'blue',
});

跟随着 visitStyledPath 函数的定义,可以找到 packages/babel-plugin/src/styled/index.tsx 文件。

export const visitStyledPath = (
  path: NodePath<t.TaggedTemplateExpression> | NodePath<t.CallExpression>,
  meta: Metadata
): void => {
  // 判断是否是支持的操作
  if (
    t.isTaggedTemplateExpression(path.node) &&
    hasInValidExpression(path.node)
  ) {
    throw buildCodeFrameError(
      `A logical expression contains an invalid CSS declaration. 
      Compiled doesn't support CSS properties that are defined with a conditional rule that doesn't specify a default value.
      Eg. font-weight: \${(props) => (props.isPrimary && props.isMaybe) && 'bold'}; is invalid.
      Use \${(props) => props.isPrimary && props.isMaybe && ({ 'font-weight': 'bold' })}; instead`,
      path.node,
      meta.parentPath
    );
  }

  // 提取样式信息
  const styledData = extractStyledDataFromNode(path.node, meta);
  if (!styledData) {
    // 没有样式信息
    return;
  }

  // 生成 CSS
  const cssOutput = buildCss(styledData.cssNode, meta);

  // 构建并替换节点
  path.replaceWith(buildStyledComponent(styledData.tag, cssOutput, meta));

  const parentVariableDeclaration = path.findParent((x) =>
    x.isVariableDeclaration()
  );
  if (
    parentVariableDeclaration &&
    t.isVariableDeclaration(parentVariableDeclaration.node)
  ) {
    const variableDeclarator = parentVariableDeclaration.node.declarations[0];
    if (t.isIdentifier(variableDeclarator.id)) {
      const variableName = variableDeclarator.id.name;
      parentVariableDeclaration.insertAfter(buildDisplayName(variableName));
    }
  }
};

再来看提取样式信息的函数 extractStyledDataFromNode,这个函数根据不同情况使用不同的方法提取样式信息:

const extractStyledDataFromNode = (
  node: t.TaggedTemplateExpression | t.CallExpression,
  meta: Metadata
) => {
  // 使用模板字符串
  if (t.isTaggedTemplateExpression(node)) {
    return extractStyledDataFromTemplateLiteral(node, meta);
  }

  // 使用函数调用
  if (t.isCallExpression(node)) {
    return extractStyledDataFromObjectLiteral(node, meta);
  }

  // 提取不到信息
  return undefined;
};

构建新节点的函数被定义在 packages/babel-plugin/src/utils/ast-builders.tsx 文件中:

export const buildStyledComponent = (
  tag: Tag,
  cssOutput: CSSOutput,
  meta: Metadata
): t.Node => {
  const unconditionalCss: string[] = [];
  const logicalCss: CssItem[] = [];

  cssOutput.css.forEach((item) => {
    if (item.type === 'logical') {
      logicalCss.push(item);
    } else {
      unconditionalCss.push(getItemCss(item));
    }
  });

  // 去重,只保留最后一个
  const uniqueUnconditionalCssOutput = transformCss(unconditionalCss.join(''));

  const logicalCssOutput = transformItemCss({
    css: logicalCss,
    variables: cssOutput.variables,
  });

  const sheets = [
    ...uniqueUnconditionalCssOutput.sheets,
    ...logicalCssOutput.sheets,
  ];

  const classNames = [
    ...[t.stringLiteral(uniqueUnconditionalCssOutput.classNames.join(' '))],
    ...logicalCssOutput.classNames,
  ];

  // 返回构建好的节点
  return styledTemplate(
    {
      classNames,
      tag,
      sheets,
      variables: cssOutput.variables,
    },
    meta
  );
};

至于构建节点的操作,则是较为简单的字符串拼接:

const styledTemplate = (opts: StyledTemplateOpts, meta: Metadata): t.Node => {
  const nonceAttribute = meta.state.opts.nonce
    ? `nonce={${meta.state.opts.nonce}}`
    : '';
  const propsToDestructure: string[] = [];

  // 提取样式
  const styleProp = opts.variables.length
    ? styledStyleProp(opts.variables, (node) => {
        const nestedArrowFunctionExpressionVisitor = {
          noScope: true,
          MemberExpression(path: NodePath<t.MemberExpression>) {
            const propsToDestructureFromMemberExpression =
              handleMemberExpressionInStyledInterpolation(path);

            propsToDestructure.push(...propsToDestructureFromMemberExpression);
          },
          Identifier(path: NodePath<t.Identifier>) {
            const propsToDestructureFromIdentifier =
              handleDestructuringInStyledInterpolation(path);

            propsToDestructure.push(...propsToDestructureFromIdentifier);
          },
        };

        if (t.isArrowFunctionExpression(node)) {
          return traverseStyledArrowFunctionExpression(
            node,
            nestedArrowFunctionExpressionVisitor
          );
        }

        if (t.isBinaryExpression(node)) {
          return traverseStyledBinaryExpression(
            node,
            nestedArrowFunctionExpressionVisitor
          );
        }

        return node;
      })
    : t.identifier('style');

  let unconditionalClassNames = '',
    logicalClassNames = '';

  opts.classNames.forEach((item) => {
    if (t.isStringLiteral(item)) {
      unconditionalClassNames += `${item.value} `;
    } else if (t.isLogicalExpression(item)) {
      logicalClassNames += `${generate(item).code}, `;
    }
  });

  // classNames 为生成好的类名
  const classNames = `"${unconditionalClassNames.trim()}", ${logicalClassNames}`;

  // 此处的 <CC />, <CS /> 是上文中处理 import 时从 @compiled/react/runtime 中导入的组件
  return template(
    `
  forwardRef(({
    as: C = ${buildComponentTag(opts.tag)},
    style,
    ${unique(propsToDestructure)
      .map((prop) => prop + ',')
      .join('')}
    ...${PROPS_IDENTIFIER_NAME}
  }, ref) => (
    <CC>
      <CS ${nonceAttribute}>{%%cssNode%%}</CS>
      <C
        {...${PROPS_IDENTIFIER_NAME}}
        style={%%styleProp%%}
        ref={ref}
        className={ax([${classNames} ${PROPS_IDENTIFIER_NAME}.className])}
      />
    </CC>
  ));
`,
    {
      plugins: ['jsx'],
    }
  )({
    styleProp,
    cssNode: t.arrayExpression(
      unique(opts.sheets).map((sheet) => hoistSheet(sheet, meta))
    ),
  }) as t.Node;
};

这样兜兜转转一圈下来,就将使用了 styled 方法生成的组件的样式抽离出来,变成了一个 compiled 的 Atomic CSS-in-JS 组件。

css Prop

compiled 首先 增加了 css prop 的 TypeScript 定义,然后和 styled component 一样在 babel transform 的时候对这个 prop 进行特殊处理:

JSXOpeningElement(path, state) {
  if (!state.compiledImports) {
    return;
  }

  // 处理 css prop
  visitCssPropPath(path, { context: 'root', state, parentPath: path });
},

相比于 styled component 繁复的处理方式,css prop 的处理看起来简洁了许多:

export const visitCssPropPath = (
  path: NodePath<t.JSXOpeningElement>,
  meta: Metadata
): void => {
  let cssPropIndex = -1;
  const cssProp = path.node.attributes.find(
    (attr, index): attr is t.JSXAttribute => {
      if (t.isJSXAttribute(attr) && attr.name.name === 'css') {
        cssPropIndex = index;
        return true;
      }

      return false;
    }
  );

  // 不存在 css prop 就不进行处理了
  if (!cssProp || !cssProp.value) {
    return;
  }

  // 从 css props 中提取样式信息
  const cssOutput = buildCss(getJsxAttributeExpression(cssProp), meta);

  // 删除 css prop
  path.node.attributes.splice(cssPropIndex, 1);

  // 没有样式信息
  if (!cssOutput.css.length) {
    return;
  }

  // 构建并替换节点
  path.parentPath.replaceWith(
    buildCompiledComponent(
      path.parentPath.node as t.JSXElement,
      cssOutput,
      meta
    )
  );
};

构建新节点的 buildCompiledComponent 函数被定义在 packages/babel-plugin/src/utils/ast-builders.tsx 文件中,这个函数主要完成了以下操作:

  1. 合并现有的 className
  2. 处理 css prop 中的样式;
  3. 生成 compiled 的 Atomic CSS-in-JS 组件。

这样就将组件的 css 参数拆成了两部分 —— 静态的样式和附加到原组件的 className 参数值。

其他

微软最近开源的 Griffel 既支持运行时模式,又支持预编译模式,同时拥有着更佳的 TypeScript 支持,不失为一个好的选择。这个库目前驱动了微软官方的 Fluent UI

后记

以上就是本文要介绍关于 Atomic CSS 的全部内容了。

虽然 Atomic CSS-in-JS 是 React 生态系统中新涌起的一股潮流,但在使用前一定要三思 —— 这个方案到底符不符合项目的需求,而不是盲目地「为了使用而使用」,给将来的维护工作埋雷,但如果使用它能带来显而易见的好处,那么何乐而不为呢?

笔者才疏学浅,只是在前人的基础之上做了一些微小的工作而已,文章中如有错误欢迎在评论区指正。感谢 Sukka 大佬在本文编写过程中的指导。感谢 Byran Lee 指出本文中的错误。

参考资料

  1. Atomic CSS-in-JS,Sébastien Lorber,2020 年 4 月 27 日。
  2. 聊聊原子类(Atomic CSS),Mongkii,2021 年 7 月 26 日。
  3. 值得参考的 CSS 理论:OOCSS、SMACSS 与 BEM,ACGTOFE,2014 年 9 月 30 日。
  4. Utility-First Fundamentals,Tailwind CSS。
拥抱 Atomic CSS-in-JS
本文作者
发布于
版权协议
转载或引用本文时请遵守许可协议,注明出处、不得用于商业用途!
喜欢这篇文章?为什么不考虑打赏一下作者呢?
爱发电