原子化 JSS 方案 broken-css

avatar
前端 @北京字节跳动科技有限公司

技术背景

对于现代 UI 框架来说,选择使用 JSS 逐渐让人可以接受,而不再被视为“大逆不道”之举。 然而, JSS 作为管理样式的前沿解决方案,有些 JSS 方案可以说被称为前沿的前沿。如:

  • linaria

一个零运行时开源的 JSS 方案,在编译期将你的 JSS 抽离解压出来,不用维持一个运行时来解析 JSS 并使得浏览器可以并行的下载 CSS 和 JSS ,具有更高的性能和体积优势。

  • stylex

Facebook 内部闭源的原子化的 JSS 方案,其最大的特点是在编译期将 JSS 编译为原子 CSS 解压出来,达到最大程度的复用

const styles = stylex.create({
  blue: {color: 'blue'},
  red: {color: 'red'}
});
function MyComponent(props) {
  return (
    <span className={styles('blue', 'red')}>
      I'm blue
    </span>
  )
}

会被编译为

.c0 { color: blue}
.c1 { color: red}


const styles = stylex.create({
  blue: {color: 'blue'},
  red: {color: 'red'}
});
function MyComponent(props) {
  return (
    <span className={"c0 c1"}>
      I'm blue
    </span>
  )
}

受到 linarira 和 stylex 的启发,broken-css 同样是一个零运行时原子化的 JSS 方案,不同点在于 broken-css 选择的 API 的形式,并非 react-native-like ,而是使用了模板字符串函数,这使得 broken-css 可以很容易的支持动画和伪类相关的 CSS 规则,并且使用起来也非常符合传统的使用方式。

介绍

使用

首先你需要安装以下两个库:

  • yarn add @broken-css/core
  • yarn add -D @broken-css/webpack-loader

@broken-css/core

@broken-css/core 做的事情很简单,只是给 @broken-css/webpack-loader 一个信号,告诉这里需要编译,实际上我们可以看下 @broken-css/core 的源码,你会发现其非常的简洁

export const css = (_literal: TemplateStringsArray, ..._DOES_NOT_SUPPORT_EXPRESSIONS: never[]): string => {
  throw new SyntaxError('Do not call css() on runtime!')
};

因为 css ... `` 表达式,在编译后会被替换成一段字符串,所以在运行期间,这个函数其实不会被真正执行到,所以并不会抛出这个错误。

@broken-css/webpack-loader

@broken-css/webpack-loader 完成核心步骤,即将代码 JSS 替换成原子化的 CSS 类名 ,并将编译后的原子化 CSS 导入到相应的 JS 文件中,使得 webpack 接管导入 CSS 的流程,以便使用相关 loader 和 plugin 。

例子

假如我们有两个组件 Foo 和 Bar

// Foo.tsx
import { css } from "@broken-css/core";
import React, { FC } from "react";

const Foo: FC = () => {
  return (
    <div className={css`
      color: red;
      font-size: 24px;
      border: 1px solid black;
      @keyframes shake {
        10%, 90% {
          transform: translate3d(-1px, 0, 0);
        }

        20%, 80% {
          transform: translate3d(2px, 0, 0);
        }

        30%, 50%, 70% {
          transform: translate3d(-4px, 0, 0);
        }

        40%, 60% {
          transform: translate3d(4px, 0, 0);
        }
      }
      &:hover {
        animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
        transform: translate3d(0, 0, 0);
        backface-visibility: hidden;
        perspective: 1000px;
      }
      &::after {
        content: ' after';
        color: brown;
      }
    `}>foo</div>
  );
}

export default Foo;

// Bar.tsx

import { css } from "@broken-css/core";
import React, { FC } from "react";

const Bar: FC = () => {
  return (
    <div className={css`
      color: red;
      font-size: 24px;
      border: 1px black solid;
    `}>bar</div>
  );
}

export default Bar;

在编译后会变成

// Foo.tsx
import { css } from "@broken-css/core";
import React, { FC } from "react";

const Foo: FC = () => {
  return <div className={"_0e91 _b38a _43fe _b04b _4b6c"}>foo</div>;
};

export default Foo;
;require("../node_modules/.cache/broken-css-webpack-loader/broken.css");

// Bar.tsx

import { css } from "@broken-css/core";
import React, { FC } from "react";

const Bar: FC = () => {
  return <div className={"_43fe _b04b _f617"}>bar</div>;
};

export default Bar;
;require("../node_modules/.cache/broken-css-webpack-loader/broken.css");


