原文链接:Speeding up the JavaScript ecosystem - Tailwind CSS
自诞生以来,Tailwind CSS已经成为一种非常流行的web项目样式化方案。这一次,我们将看一看支持它的架构,以及可以做些什么来改进它。
老实说,目前我手头没有更大的项目是用 Tailwind CSS 写的。用 Tailwind 写的项目太小了以至于不能去做有意义的性能分析。所以我想,还有什么办法比用 Tailwind 的官方网站去介绍 Tailwind 更好呢!但是在一开始我遇到了一个问题:"这个项目是用 Next.js 构建的,这使得我很难这个项目很难获得有意义的信息。"更别说获取到的信息还有很多和 TailwindCSS 不相关的无用信息。
作为替换,我决定在项目上运行 Tailwind CLI 来获取性能信息。Tailwind CLI 构建花了大概 3.2s,Tailwind 里的运行时大概花了 1.4s 左右。这些数字来自我的个人电脑 MacBook M1 Air。看看下面的图片我们可以搞明白一些关键区域所花的时间。
和我之前的文章一样,图表的 x 轴并不展示"它什么时候发生",而是展示堆积在这里的每个堆栈的累计时间。这能让我们一眼就看到问题区域。我在用 SpeedScope 去可视化 CPU的信息。
有一个代码块用于提取用于解析的潜在候选项,一个用于配置和插件初始化的代码块,CSS生成,一些PostCSS相关的内容。当涉及到PostCSS时,autoprefixer通常也会被同时提及,因为它们经常一起使用。值得注意的是,即使什么都不做,加载autoprefixer似乎已经消耗了相当多的时间。
换一下你的思路
仔细看下 Tailwind CSS 代码库和配置文件,那里绝对有一些函数是可以被优化的。但是如果我们做了这些优化,我们只能得到几个百分点的提升。在之前的帖子中,有些很明显的东西跳出了配置文件被我们所觉察到,但是在这里,我们在没有任何指导的情况下,应该怎么做?
实现多因素加速的秘密,不仅仅是低百分比,不是应用通用规则或习惯,比如"不要在for循环中创建闭包"。一个常见的误解是,如果您遵循所有这些"最佳实践",那么您的代码就会很快,因为在大多数情况下(不是全部)令人不安的事实是,这并不重要。让代码真正快速的是意识到它应该解决什么,然后采取最短的路径来实现这个目标。
所以作为一个挑战,我认为去看看Tailwind的代码结构看起来是什么样的会更有趣。如果我们从性能的角度去考虑如何构建它,这是否会造成一个不同的结果?但为了找到一个更优化的结构,我们需要去知道Tailwind解决的是哪个问题,以及最短的解决这个问题的路径。
小剧透:
Tailwind CSS 是如何工作的
在它的核心代码中,Tailwind CSS的工作方式是,首先你传递一些 CSS 文件,并在其中寻找@tailwind
的规则。如果它遇到了这样的规则,他就会为了查找 tailwind 类名爬取你项目中的其他文件,并在找到之后注入到@tailwind
规则被发现的 CSS 文件里。它还有其他一些方面,但为了本文的简洁起见,我们现在将忽略其他规则。
CSS
/* Input */
@tailwind base;
@tailwind components;
@tailwind utilities;
.foo {
color: red;
}
被转化为
css
.border {
border-width: 1px;
}
.border-2 {
border-width: 2px;
}
/* ...etc */
.foo {
color: red;
}
基于此我们可以确认 Tailwind CSS 内部工作原理的几个阶段
- 扫描以
.css
为后缀的文件去匹配@tailwind
的规则 - 根据用户在 Tailwind 配置中提供的全局模式,查找要从中提取 Tailwind 类名的所有文件
- 找到这些文件后,就提取潜在的 tailwind 类名。
- 解析潜在的 tailwind 类名去检查他们是否是真的 tailwind 类名。如果他们真的是,则从中生成一些CSS。
- 用生成的 CSS 替代在原来文件中的
@tailwind
规则。
优化提取阶段
因为只有三种有效的 @tailwind
规则值,我们通过基础的正则可以绕过整个 PostCSS 解析阶段:
js
/@tailwind\s+(base|components|utilities)(?:;|$)/gm;
有了这个正则,在所有的CSS文件里找到@tailwind
的规则和它的位置是很容易的,因为它只花了大概0.02ms
。与 Tailwind CSS 花费的 3.2s 相比,这个时间几乎不重要。当涉及到用户的 glob 模式查找文件的时候,我们不能做太多影响总时间的事情,因为我们无论如何都要访问文件系统,并且需要使用运行时提供给我们的 readFile
方法。
但是,一旦这些文件被读取了,并且我们需要提取潜在的tailwind 类名的时候,我们就有很多事情可以做了。但是有一个问题,我们怎么知道我们探测到的东西是不是 tailwind 类名?这乍一听起来可能很简单,但仔细想想,并没有那么简单。问题是没有任何maker或任何其他指示表明一个字符序列是一个有效的Tailwind类名。因为可能存在与Tailwind类名格式相同但不存在的单词组合。
有效的 tailwind 类名:
ml-2
border-b-green-500
dark: text-slate-100
dark: text-slate-100/50
[&:not(:focus-visible)]:focus:outline-none
foo-bar
是有效的 tailwind 类名吗?它并不是默认的 tailwind 语法,但是它可以由用户添加。所以我们有的唯一真正的选择就是尽可能减少搜索空间,然后向解析器提供剩余的候选项。如果解析器生成了一些CSS,那么我们就是知道这个类名是有效的,如果它并没有,那它就是无效的。这个结论反过来意味着我们需要优化我们的解析器,如果它检测到没有定义的字符串值,则尽快退出。
为了提醒我们:这个过程在当前的 Tailwind CSS 花费了 388ms
我在本地修复了 Tailwind CSS 以揭露提取器提取的一些信息。
- 解析器文件;454
- 类候选字符:26466
但是最有意思的是看提取器提取出来的最常使用前10个代码
js
- 9774x ''
- 2634x </div>
- 1858x }
- 1692x ```
- 1065x },
- 820x ---
- 694x ```html
- 385x {
- 363x >
- 345x </p>
换句话说,在 264666 个匹配的字符中,有 19630 个字符都显而易见是无效的类名。现在让我们说实话,Tailwind CSS有缓存,以减轻是否有误报的检查负担。并且已经有一个人评论说,任何对于正则的提升都可以提升 Tailwind CSS 接近 30% 的性能。
重新计算所有东西
使用正则的好处和坏处就是他是和语言无关的。它并不知道我们是操作.js
文件还是.html
文件,并且更坏的是这些语言可以彼此融入。一个.html
文件可以同时容纳 HTML、Javascript和CSS。在 .jsx
文件中也是同理。当我们涉及到 JavaScript 代码的时候,我们假设只看到字符串。
在经过一个很快但是很丑的正则,我们将搜索空间从 26466 减少到 9633 个候选类名。仍然不是最优的,但是比我们开始的时候要好很多了。很多提取出来的字符串看起来更像潜在的 tailwind 类了:
relative not-prose [a:not(:first-child)>&]:mt-12
none
break-after
grid-template-rows
每个被提取的字符可能包含一个或多个潜在的类。我们可以进一步减少搜索空间,方法是在每个提取的字符串上触发另一个正则表达式,以提取出可能是有效 Tailwind 类名的部分。对我们来说幸运的是,有效的 tailwind 类名的语法遵循下面几条简单的规则。
- 不允许有空格
- 变量必须以冒号结尾。
- 任意值都是用方括号
[foo]
定义的,它们必须被放在类名的末尾。 - 变量也可以是任意值,例如:
[&>.foo]:border-2
。必须仍然不包含空格。 - 括号内的任何东西必须只包含数字、字母或者减号。我并不确定是否允许使用下划线,但是我猜它可以是用户自定义的 tailwind 类名。
- 一个有效的 Tailwind 类名必须以
[
,-
,!
,a-z
或者0-9
开头
所有的这些匹配确实需要花费一些时间,并且将总的花费时间提升到了92ms
。并且经过我们减少搜索空间的努力之后,我们仍然有 8000
个潜在的 tailwind 类名(记住,之前提取的字符串可以包含多个候选字符串)。
到目前为止,我们取得了相当多值得称道的收获。我们将从Tailwind 的源代码的提取时间从 388ms
减少到了 98ms
,这是接近四倍的提升。
将类名转化为 CSS
在这个阶段,我们仍然没有生成任何的 CSS 规则。我们仍然需要一些规则去替换,以支持我们开始使用的原始CSS文件的 @tailwindcss
规则。但是现在我们有能力使用潜在的Tailwind类名列表来做到这一点。其中很多可能是误报,所以我们需要尽快确保如果我们检测到一个类名不能渲染CSS,我们能够尽快退出。
第一步是解析前面的变量(如果有的话)。请记住,变量可以通过末尾的冒号:
字符来检测。变量的一个关键方面是它们只影响选择器,如果有的话可能还会影响周围的媒体查询。它们本身不用于生成CSS属性。解析变体只是一些繁重的工作,没有什么特别的。如果我们检测到一个假定的变量不存在,我们可以提前退出。
比处理变量更有意思的是生成规则的方面。大部分的Tailwind类名都不包含变量。因为Tailwind映射了许多CSS属性,因此我们需要进行的潜在匹配的数量相当大。我已经尝试很多方法,比如匹配所有的静态 tailwind 类名,把所有的东西放在一个对象中,用方法来使用,比如一个虚拟函数表等等。但最后最快的,我觉得最容易维护的是一个巨大的愚蠢的开关语句。
js
function parse(lexer, config, hasNegativePrefix) {
const first = lexer.nextSegment()
switch (first) {
case "aspect":
//...
case "block":
if (!lexer.isEnd) return // bail out
return `display: block`
case "inline":
if (lexer.isEnd) return `display: inline`
const second = lexer.nextSegment();
if (
second !== "block" || second !== "flex" || second !== "table"
|| second !== "grid"
) {
return // bail out
}
return `display: inline-${second}`
// ...1000 lines more of this
}
}
这个可能看起来就是很标准的解析器代码,但是这里也有一些很有意思的部分。最明显的一个就是在每个步骤我们都会检查这个路径是否是一个有效路径。这增加了很多额外检查代码,但是我发现,更早的退出能抵消掉这些成本。在之前的一些迭代中,我在提取部分犯了一个错误,结果导致将太多已知的误报字符串传递给了这个解析函数。但是由于解析函数能够快速退出无效的类名,所以我花了一些时间才注意到这个问题,因为总体上它仍然很快。
值得注意的是,还有一个hasNegativePrefix参数传递给parse()函数。许多基于数字的属性,比如padding,可以通过在类名前加上减号-来接收负值。
js
"pl-2"; // -> padding-left: 0.5rem;
"-pl-2"; // -> padding-left: -0.5rem;
在传递给parse()函数之前,会去掉前导的减号字符,这样我们就可以在正常情况和负数情况下重用相同的分支。这里没有显示,但解析器还支持任意值、important声明、带有透明度的颜色值等等。
虽然我没有实现每一条规则,但支持所有的语法变体。我确实实现了相当一部分的规则,大约126条。这大约占到了Tailwind语法的80%。尽管这主要是一个原型,但我想更好地了解解析器的扩展性。
有了生成的规则,我们现在可以最终替换原始CSS文件中的@tailwind规则了。如果我们希望它能够识别源映射,可以使用Magic String。
一切就绪,以下是最终的测量结果:
提取:98毫秒
解析:21毫秒
总时间:192毫秒(包括运行时启动时间)
整个项目由5个文件组成(不包括测试),总共接近3000行代码。