为了账号安全,请及时绑定邮箱和手机立即绑定

【CSS 系列好文】Tailwind 生成的 CSS文件竟然有 3.7 Mb 这么巨大!这还能用?

忍不住开头打个广告😎:
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_ENVproduction 的情况下,构建生成的样式表只会留下用到的样式,一般不会超过 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 其实思路也很清晰:

  1. 先扫描用户提供的入口文件,根据用户提供的提取器针对特定文件类型提取出使用到的各种属性,如 attributes, classes

  2. 解析 CSS 文件,生成抽象语法树,再去提取信息中查找匹配,将未使用到的 CSS 规则从语法树中删掉,最终生成精简后的 CSS 文本。

最后,展望了未来 Tailwind 未来按需编译的方向。

总而言之,希望 CSS 的世界越来越好!

作者:ssh前端

打个小广告

2021 火爆全网的 CSS 架构实战课上线,好评如潮!!!
【课程链接:https://coding.imooc.com/class/501.html

点击查看更多内容
1人点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消