Tailwind中的purgeCSS机制——CSS树摇
Tailwind 是最近国外大火的 Utility CSS 框架,形态上有点类似以前的 Bootstrap,潮流是一种轮回。
用它来写一个卡片,大概是这样的体验,只用到了工具 class,而不用写任何额外的样式:
tailwind card
不过只把他当成 Bootstrap 或者内联样式就有点太狭隘了,它提供了非常多的现代化特性:
国外的流行
在国外的火热程度已经证明了它带来的收益,程序员们都不傻,如果一个新工具只带来负担而没有收益,大家是不会热烈的拥护它的。
在 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 的选项,用于开启清理无用样式的功能:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">// tailwind.config.js module.exports = { purge: ["./src*.html", "./src*.vue", "./src*.jsx"], theme: {}, variants: {}, plugins: [], };
</pre>
在这个选项范围内的文件都会被扫描,用于确定使用到了哪些类名,最后在 NODE_ENV 为 production 的情况下,构建生成的样式表只会留下用到的样式,一般不会超过 10kb,这下就轻量多了!
从示例选项中的后缀名也可以看出,无论是 vue 还是 react 文件,都是支持的。
CSS Purge 底层
官网也有提到,这项名为 purge CSS 的功能,底层是使用了 purgecss[5] 这个库。
这个库并不是只供 Tailwind CSS 使用,它最简单的使用只需要提供一个 html 入口,还有一份样式文件,就会自动帮你找出项目中使用到的那部分 CSS的结果。
尝试一下这个库,先写一个 index.html,里面只使用 hello 这个样式:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;"> Hello
</pre>
再写一个 index.css,里面故意多写一个没用的 useless 类:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">.hello { text-align: center; } .useless { margin: 8px; }
</pre>
然后根据 Github 里的用法,写一段构建脚本:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">const PurgeCSS = require("purgecss").default; (async () => { const purgeCSSResults = await new PurgeCSS().purge({ content: ["index.html"], css: ["index.css"], }); console.log(purgeCSSResults); })();
</pre>
控制台打印出如下结果:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">[{ css: ".hello {\n text-align: center;\n}", file: "index.css" }];
</pre>
完美的清除掉了 useless 类。
它的设计和框架无关,所以各个框架也可以基于这个工具封装自己的上层工具。
比如 vue-cli-plugin-purgecss[6],可以用来在 Vue 中清理你没有使用到的样式。
而它的实现也不复杂,只是在 postcss 配置中加了一个 plugin,再配合 purgeCSS 提供的自定义提取功能把 .vue 文件中的 整个删除掉,这样就可以找到使用到了哪些样式。
/templates/postcss.config.js:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">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) { // 排除 标签中匹配的样式 const contentWithoutStyleBlocks = content.replace( / typeof o === "string" ) as string[]; // 获取每种文件类型的“选择器”,用于提取使用到的样式 const cssFileSelectors = await this.extractSelectorsFromFiles( fileFormatContents, extractors ); // 提取使用到的样式 return this.getPurgedCSS( css, mergeExtractorSelectors(cssFileSelectors, cssRawSelectors) ); }
</pre>
而 getPurgedCSS 中,则会利用 postcss 去生成对应 CSS 文件的 AST,然后根据用户传入的规则做一系列的匹配,找出无用的样式,直接删除掉规则节点。
精简后的流程如下:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">public async getPurgedCSS( cssOptions: Array, selectors: ExtractorResultSets ): Promise { 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; }
</pre>
提取器
移除无用样式的关键代码是:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">this.walkThroughCSS(root, selectors);
</pre>
这其中最重要的就是这个 selectors 了,根据 purgeCSS 官网的 extractors 部分[8],框架会内置一个默认的提取器,支持任何类型的文件内提取关键词。
The default extractor considers every word of a file as a selector.
也就是说,默认的提取器会宁可错杀三千不可放过一个,把每个单词都视为可能的关键词。
从源码里来看,这个提取器简单粗暴的匹配了一切大小写字母和下划线、中划线:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">defaultExtractor: (content) => content.match(/[A-Za-z0-9_-]+/g) || [],
</pre>
可以看出,这种提取器的失误率很高,比如这样一段简单的 HTML 文本:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;"> Document Hello
</pre>
提取出来的关键词有 30 个以上:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">undetermined: [ "DOCTYPE", "html", "lang", "en", // 各种词语 ... "div", "class", "hello", "Hello", ];
</pre>
由于这是针对所有文件类型的关键词提取,所以它提取出的关键词被分类在undetermined中,这个分类是用来兜底匹配的,无论是 class 类型还是 tag 类型,只要它的在 undetermined 中出现,那么这个 CSS 节点就不会被删除。
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">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); }
</pre>
不过这在框架设计中是非常有道理的,框架绝对不可以为了所谓的优雅或者精简,而去让用户承担风险(比如样式被误删),所以有时候看似笨重的做法反而是最合适的做法。
当然,purgeCSS 也提供了完善的 API,让社区可以针对不同类型的文件做精确的提取器,从这个类型中就可以看出:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">type ExtractorResultDetailed = { attributes: { names: string[]; values: string[]; }; classes: string[]; ids: string[]; tags: string[]; undetermined: string[]; };
</pre>
提取器支持各种各样的属性,你可以自己去写文件的解析,决定某些属性究竟是 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 节点:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">[ { // h1 type: "selector", node: { type: "tag", value: "h1", }, }, { // #useless type: "selector", node: { type: "id", value: "useless", }, }, { // .hello type: "selector", node: { type: "class", value: "hello", }, }, ];
</pre>
再根据提取器中的信息,分别确定类名、id、标签究竟有没有使用到:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">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; } }
</pre>
最终,没有用到的选择器会被调用 selector.remove() 方法,从 AST 树中移除掉。
样式的处理非常精细,由于我们只用到了 hello 这个类,最终生成的样式规则也会删除掉无关的 h1 和 #useless:
<pre data-tool="mdnice编辑器" style="margin-top: 10px;margin-bottom: 10px;">{ css: '.hello { text-align: center; }' },
</pre>
至此,一份瘦身完成的 CSS 文本就处理完成了。
展望未来
Tailwind 在开发环境全量编译这一特性,在本身启动就很慢的 Webpack 环境下还好,但是在以秒启动为卖点的 Vite 项目中就变得非常不可接受了。
在 Anthony Fu[10] 的这条推中提到:
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] 中也可以看出,这个特性在经过社区大量的反馈css文件是自动生成吗,趋于稳定之后,将会成为 Tailwind CSS v3.0 的默认选项。
总结
无论如何,Tailwind 在 CSS 的世界里无疑是浓墨重彩的一笔。虽然中文社区目前对它的评价还充斥这反对的声音,它还是在朝着积极的方向发展下去。
在 React 项目中,我们可以尝试这样的组合:
有了这几个工具的加持,React 样式开发体验变得非常顺滑,从我个人的角度是非常喜欢这一系列生态的。
本文介绍了 Tailwind 的大致用法,之后重点介绍了 purgeCSS 的能力,以帮助大家更好的了解 CSS tree-shaking 目前的生态。
purgeCSS 其实思路也很清晰:
先扫描用户提供的入口文件,根据用户提供的提取器针对特定文件类型提取出使用到的各种属性,如 attributes, classes。
解析 CSS 文件,生成抽象语法树css文件是自动生成吗,再去提取信息中查找匹配,将未使用到的 CSS 规则从语法树中删掉,最终生成精简后的 CSS 文本。
最后,展望了未来 Tailwind 未来按需编译的方向。
总而言之,希望 CSS 的世界越来越好!
参考资料
[1]
Tailwind Responsive Utilities:
[2]
state variant:
[3]
State Of CSS 2020:
[4]
Optimizing for Production:
[5]
purgecss:
[6]
vue-cli-plugin-purgecss:
[7]
purgecss: #packages
[8]
purgeCSS 官网的 extractors 部分:
[9]
purgecss-from-html:
[10]
Anthony Fu:
[11]
WindiCSS:
[12]
tailwindcss-jit:
[13]
维基百科的定义: %E5%8D%B3%E6%99%82%E7%B7%A8%E8%AD%AF
[14]
tailwindcss-jit 的 Roadmap: #roadmap
[15]
styled-component:
[16]
tailwind-macro:
[17]
Tailwind:
发表评论
热门文章
Spimes主题专为博客、自媒体、资讯类的网站设计....
一款个人简历主题,可以简单搭建一下,具体也比较简单....
仿制主题,Typecho博客主题,昼夜双版设计,可....
用于作品展示、资源下载,行业垂直性网站、个人博客,....
尘集杂货铺和官网1t5-cn
11月11日
[已回复]
希望主题和播放器能支持SQLite数据库,AI能多个讯飞星火