加速JavaScript -Tailwind CSS

有什么比在 Tailwindcss.com 网站上对 Tailwind 进行性能分析更好的方法呢!然而,在开始的时候,我遇到了一个问题:该项目是用 Next.js 构建的,这使得获取有意义的跟踪信息变得非常困难。更重要的是,这些跟踪信息包含了与 TailwindCSS 完全无关的太多噪音。

相反,我决定使用相同的配置在项目上运行 Tailwind CLI 以获取一些性能跟踪信息。运行 CLI 构建总共需要 3.2 秒,而运行时 Tailwind 花费了 1.4 秒。从性能分析中,我们可以看出一些关键的时间消耗点:

和我以前的帖子一样,火焰图的 x 轴不显示“事件发生时”的时间,而是每个调用栈的累积时间,这里合并在一起。这使得一目了然地看到问题区域变得更加容易。我使用 SpeedScope 来可视化 CPU 分析。

有一个块用于提取可能的解析候选项,一个块用于配置和插件初始化,CSS 生成,一些 PostCSS 的东西,而一旦涉及到 PostCSSautoprefixer 通常也会被提到,因为它们经常一起使用。值得注意的是,加载autoprefixer而不执行任何操作似乎已经消耗了相当多的时间。

换个思路

Tailwind CSS代码库中走一遍,查看性能分析,确实有一些地方可以进一步优化。但如果我们这样做,只能得到一些个位数的百分比改进。在以前的帖子中,性能分析中通常会有一些明显的东西,但在这里,没有明显的迹象表明时间花费在哪里,我们该怎么办?

实现多因素加速的秘诀,而不仅仅是低百分比的提升,很少涉及应用通用规则或习惯,比如“不要在 for 循环中创建闭包”。有一个常见的误解是,如果你遵循所有这些“最佳实践”,你的代码将变得很快,因为在大多数情况下(并非所有情况),不太重要。使代码真正快速的是意识到它应该解决的问题,然后采取最短的路径来实现这个目标。

所以作为一个挑战,我觉得查看如果我们从头开始以性能为重心构建 Tailwind 代码的体系结构会很有趣。我们会做出不同的决策吗?但为了找到一个最优的体系结构,我们需要知道 Tailwind 解决的问题,并考虑实现这一目标的最短路径。

Tailwind CSS工作原理

在核心层面上,Tailwind CSS 的工作方式是,你向它传递一些 CSS 文件,它会查找这些文件中的@tailwind规则。如果它遇到这样的规则,它将遍历你项目中的其他文件,查找 tailwind 类名,并将其插入到找到 @tailwind 规则的 CSS 文件中。这还有一些其他方面,但为了简化起见,本文将暂时忽略其他 at-rules

1
2
3
4
5
6
7
8
/* Input */
@tailwind base;
@tailwind components;
@tailwind utilities;

.foo {
color: red;
}

转换

1
2
3
4
5
6
7
8
9
10
11
.border {
border-width: 1px;
}
.border-2 {
border-width: 2px;
}

/* …etc */
.foo {
color: red;
}

基于此,我们可以确定 Tailwind CSS 内部工作的几个阶段:

  • 扫描 .css 文件以查找 @tailwind 规则
  • 根据用户在 Tailwind 配置中提供的 glob 模式,找到要从中提取tailwind类名的所有文件
  • 一旦找到这些文件,提取潜在的tailwind类名
  • 解析潜在的tailwind类名以检查它们是否真的是 tailwind 类名。如果是,从中生成一些 CSS
  • 用生成的 CSS 替换原始css文件中的 @tailwind 规则

    Optimizing优化

    因为只有三个有效的 @tailwind 规则值,我们可以通过使用基本的正则表达式绕过整个PostCSS解析步骤:
    1
    /@tailwind\s+(base|components|utilities)(?:;|$)/gm;
    使用这个正则表达式,在所有的CSS文件中找到 @tailwind 规则及其位置基本上是免费的,因为它只需大约0.02毫秒。与 Tailwind CSS 花费的总时间 3.2 秒相比,这个时间几乎不重要。当根据用户指定的 glob 模式找到所有文件时,我们不能做太多会影响总时间的事情,因为我们无论如何都需要访问文件系统,而且我们受到运行时提供的readFile函数的限制。

然而,一旦这些文件被读取,我们需要提取潜在的 Tailwind 类名候选项时,就有很多事情可以做。但问题在于:我们如何检测什么是Tailwind类名,什么不是呢?表面上听起来可能很简单,但实际上并不那么容易。问题在于没有任何标记或其他指示说明一系列字符是一个有效的 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 中大约需要388毫秒。

我在本地对 Tailwind CSS 进行了补丁,以显示有关提取器提取出的值的一些统计信息。

  • 已解析的文件:454
  • 候选字符串:26466

但更有趣的是看一下提取代码提取出的最常见的前 10 个值:

