broken-css – 零运行时的原子化 JSS 方案
介绍
broken-css 践行了 “正常的编写代码,让编译器负责优化的事情” 的理念,你会使用你熟悉的方式来编写 CSS ,让编译器来自动原子化你的样式,进行复用。
broken-css 核心实现由以下两个库构成
@broken-css/core
@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 则是一条曲线。
伪类支持
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'
// b.js
const cls2 = 'c1 c2'
复制代码
.c1, .c1:hover { color: red; }
.c2 { font-size: 24px; }
复制代码
@规则支持
@ 规则的支持是无需 broken-css 的编译期做任何特殊处理,因此你可以自由的使用动画和媒体查询等规则,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); }
复制代码
SSR
由于 broken-css 会在编译后将 JSS 抽离到 CSS 文件中,因此你可以在返回组件生成的 html 片段时,去读取 CSS 文件自行决定用什么形式传输给浏览器。
import { collect } from '@linaria/server';
const css = fs.readFileSync('./dist/styles.css', 'utf8');
const html = ReactDOMServer.renderToString(<App />);
const { critical, other } = collect(html, css);
复制代码
这是 linaria 实现的 SSR 的方式,broken-css 同样适用于这种方式,现在面临的问题是 broken-css 还没有实现类似 collect
形式的用于抽离非首屏的样式的 API ,因此返回的是全量 CSS ,会带有一些冗余样式。实际上如果只是考虑到抽离这个功能,你可以直接使用 @linaria/server
,它并不是依赖于某个特定 JSS 框架的。
智能语法提示
我不太熟悉其他的 IDE ,如果你使用 VSCode ,这个问题可以很完美的解决, broken-css 的 API 形式兼容了 vscode-styled-components 扩展。
后记
broken-css 是我现在在字节实习期间的兴趣项目。项目的灵感起源于 Facebook 分享的他们 如何使用 stylex 来大幅度减少他们的 CSS 体积 和 Linaria ,但那时 stylex 没有开源,同时我也对他们 react-native-like 的 API 形式也颇为不喜,就准备自己尝试写一个出来。心理有了这个想法之后就和当时的导师讨论了下,尽管技术方向和我们当时业务技术栈无关,导师还是鼓励我做一些技术探索。
一周的时间里,花了两三天思考技术方案,再花两三天编写整整个框架,最终在周日把原型整了出来。
完成原型后,我在公司内做了关于 broken-css 的技术分享,在内部论坛上发了一篇技术文章。自此,整个项目就基本上停止开发了。项目停止开发的次要原因是 JSS 和我们业务技术栈方向不契合。我们业务技术栈主要是 Vue ,在 Vue 中使用 JSS 没有什么难度,但是开发体验以及维护难度并不友好。试着体验阅读下以下代码。
<script lang="ts">
import Vue from 'vue'
import { css } from '@broken-css/core`
export default Vue.component('counter', {
mainCls: css` height: 200px; `,
countCls: css ` font-size: 20px; color: blue; `,
data() {
return {
count: 0,
}
},
methods: {
inc() {
this.count++
}
}
})
</script>
<template>
<div :class="mainCls">
count: <span :class="countCls">{{ count }}</span>
<button :onClick="inc" >Inc</button>
</div>
</template>
复制代码
主要原因还是我个人对 broken-css 失去了兴趣,因为在实现 broken-css 后,我又接触了很多新的概念,我的脑海中多了很多新的可能性。比如说, Vue3 的 setup
语法使得 Vue 中使用 JSS 的开发体验同样友好, 如何在设计上同时对 Vue 和 React 友好。利用 CSS Variable 来扩展 broken-css 的动态性, Vue3 也做了类似的尝试。 VueConf 上 @HcySunYang 大佬的 “把 Vue SFC 编译到 X” 分享更是给了我很多编译方面的启示。
尽管这些东西同样可以迁移到 broken-css 上实现,但是我很满意 broken-css 现在的实现,尽管有着各种各样的缺点,但是它的确完成了当初设计它的目标。
由于一些原因,主要是代码写的太烂 :),我不想也不能把代码开源出来。如果你对 broken-css 感兴趣的话,欢迎在飞书、微信(yunfeihe)和邮件(i.heyunfei#gmail.com)上找我讨论。
或者,更直接一点,欢迎加入我所在的字节电商广告团队,我们现在实习、校招和社招都有大量的 HC ,有兴趣的同学可直接加我微信。