有什么比在 Tailwindcss.com
网站上对 Tailwind
进行性能分析更好的方法呢!然而,在开始的时候,我遇到了一个问题:该项目是用 Next.js
构建的,这使得获取有意义的跟踪信息变得非常困难。更重要的是,这些跟踪信息包含了与 TailwindCSS
完全无关的太多噪音。
相反,我决定使用相同的配置在项目上运行 Tailwind CLI
以获取一些性能跟踪信息。运行 CLI
构建总共需要 3.2
秒,而运行时 Tailwind
花费了 1.4 秒。从性能分析中,我们可以看出一些关键的时间消耗点:
和我以前的帖子一样,火焰图的 x 轴不显示“事件发生时”的时间,而是每个调用栈的累积时间,这里合并在一起。这使得一目了然地看到问题区域变得更加容易。我使用 SpeedScope
来可视化 CPU
分析。
有一个块用于提取可能的解析候选项,一个块用于配置和插件初始化,CSS 生
成,一些 PostCSS
的东西,而一旦涉及到 PostCSS
,autoprefixer
通常也会被提到,因为它们经常一起使用。值得注意的是,加载autoprefixer
而不执行任何操作似乎已经消耗了相当多的时间。
换个思路
从Tailwind CSS
代码库中走一遍,查看性能分析,确实有一些地方可以进一步优化。但如果我们这样做,只能得到一些个位数的百分比改进。在以前的帖子中,性能分析中通常会有一些明显的东西,但在这里,没有明显的迹象表明时间花费在哪里,我们该怎么办?
实现多因素加速的秘诀,而不仅仅是低百分比的提升,很少涉及应用通用规则或习惯,比如“不要在 for 循环中创建闭包”。有一个常见的误解是,如果你遵循所有这些“最佳实践”,你的代码将变得很快,因为在大多数情况下(并非所有情况),不太重要。使代码真正快速的是意识到它应该解决的问题,然后采取最短的路径来实现这个目标。
所以作为一个挑战,我觉得查看如果我们从头开始以性能为重心构建 Tailwind
代码的体系结构会很有趣。我们会做出不同的决策吗?但为了找到一个最优的体系结构,我们需要知道 Tailwind
解决的问题,并考虑实现这一目标的最短路径。
Tailwind CSS工作原理
在核心层面上,Tailwind CSS
的工作方式是,你向它传递一些 CSS
文件,它会查找这些文件中的@tailwind
规则。如果它遇到这样的规则,它将遍历你项目中的其他文件,查找 tailwind 类名,并将其插入到找到 @tailwind
规则的 CSS
文件中。这还有一些其他方面,但为了简化起见,本文将暂时忽略其他 at-rules
。
1 | /* Input */ |
转换
1 | .border { |
基于此,我们可以确定 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 | - 9774x '' |
换句话说:在 26466
个匹配的字符串中,有19630
个明显是无效的Tailwind
类名。公平地说,Tailwind CSS
采用了一些缓存机制来缓解检查某些内容是否是误报的问题。并且已经有一条代码注释说,改进正则表达式可以使 Tailwind CSS
的速度提高多达 30%
。
到处用正则
使用正则表达式的好处和坏处在于它不具备语言感知能力。它不知道我们是在处理.js
还是 .html
文件,更糟糕的是语言可能会嵌套在彼此之间。一个 .html
文件可以同时包含 HTML
、JavaScript
和 CSS
。对于 .jsx
文件也是如此。在处理 JavaScript
代码时,我们可以假设只需查看字符串。
经过一次简单粗暴的正则表达式后,我们将搜索空间从 26466 减少到 9633 个候选项。虽然仍然不是最优的,但比起一开始的情况要好得多。许多提取的字符串现在更像是潜在的 Tailwind
候选项:
1 | ruby |
每个提取的字符串包含一个或多个潜在的候选项。我们可以通过在每个提取的字符串上再次使用正则表达式,提取可能是有效 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 | function parse(lexer, config, hasNegativePrefix) { |
这可能看起来像是相当标准的解析器代码,但其中有一些有趣的方面。显而易见的是,每一步我们都检查是否仍然在有效的路径上。这增加了许多额外的检查,但我发现这些检查的成本可以通过能够更早退出来得到补偿。在之前的一些迭代中,我在提取部分犯了一个错误,最终给这个解析函数提供了太多已知的错误正例字符串。但由于解析函数对无效的类名能够快速退出,我花了一段时间才注意到这一点,因为整体上仍然很快。
值得注意的是,parse()
函数传递的hasNegativePrefix
参数。许多基于数字的属性,比如 padding
,可以通过在类名前缀加上减号 - 来接收负值。
1 | "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
,据我所了解,他们已经进展很远。我没有相关的数据,因为它还没有发布。像任何被重写为 Rust
的 JavaScript
工具一样,仍需解决的问题是它们的插件支持情况。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/