1
2
3
4
5
6
7
8
9
10
- 9774x ''
- 2634x </div>
- 1858x }
- 1692x ```
- 1065x },
- 820x ---
- 694x ```html
- 385x {
- 363x >
- 345x </p>

换句话说:在 26466 个匹配的字符串中,有19630个明显是无效的Tailwind类名。公平地说,Tailwind CSS 采用了一些缓存机制来缓解检查某些内容是否是误报的问题。并且已经有一条代码注释说,改进正则表达式可以使 Tailwind CSS 的速度提高多达 30%

到处用正则

使用正则表达式的好处和坏处在于它不具备语言感知能力。它不知道我们是在处理.js还是 .html 文件,更糟糕的是语言可能会嵌套在彼此之间。一个 .html 文件可以同时包含 HTMLJavaScript CSS。对于 .jsx 文件也是如此。在处理 JavaScript 代码时,我们可以假设只需查看字符串。

经过一次简单粗暴的正则表达式后,我们将搜索空间从 26466 减少到 9633 个候选项。虽然仍然不是最优的,但比起一开始的情况要好得多。许多提取的字符串现在更像是潜在的 Tailwind 候选项:

1
2
3
4
5
6
7
ruby
Copy code
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开头

虽然所有这些匹配都会花费一些时间,并将总提取时间增加到 92 毫秒,但在努力减小搜索空间的所有努力之后,我们仍然有大约 8000 个潜在的 Tailwind 类名(请记住,先前提取的字符串可能包含多个候选项)。

到目前为止,我们取得了相当可观的收益。我们将 Tailwind 的初始提取时间从 388 毫秒减少到 98 毫秒。这大致是 4 倍的提升。

将类名转换为CSS

在这个阶段,我们还没有生成任何 CSS 规则。我们仍然需要一些规则来替换原始CSS文件中的 @tailwindcss 规则。但是现在我们已经可以使用潜在的 Tailwind 类名列表来执行这一步。其中很多可能是误报,因此如果我们检测到一个类名不生成 CSS,我们需要确保我们能够尽快退出。

第一步是解析前面的变体(如果有的话)。请记住,变体可以通过冒号:字符来检测。变体的一个关键方面是它们仅影响选择器,如果存在的话,可能还影响周围的媒体查询。它们不用于生成CSS属性本身。解析变体有点繁琐,没有什么特别的。如果我们检测到一个所谓的变体不存在,我们可以提前退出。

比变体更有趣的是规则生成方面。大多数 Tailwind 类名不包含变体。由于 Tailwind 反映了许多CSS属性,我们需要匹配的潜在匹配数量相当大。我尝试过各种方法,比如一开始就匹配所有静态的 Tailwind 类名,将所有内容放入一个带有用作虚拟函数表的方法的对象中,等等。但最后最快且我感觉最容易维护的方法是一个巨大的简单 switch 语句。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
}
}

这可能看起来像是相当标准的解析器代码,但其中有一些有趣的方面。显而易见的是,每一步我们都检查是否仍然在有效的路径上。这增加了许多额外的检查,但我发现这些检查的成本可以通过能够更早退出来得到补偿。在之前的一些迭代中,我在提取部分犯了一个错误,最终给这个解析函数提供了太多已知的错误正例字符串。但由于解析函数对无效的类名能够快速退出,我花了一段时间才注意到这一点,因为整体上仍然很快。

值得注意的是,parse() 函数传递的hasNegativePrefix参数。许多基于数字的属性,比如 padding,可以通过在类名前缀加上减号 - 来接收负值。

1
2
"pl-2"; // -> padding-left: 0.5rem;
"-pl-2"; // -> padding-left: -0.5rem;

这个负号字符在传递给 parse() 函数之前被剥离,以便我们可以重用同一case分支来处理正常和负值的情况。这里没有显示,但解析器还支持任意值、important 声明、带有不透明度的颜色值等。

尽管我没有实现每一个规则,但所有的语法变化都得到了支持。我确实实现了相当比例的规则,大约有 126 条。这大致上占据了tailwind语法的 80%。尽管这主要是一个原型,但我想更好地了解解析器如何扩展。

有了生成的规则,我们现在终于可以替换原始 CSS 文件中的 @tailwind 规则。如果我们想要支持源映射,我们可以使用 Magic String

一切就绪后,以下是最终的测量结果:

  • 提取:98毫秒
  • 解析:21毫秒
  • 总时间:192毫秒(包括运行时启动时间)

整个项目由5个文件(不包括测试)组成,总共接近3000行代码。

Rust 呢?

我们这个小项目之所以比原始的 Tailwind CSS CLI 更快,是因为我们完全绕过了使用 PostCSS 进行任何解析的过程,而是专注于尽可能快地生成 CSS 规则。Tailwind 团队目前正在使用 Rust 重写 Tailwind CSS,据我所了解,他们已经进展很远。我没有相关的数据,因为它还没有发布。像任何被重写为 RustJavaScript 工具一样,仍需解决的问题是它们的插件支持情况。Tailwind 支持在配置中定义自定义变体或完整规则。一旦发布,比较这两者将会很有趣。

结论

这是一次有趣的小探索,看看专为性能调整的 Tailwind CSS 的架构会是什么样子。坦率地说,与之前的文章相比,这篇文章花费的时间更长,因为其中涉及了大量的原型制作,以达到我满意的结果。无论 Tailwind CSS 团队决定做什么,我都非常期待。

对我来说,Tailwind CSS CSS jQuery。不是每个人都喜欢它,但它对网络行业产生的积极影响是不可否认的。它使全新一代的开发人员能够涉足Web开发。

我真的很感激他们所努力做的事情,因为它反映了我自己成为开发人员的经历。当我开始进行Web开发时,jQuery 正值鼎盛时期,没有它我可能永远不会碰 JavaScript。直到我从事职业生涯的头两年,我才对JavaScript本身产生兴趣并学习了基础知识。对于今天的开发人员来说,Tailwind CSS 正是在 CSS 方面实现这一目标。

即使他们的编译器可能运行得更快,我仍然很高兴它存在。

原文:https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-8/