Tailwind中的purgeCSS机制——CSS树摇

  Tailwind 是最近国外大火的 Utility CSS 框架,形态上有点类似以前的 Bootstrap,潮流是一种轮回。

  用它来写一个卡片,大概是这样的体验,只用到了工具 class,而不用写任何额外的样式:

  css文件是自动生成吗_php 生成css文件_外链css文件变为页面css文件

  tailwind card

  不过只把他当成 Bootstrap 或者内联样式就有点太狭隘了,它提供了非常多的现代化特性:

  国外的流行

  在国外的火热程度已经证明了它带来的收益,程序员们都不傻,如果一个新工具只带来负担而没有收益,大家是不会热烈的拥护它的。

  在 State Of CSS 2020[3] 的调查中,Tailwind 在「满意度, 关注度, 使用率, 和认知率的排行」中冲上了首位:

  php 生成css文件_css文件是自动生成吗_外链css文件变为页面css文件

  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 的机制,直接丢到线上去,那真是灾难了。

  我自己手动生成尝试了下,大概长这个样子:

  php 生成css文件_外链css文件变为页面css文件_css文件是自动生成吗

  方案

  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 底层

  php 生成css文件_css文件是自动生成吗_外链css文件变为页面css文件

  官网也有提到,这项名为 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] 的这条推中提到:

  外链css文件变为页面css文件_php 生成css文件_css文件是自动生成吗

  Tailwind vs Windi

  WindiCSS[11] 是什么呢?说来也简单,其实就是按需编译版本的 Tailwind,它会在生成样式代码之前就扫描你的文件,确定编译生成的样式产物。

  这样就可以避免生成之前提到的 3739.8kB 的怪物 CSS 文件。

  戏剧性的是,在这个项目出现后不久,Tailwind 的作者就宣布了实验性的项目 tailwindcss-jit[12]。

  css文件是自动生成吗_外链css文件变为页面css文件_php 生成css文件

  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:

文章由官网发布,如若转载,请注明出处:https://www.veimoz.com/1874
0 评论
601

发表评论

!