查看原文
其他

译:加速 JavaScript 生态系统 - Tailwind CSS

云谦 云谦和他的朋友们 2024-04-02

原文:Speeding up the JavaScript ecosystem - Tailwind CSS
作者:Marvin Hagemeister
译者:Deepl
校对:云谦

📖 TL;DR:自问世以来,Tailwind CSS 已成为一种超级流行的网页项目样式设计方式。这一次,我们将了解为其提供动力的架构,以及如何对其进行改进。

诚然,目前我手头还没有使用 Tailwind CSS 编写的更大的项目。那些使用 Tailwind 的项目范围太小,无法进行有意义的性能分析。因此,我认为还有什么比在自己的 tailwindcss.com 网站上对 Tailwind 进行剖析更好的方法呢?不过一开始我就遇到了一个问题:该项目是使用 Next.js 构建的,因此很难获得有意义的跟踪数据。更重要的是,这些痕迹包含了太多与 TailwindCSS 完全无关的噪音。

因此,我决定使用相同的配置在该项目上运行 Tailwind CLI,以获得一些性能跟踪。运行 CLI 构建总共耗时 3.2 秒,而在 Tailwind 中的运行时间为 1.4 秒。这些数据来自我个人的 MacBook M1 Air。通过查看配置文件,我们可以发现一些关键的耗时区域:



和我的文章一样,火焰图的 x 轴并不显示 "发生时" 的时间,而是将每个调用栈的累计时间合并在一起。这样就更容易一眼看出问题所在。我正在使用 SpeedScope 将 CPU 配置文件可视化。

有一个区块负责提取潜在的候选代码进行解析,还有一个区块负责配置和插件初始化、CSS 生成以及一些 PostCSS 的工作。值得注意的是,在不做任何事情的情况下加载 autoprefixer 似乎已经消耗了不少时间。

改变现状

通过查看 Tailwind CSS 代码库和配置文件,可以发现有些地方的功能可以进一步优化。但如果我们这样做了,也只能得到几个个位数百分比的改进。在之前的文章中,我们通常会在配置文件中发现一些显而易见的地方,但在这里,我们该怎么做呢?

实现多因素提速而不仅仅是低百分比提速的秘诀,并不在于应用 "不要在 for 循环内创建闭包 "之类的通用规则或习惯。一个常见的误解是,如果您遵循了所有这些 "最佳实践",您的代码就会变得很快,因为在大多数情况下(并非所有情况),令人不安的事实是,这并不重要。真正让代码变得快速的,是意识到它应该解决什么问题,然后采取最短的路径来实现这一目标。

因此,作为一项挑战,我认为如果我们从头开始构建 Tailwind 代码,并将其性能考虑在内,那么看看 Tailwind 的代码架构会是什么样子会很有趣。我们会做出不同的决定吗?但是,为了找到最佳架构,我们需要知道 Tailwind 要解决什么问题,并思考实现这一目标的最短路径。

小小剧透:



Tailwind CSS 如何工作

Tailwind CSS 的核心工作原理是,你将一些 CSS 文件传给它,它就会在这些文件中寻找 @tailwind 规则。如果遇到这样的规则,它就会抓取项目中的其他文件,查找 tailwind 类名,然后将其注入到找到 @tailwind 规则的 CSS 文件中。还有其他一些方面,但为了本文的简洁起见,我们暂时不考虑其他规则。

/* Input */
@tailwind base;
@tailwind components;
@tailwind utilities;

.foo {
color: red;
}

会转换为,

.border {
border-width: 1px;
}
.border-2 {
border-width: 2px;
}

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

据此,我们可以确定 Tailwind CSS 内部运作的几个阶段:

1、扫描 .css 文件以查找 @tailwind 规则
2、根据用户在 tailwind 配置中提供的 glob 模式,查找要提取 tailwind 类名的所有文件
3、找到这些文件后,提取潜在的 tailwind 类名
4、Parse potential tailwind class names to check if they are really a tailwind class name. If they are, generate some CSS from that
5、解析潜在的 tailwind 类名,检查它们是否真的是 tailwind 类名。如果是,则从中生成一些 CSS
6、用生成的 CSS 替换原始 CSS 文件中的 @tailwind 规则

优化提取阶段

由于只有三个有效的 @tailwind 规则值,因此我们可以通过使用基本的 regex 来绕过整个 PostCSS 解析步骤:

/@tailwind\s+(base|components|utilities)(?:;|$)/gm;

有了这个 regex,查找 @tailwind 规则及其在所有 CSS 文件中的位置基本上是免费的,只需 0.02 毫秒。与 Tailwind CSS 所耗费的 3.2 秒相比,这点时间几乎不值一提。说到根据用户指定的 glob 模式查找所有文件,我们能做的事情并不多,因为我们无论如何都需要访问文件系统,而且我们受到运行时提供的 readFile 函数的限制,所以这不会影响总时间。

不过,一旦读取了这些文件,我们需要提取潜在的 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

But what’s much more interesting is looking at the most common top 10 values that the extraction code pulled out:

但更有趣的是,提取代码最常见的前 10 个值:

- 9774x ''
- 2634x </div>
- 1858x }
- 1692x ```
- 1065x },
- 820x ---
- 694x ```html
- 385x {
- 363x >
- 345x </p>

换句话说在 26466 个匹配字符串中,有 19630 个显然是无效的 tailwind 类名。公平地说,尾风 CSS 有一些缓存功能,可以减轻检查误报与否的工作。而且已经有代码注释指出,对他们的 regex 进行任何改进,都能将 Tailwind CSS 的速度提高 30%。

用正则处理所有内容

在这里使用 regex,既是福也是祸,因为它无法感知语言。它不知道我们是在操作 .js 文件还是 .html 文件,更糟糕的是,语言之间可以相互嵌入。一个 .html 文件可以同时包含 HTML、JavaScript 和 CSS。.jsx 文件中的 JSX 也是如此。说到 JavaScript 代码,我们可以认为我们只需要查看字符串。

通过快速和肮脏的 regex,我们将搜索空间从 26466 个候选字减少到 9633 个。虽然仍未达到最佳状态,但已经比开始时好多了。现在,很多提取的字符串都更像潜在的尾风候选字符串:

  • relative not-prose [a:not(:first-child)>&]:mt-12

  • none

  • break-after

  • grid-template-rows

每个提取的字符串都包含一个或多个潜在候选字符串。我们可以在每个提取的字符串上执行另一个 regex,找出可能是有效尾风类名称的部分,从而进一步缩小搜索空间。幸运的是,有效 tailwind 类名的语法遵循相当简单的规则:

  • 不允许空格

  • 变量必须以冒号 : 结束

  • 任意值用包围括号 \[foo\] 定义,它们必须位于类名的末尾

  • 变量也可以是任意值:[&>.foo]:border-2,仍不得包含空格

  • 除括号内的值外,其他任何内容都必须只包含数字、字母字符或减号。我不确定是否允许使用下划线,但我猜可以是用户定义的尾风类名称

  • 有效的尾风类名称必须以[-!a-z0-9开头

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

到目前为止,我们取得了相当值得称赞的成果。我们将提取时间从 Tailwind 最初的 388 毫秒缩短到了 98 毫秒。这大约是原来的 4 倍。

将类名转化为 CSS

在这个阶段,我们还没有生成任何 CSS 规则。我们还需要替换一些规则,以取代我们开始使用的原始 CSS 文件中的 @tailwindcss 规则。不过,我们现在可以利用潜在的 tailwind 类名列表来实现这一目标。其中有很多可能是误报,因此我们需要确保在检测到某个类名无法渲染 CSS 时,能够尽快退出。

第一步是解析前面的变体(如果有的话)。请记住,可以通过尾部的冒号:字符来检测变体。变体的一个重要方面是,它们只会影响选择器,如果存在,还可能影响周围的媒体查询。它们本身并不用于生成 CSS 属性。解析变体只是一些粗活,并无特别之处。如果我们检测到假定的变体不存在,就可以提前退出。

比变体更有趣的是规则生成方面。大多数尾风类名称都没有变体。由于 tailwind 映射了大量 CSS 属性,我们需要进行的潜在匹配数量相当大。我曾尝试过多种方法,例如在前面匹配所有静态尾风类名,将所有内容放在一个对象中,并使用虚拟函数表等方法。但最终,我觉得最快也最容易维护的方法是使用一个巨大的哑开关语句。

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)都可以通过在类名前添加减号 - 字符来接收负值。

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

在将其传递给 parse() 函数之前,前导减号字符会被去掉,这样我们就可以在正常情况和负值情况下重复使用相同的情况分支。这里没有显示,但解析器还支持任意值、重要声明、带不透明度的颜色值等。

虽然我没有实现每一条规则,但所有的语法变化都得到了支持。不过我还是实现了相当一部分规则,大约有 126 条。这大约占了尾风语法的 80%。尽管这主要是一个原型,但我还是想更好地了解解析器的规模。

有了生成的规则,我们现在终于可以替换原始 CSS 文件中的 @tailwind 规则了。如果我们希望它能感知源映射,可以使用 Magic String

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

  • Extract: 98ms

  • Parse: 21ms

  • Total time: 192ms (including runtime startup time)

整个项目包含 5 个文件(不包括测试),代码行数接近 3000 行。

Rust 如何?

我们的小项目之所以比 Tailwind CSS cli 更快,是因为我们完全避免了使用 PostCSS 解析任何内容,而是专注于尽可能快地生成 CSS 规则。Tailwind 团队目前正在用 Rust 重写 Tailwind CSS,据我所知,他们已经取得了很大进展。由于尚未发布,我没有这方面的数据。与任何正在重写为 Rust 的 JavaScript 工具一样,有待解决的问题是他们的插件故事会是什么样子。Tailwind 确实支持在配置中定义自定义变体或完整规则。一旦发布,对两者进行比较将非常有趣。

结论

这是一个有趣的小探索,看看 Tailwind CSS 的性能调整架构会是什么样子。不可否认,这篇文章花了我比以往更多的时间来撰写,因为其中涉及到了大量的原型设计工作,最终才得到了我满意的结果。无论 Tailwind CSS 团队决定做什么,我都非常期待。

对我来说,Tailwind CSS 就是 CSS 中的 jQuery。虽然不是每个人都喜欢它,但它对网络行业的积极影响是不可否认的。它让全新一代的开发者得以进入网络开发领域。

我非常欣赏他们的努力,因为这与我自己成为开发人员的道路不谋而合。我刚开始接触网络开发时,jQuery正处于巅峰时期,如果没有它,我根本不会接触JavaScript。直到工作两年后,我才对JavaScript本身产生了兴趣,并学习了基础知识。在 CSS 方面,Tailwind CSS 正是在为当今的开发人员做这些事情。

我真的很高兴他们的存在,即使他们的编译器可以更快一些。


继续滑动看下一个
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存