/** broken.css **/
@keyframes shake {
    10%, 90% {
        transform: translate3d(-1px, 0, 0);
    }
    20%, 80% {
        transform: translate3d(2px, 0, 0);
    }
    30%, 50%, 70% {
        transform: translate3d(-4px, 0, 0);
    }
    40%, 60% {
        transform: translate3d(4px, 0, 0);
    }
}

._0e91:hover {
    animation: shake 0.82s cubic-bezier(.36,.07,.19,.97) both;
    transform: translate3d(0, 0, 0);
    backface-visibility: hidden;
    perspective: 1000px;
}

._b38a::after {
    content: ' after';
    color: brown;
}

._43fe {color: red;}

._b04b {font-size: 24px;}

._4b6c {border: 1px solid black;}

._f617 {border: 1px black solid;}

原子化

如例子所演示的那样,broken-css 会根据样式的内容计算出一个哈希值来表示这个样式规则,同时这个哈希值会被当做类名替换到相应的 JS 文件中,因为哈希是根据其内容计算的,来自两个文件相同的样式计算出的哈希值是一样的,因此在后续的去重步骤中可以将其筛选掉,从而达到复用的目的。 这里需要多说一点的是,对于样式 border: 1px solid black; 和 border: 1px black solid; 来说, broken-css 并不会视为是同一种样式,尽管他们的效果是一样的。如果要做到这种程度的复用,需要考虑太多的边界情况,我希望有一种简单通用的方法来解决这个问题,还在研究中。

体积优势

在使用 broken-css 后,你的 CSS 体积在刚开始不会明显的减少,但随着项目的发展,越来越多的样式存在重复,使得复用的可能性大大增多,体积就会降下来,如果把体积的变化汇成一条线的话,传统的 CSS 体积增长是一条直线,而 broken-css 则是一条曲线。 配图源于《Atomic CSS-in-JS》

伪类支持

broken-css 支持伪类选择器,对于形如 &::after { ... } 的规则,broken-css 会根据整体样式规则计算出哈希值,然后将 & 替换成对应的哈希值。

// a.js
const cls1 = css`
        color: red;
`

// b.js

const cls2 = css`
        &:hover {
                color: red;
                font-size: 24px;
        }
`

会编译为

// a.js
const cls1 = 'c1'
/*
    color: red;
*/

// b.js
const cls2 = 'c2 c3'
/*
    &:hover {
            color: red;
            font-size: 24px;
    }
*/


.c1, .c2:hover { color: red; }
.c3:hover { font-size: 24px; }  

从而达到更细粒度的复用,~~但是对于现有的版本来说,只会编译成 ,已经实现,~~有一些情况会导致样式冲突,已经切换为老的实现

@规则支持

@ 规则的支持是自然而然的,因此你可以自由的使用动画和媒体查询等规则,broken-css 不会对他们做任何处理,只是简单的解压到最终的 CSS 文件中。 这里我纠结的一点在于要不要在 @keyframes 命名的隔离,但是在后续的思考中发现这并不是简单的事情,例如命名 scope 的范围,是隔离在每一次 css ... `` 调用期间,这样的话怎么做复用?全局范围的话,意味着需要维持一张状态表,并且分析全局的 CSS 代码将相应的命名替换掉,同样的很复杂。

CSS 变量

broken-css 将 CSS 变量视作一个普通的样式声明,并没有任何特殊的处理,同样的会根据其内容计算出一个哈希值,并分配一个唯一的类名

const cls1 = css`
        --main-color: red;
        backgroud-color: var(--main-color);
`

const cls2 = css`
        backgroud-color: var(--main-color);
`

会被编译为

const cls1 = 'c1 c2'
const cls2 = 'c2'


.c1 { --main-color: red; }
.c2 { backgroud-color: var(--main-color); }

问题

智能语法提示

我不太熟悉其他的 IDE ,如果你使用 VSCode ,这个问题可以很完美的解决, broken-css 的 API 形式兼容了 vscode-styled-components 扩展。

stylelint

broken-css 在编译期间会 JSS 抽离解压出来,在转换成原子化的 CSS 后,会将后续的行为委托给 webpack ,因此你可以自由的选择是否使用 stylelint-webpack-plugin ,并且 vscode-styled-components 支持书写期间的 lint 检查。

我所在的字节电商广告前端团队,现有大量HC。实习,初级和资深前端都可。可以加微信私聊(yunfeihe),或者简历直发 wujiantao@bytedance.com ,邮件标题请注明【简历】。

作者:何云飞