忍不住开头打个广告😎:
2021 火爆全网的 CSS 架构实战课上线,好评如潮!!!
【课程链接:https://coding.imooc.com/class/501.html 】
Tailwind 是最近国外大火的 Utility CSS 框架,形态上有点类似以前的 Bootstrap,潮流是一种轮回。
用它来写一个卡片,大概是这样的体验,只用到了工具 class,而不用写任何额外的样式:
tailwind card
不过只把他当成 Bootstrap 或者内联样式就有点太狭隘了,它提供了非常多的现代化特性:
-
有约束的设计:用内联样式,你只是在随处书写
魔法值
,而使用工具类框架,你则是在设计系统
的约束下书写样式,有约束的前提下才可以谈工程化的可维护性、可拓展性,这点相信大家都深有感受。 -
响应式设计:内联样式不可以使用响应式,但你可以在 Tailwind Responsive Utilities[1] 的帮助下构建响应式的系统。
-
Hover, Focus 等状态:内联样式不支持 Hover, Focus 等目标状态的样式书写,但 Tailwind 提供的 state variant[2] 系统让你可以轻松定义不同状态下的样式。
-
更加原子化:比起 Bootstrap 预先提供好的
.btn-primary
这样的语义,Tailwind 的抽象等级显然更加底层。它不会提供按钮,表单这种高层抽象,而是去提供更底层的间距、色彩、布局等抽象。简单来说,你可以用 Tailwind 的工具类组合比如:.pd-2 .bg-blue
等组合去还原 Bootstrap 的.btn-primary
样式,反之则不可能,两者之间根本不是同一抽象等级的,无需对比。
国外的流行
在国外的火热程度已经证明了它带来的收益,程序员们都不傻,如果一个新工具只带来负担而没有收益,大家是不会热烈的拥护它的。
在 State Of CSS 2020[3] 的调查中,Tailwind 在「满意度, 关注度, 使用率, 和认知率的排行」中冲上了首位:
stateofcss
代价
不过今天我想聊的不是 Tailwind 的优点,这些国内也有很多文章都已经聊过,今天想探索的是 Tailwind 中的 purgeCSS
机制。
一直以来,JS 的 tree-shaking 都是很热门的话题(尤其是面试中 😁),但是 CSS 的 tree-shaking 相比来说则比较冷门。在 Tailwind 的 Optimizing for Production[4] 章节中,我们看到了 CSS 树摇的身影,这实在是勾起了我的兴趣。
聊这个,就不得不提及 Tailwind 的原理,它基于 postcss 来扫描 CSS 文件,生成 AST(抽象语法树)再通过一系列的转换,最后构建出一份完整的工具类 CSS。
在开发的时候,Tailwind 其实不知道你会写出什么样的工具类,比如这个页面你突然发现要加一个 mr-8
,总不能每次保存文件的时候重新生成样式,所以目前 Tailwind 是先全量生成一份完整的 CSS,包含了 mr-1
- mr-8
供你使用的。
这就必然会带来一个问题,也就是生成的无关 CSS 过多,导致文件过大,根据 Tailwind 官网的说法:
Using the default configuration, the development build of Tailwind CSS is 3739.8kB uncompressed, 294.0kB minified and compressed with Gzip, and 71.5kB when compressed with Brotli.
简单来说,未压缩的情况下这个样式文件达到了 3739.8kB
的惊人大小!这要是不加上 CSS tree-shaking 的机制,直接丢到线上去,那真是灾难了。
我自己手动生成尝试了下,大概长这个样子:
方案
Tailwind 提供了 purge
的选项,用于开启清理无用样式的功能:
// tailwind.config.js
module.exports = {
purge: ["./src/**/*.html", "./src/**/*.vue", "./src/**/*.jsx"],
theme: {},
variants: {},
plugins: [],
};
在这个选项范围内的文件都会被扫描,用于确定使用到了哪些类名,最后在 NODE_ENV
为 production
的情况下,构建生成的样式表只会留下用到的样式,一般不会超过 10kb
,这下就轻量多了!
从示例选项中的后缀名也可以看出,无论是 vue
还是 react
文件,都是支持的。
CSS Purge 底层
官网也有提到,这项名为 purge CSS
的功能,底层是使用了 purgecss[5] 这个库。
这个库并不是只供 Tailwind CSS 使用,它最简单的使用只需要提供一个 html
入口,还有一份样式文件,就会自动帮你找出项目中使用到的那部分 CSS的结果。
尝试一下这个库,先写一个 index.html
,里面只使用 hello
这个样式:
<!DOCTYPE html>
<html lang="en">
<body>
<div class="hello">Hello</div>
</body>
</html>
再写一个 index.css
,里面故意多写一个没用的 useless
类:
.hello {
text-align: center;
}
.useless {
margin: 8px;
}
然后根据 Github 里的用法,写一段构建脚本:
const PurgeCSS = require("purgecss").default;
(async () => {
const purgeCSSResults = await new PurgeCSS().purge({
content: ["index.html"],
css: ["index.css"],
});
console.log(purgeCSSResults);
})();
控制台打印出如下结果:
[{ css: ".hello {\n text-align: center;\n}", file: "index.css" }];
完美的清除掉了 useless
类。
它的设计和框架无关,所以各个框架也可以基于这个工具封装自己的上层工具。
比如 vue-cli-plugin-purgecss[6],可以用来在 Vue 中清理你没有使用到的样式。
而它的实现也不复杂,只是在 postcss
配置中加了一个 plugin,再配合 purgeCSS
提供的自定义提取功能把 .vue
文件中的 <style></style>
整个删除掉,这样就可以找到使用到了哪些样式。
/templates/postcss.config.js
:
const IN_PRODUCTION = process.env.NODE_ENV === "production";
module.exports = {
plugins: [
IN_PRODUCTION &&
require("@fullhuman/postcss-purgecss")({
// Vue 项目中,样式一般都出现在 .vue 文件里
content: [`./public/**/*.html`, `./src/**/*.vue`],
defaultExtractor(content) {
// 排除 <style> 标签中匹配的样式
const contentWithoutStyleBlocks = content.replace(
/<style[^]+?<\/style>/gi,
""
);
return (
contentWithoutStyleBlocks.match(
/[A-Za-z0-9-_/:]*[A-Za-z0-9-_/]+/g
) || []
);
},
}),
],
};
道理其实很简单,就是先用正则去除掉 style
标签里写的样式,排除干扰,再从剩余部分提取可能用到的类名。
purgecss[7] 目前已经提供了这些开箱即用的集成包:
purgeCSS 集成
也可以选择后编译的方式来接入 purgeCSS,以 React 的接入为例,除了直接去扫描用户编写的 tsx
文件以外,也可以选择在构建完成之后,利用 postbuild
脚本(这个命令会在 build 命令执行完后自动执行)去扫描生成的 html, css
产物。
"scripts": {
"postbuild": "purgecss --css build/static/css/*.css --content build/index.html build/static/js/*.js --output build/static/css"
},
这样,就不需要考虑 tsx
, ts
的各种扫描,规则匹配,只需要利用 purgecss 最原始的能力即可。
purgecss 大致流程
对于 purgecss 这种库,在我自己的知识分类里属于暂时用不到,但是未来一定会用到的广度学习范围里,我习惯大概看一下这些库的流程原理,这样才能知道它究竟能应付什么样的场景。
核心流程
恰巧 purgecss 的核心流程写的非常清晰,我们看看刚才调用的 new PurgeCSS().purge()
方法,我省略掉了一些额外处理的逻辑:
public async purge(
userOptions: UserDefinedOptions | string | undefined
): Promise<ResultPurge[]> {
const { content, css, extractors, safelist } = this.options;
// 获取需要提取的文件范围
const fileFormatContents = content.filter(
(o) => typeof o === "string"
) as string[];
// 获取每种文件类型的“选择器”,用于提取使用到的样式
const cssFileSelectors = await this.extractSelectorsFromFiles(
fileFormatContents,
extractors
);
// 提取使用到的样式
return this.getPurgedCSS(
css,
mergeExtractorSelectors(cssFileSelectors, cssRawSelectors)
);
}
而 getPurgedCSS
中,则会利用 postcss
去生成对应 CSS 文件的 AST,然后根据用户传入的规则做一系列的匹配,找出无用的样式,直接删除掉规则节点。
精简后的流程如下:
public async getPurgedCSS(
cssOptions: Array<string | RawCSS>,
selectors: ExtractorResultSets
): Promise<ResultPurge[]> {
const sources = [];
for (const option of processedOptions) {
// parse 出 AST 树
const root = postcss.parse(cssContent);
// 遍历 CSS 的 AST 节点,根据 selectors 信息清除掉无用的样式
this.walkThroughCSS(root, selectors);
const result: ResultPurge = {
// 调用 AST 的 toString() 方法,还原成 CSS 文本
css: root.toString(),
file: typeof option === "string" ? option : undefined,
};
sources.push(result);
}
return sources;
}
提取器
移除无用样式的关键代码是:
this.walkThroughCSS(root, selectors);
这其中最重要的就是这个 selectors
了,根据 purgeCSS 官网的 extractors 部分[8],框架会内置一个默认的提取器,支持任何类型的文件内提取关键词。
The default extractor considers every word of a file as a selector.
也就是说,默认的提取器会宁可错杀三千不可放过一个,把每个单词都视为可能的关键词。
从源码里来看,这个提取器简单粗暴的匹配了一切大小写字母和下划线、中划线:
defaultExtractor: (content) => content.match(/[A-Za-z0-9_-]+/g) || [],
可以看出,这种提取器的失误率很高,比如这样一段简单的 HTML 文本:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div class="hello">Hello</div>
</body>
</html>
提取出来的关键词有 30 个以上:
undetermined: [
"DOCTYPE",
"html",
"lang",
"en",
// 各种词语
...
"div",
"class",
"hello",
"Hello",
];
由于这是针对所有文件类型的关键词提取,所以它提取出的关键词被分类在undetermined中,这个分类是用来兜底匹配的,无论是 class
类型还是 tag
类型,只要它的在 undetermined 中出现,那么这个 CSS 节点就不会被删除。
hasAttrValue(value: string): boolean {
return this.attrValues.has(value) || this.undetermined.has(value);
}
hasClass(name: string): boolean {
return this.classes.has(name) || this.undetermined.has(name);
}
hasId(id: string): boolean {
return this.ids.has(id) || this.undetermined.has(id);
}
hasTag(tag: string): boolean {
return this.tags.has(tag) || this.undetermined.has(tag);
}
不过这在框架设计中是非常有道理的,框架绝对不可以为了所谓的优雅或者精简,而去让用户承担风险(比如样式被误删),所以有时候看似笨重的做法反而是最合适的做法。
当然,purgeCSS 也提供了完善的 API,让社区可以针对不同类型的文件做精确的提取器,从这个类型中就可以看出:
type ExtractorResultDetailed = {
attributes: {
names: string[];
values: string[];
};
classes: string[];
ids: string[];
tags: string[];
undetermined: string[];
};
提取器支持各种各样的属性,你可以自己去写文件的解析,决定某些属性究竟是 class 还是 tag,之后在解析选择器的时候,就可以按需匹配了。
可以参考 purgecss-from-html[9] 来写一个完善的提取器。
使用了purgecss-from-html
这个提取器之后, selectors
中的 classes
就应该能精确的找到 hello
这个类名。之后就可以针对 postCSS 解析出的 class
类型的 AST 节点,直接从 classes 中查找是否使用到相应的类名了。
之后,postCSS 会遍历每一个样式节点,在拿到 rule
类型的节点之后,会使用 postcss-selector-parser
这个包去解析选择器。
比如 h1, #useless, .hello
这样的选择器会被分别解析成 3 个 selector
类型的 AST 节点:
[
{
// h1
type: "selector",
node: {
type: "tag",
value: "h1",
},
},
{
// #useless
type: "selector",
node: {
type: "id",
value: "useless",
},
},
{
// .hello
type: "selector",
node: {
type: "class",
value: "hello",
},
},
];
再根据提取器中的信息,分别确定类名、id、标签究竟有没有使用到:
shouldKeepSelector(selectorNode, selectorsFromExtractor) {
// 针对不同类型的 AST 节点 从不同的提取类型中精确查找
switch (selectorNode.type) {
case "attribute":
isPresent = isAttributeFound(selectorNode, selectorsFromExtractor);
break;
case "class":
isPresent = isClassFound(selectorNode, selectorsFromExtractor);
break;
case "id":
isPresent = isIdentifierFound(selectorNode, selectorsFromExtractor);
break;
case "tag":
isPresent = isTagFound(selectorNode, selectorsFromExtractor);
break;
default:
continue;
}
}
最终,没有用到的选择器会被调用 selector.remove()
方法,从 AST 树中移除掉。
样式的处理非常精细,由于我们只用到了 hello
这个类,最终生成的样式规则也会删除掉无关的 h1
和 #useless
:
{ css: '.hello { text-align: center; }' },
至此,一份瘦身完成的 CSS 文本就处理完成了。
展望未来
Tailwind 在开发环境全量编译这一特性,在本身启动就很慢的 Webpack 环境下还好,但是在以秒启动为卖点的 Vite 项目中就变得非常不可接受了。
在 Anthony Fu[10] 的这条推中提到:
-
Tailwind + Vite 的启动时间大概在 22s 左右,热更新的时间在 13s 左右。
-
Tailwind + WindiCSS 启动时间在 1.4s 左右,热更新在 0.09s 左右。
Tailwind vs Windi
WindiCSS[11] 是什么呢?说来也简单,其实就是按需编译版本的 Tailwind,它会在生成样式代码之前就扫描你的文件,确定编译生成的样式产物。
这样就可以避免生成之前提到的 3739.8kB
的怪物 CSS 文件。
戏剧性的是,在这个项目出现后不久,Tailwind 的作者就宣布了实验性的项目 tailwindcss-jit[12]。
Tailwind JIT
JIT 指的是即时编译,参考维基百科的定义[13]:
在计算机技术中,即时编译(英语:just-in-time compilation,缩写为 JIT;又译及时编译、实时编译),也称为动态翻译或运行时编译,是一种执行计算机代码的方法,这种方法涉及在程序执行过程中(在运行期)而不是在执行之前进行编译。
非常类似的按需编译的思路,从 tailwindcss-jit 的 Roadmap[14] 中也可以看出,这个特性在经过社区大量的反馈,趋于稳定之后,将会成为 Tailwind CSS v3.0 的默认选项。
总结
无论如何,Tailwind 在 CSS 的世界里无疑是浓墨重彩的一笔。虽然中文社区目前对它的评价还充斥这反对的声音,它还是在朝着积极的方向发展下去。
在 React 项目中,我们可以尝试这样的组合:
-
✨ 利用 styled-component[15] 搞定组件的动态样式能力。
-
✨ 利用 tailwind-macro[16] 让 Tailwind 的工具类可以在 CSS-in-JS 中完美使用。
-
✨ 利用 Tailwind[17] 的工具类去书写大部分的一次性样式。
有了这几个工具的加持,React 样式开发体验变得非常顺滑,从我个人的角度是非常喜欢这一系列生态的。
本文介绍了 Tailwind 的大致用法,之后重点介绍了 purgeCSS
的能力,以帮助大家更好的了解 CSS tree-shaking 目前的生态。
purgeCSS 其实思路也很清晰:
-
先扫描用户提供的入口文件,根据用户提供的提取器针对特定文件类型提取出使用到的各种属性,如
attributes
,classes
。 -
解析 CSS 文件,生成抽象语法树,再去提取信息中查找匹配,将未使用到的 CSS 规则从语法树中删掉,最终生成精简后的 CSS 文本。
最后,展望了未来 Tailwind 未来按需编译的方向。
总而言之,希望 CSS 的世界越来越好!
作者:ssh前端
打个小广告
2021 火爆全网的 CSS 架构实战课上线,好评如潮!!!
【课程链接:https://coding.imooc.com/class/501.html 】