改变思路
浏览 Tailwind CSS 代码库和性能分析报告后可以确定,确实有一些地方可以进一步优化函数。但即使这样做,提速也只能达到个位数百分比。之前的文章中,性能分析报告通常会有一些明显的 "红旗" 引人关注,但是如果没有指示时间主要消耗在何处,该怎么办?
实现数倍速度提升的秘诀,与遵循 "不要在循环内创建闭包" 等通用规则或习惯没有太大关系。常见的误区是,遵循所有这些 "最佳实践" 就一定能让代码快速运行。但在大多数情况下,这些习惯的实际帮助很少。真正提高代码速度的,是对代码要解决的问题保持清醒认识,然后选择最直接有效的途径实现目标。
我想提出一个挑战:假设我们从零开始构建 Tailwind, 以性能为目标设计其代码架构,会做出哪些不同的设计决策?为了找到最优架构方案,我们首先需要明确 Tailwind 要解决的问题,然后考虑实现该目标的最直接方法。
小剧透:
Tailwind CSS 的工作原理
Tailwind CSS 的核心工作原理是,您传入一些 CSS 文件,它会在这些文件内查找 @tailwind
规则。如果检测到这样的规则,它将遍历项目中的其他文件,查找 Tailwind 类名,然后将对应的样式注入到发现 @tailwind
规则的 CSS 文件中。实际上还有一些其他细节,但为了本文的简洁性,我们暂时忽略其他 at 规则。
less
/* 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 配置中提供的 glob 模式,找到要提取 Tailwind 类名的所有文件
- 提取潜在的 Tailwind 类名
- 解析 Tailwind 类名并生成对应的 CSS
- 用生成的 CSS 替换原始 CSS 文件中的
@tailwind
规则
优化提取阶段
由于只有三个有效的 @tailwind
规则值,我们可以通过正则表达式跳过 PostCSS 解析步骤:
less
/@tailwind\s+(base|components|utilities)(?:;|$)/gm;
通过正则表达式,在所有 CSS 文件中查找 @tailwind 规则只需要约 0.02ms, 可以忽略。当根据用户指定的 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
我们只能尽量减少搜索空间,然后将剩余候选项传入解析器。如果解析器生成 CSS, 则类名有效;如果没有,则无效。这意味着需要优化解析器,在检测到未定义的字符串值时快速退出。
为提醒:此过程 Tailwind CSS 当前约需 388ms。
我在本地修补了 Tailwind CSS 的代码,以显示提取程序提取的值的一些统计数据:
- 已解析文件:454
- 候选字符串:26,466
但更有趣的是查看提取代码提取的前 10 个最常见值:
markdown
- `9774x ''`
- `2634x </div>`
- `1858x }`
- `1692x `
-
1065x },
-
820x ---
-
694x ```html
-
385x {
-
363x >
-
345x </p>
换句话说,在匹配的 26,466 个字符串中,有 19,630 个明显不是有效的 Tailwind 类名。为公平起见,Tailwind CSS 实现了一些缓存机制来减少检查误报的次数。代码注释上已经提到任何对正则表达式的改进都可以将 Tailwind CSS 的速度提高多达 30%。
使用正则表达式匹配
使用正则表达式的优缺点是它不知道编程语言的语法。它不知道正在处理的是 .js
文件还是 .html
文件,更糟的是不同语言可以嵌套。一个 .html
文件可以同时包含 HTML、JavaScript 和 CSS。.jsx
文件也类似。针对 JavaScript 代码,我们只需要检查字符串。
通过一个快速的正则表达式,我们将搜索空间从 26,466 个减少到 9,633 个候选项。仍有改进空间,但比开始时好很多。现在大多数提取的字符串更像是潜在的 Tailwind 类名:
- relative not-prose [a:not(:first-child)>&]:mt-12
- none
- break-after
- grid-template-rows
- ...
每个提取的字符串可能包含一个或多个潜在的 Tailwind 类名。我们可以通过在每个字符串上运行正则表达式进一步减少搜索空间,拉出可能是有效 Tailwind 类名的部分。幸运的是,有效 Tailwind 类名的语法比较简单:
- 不允许包含空格
- 变体必须以冒号
:
结尾 - 任意值用中括号
[]
包裹定义,必须位于类名末尾 - 变体也可以是任意的,如
[&>.foo]:border-2
。仍不能包含空格 - 中括号内的值之外的内容只能包含数字、字母或连字符
- 一个有效的 Tailwind 类名必须以
[
、-
、!
、a-z
或0-9
开头
所有这些匹配需要一些时间,提取时间增加到 92ms。尽管我们努力减少了搜索空间,但仍留下了约 8000 个潜在的 Tailwind 类名。
到目前为止,我们将提取时间从 Tailwind 的原始 388ms 减少到了 98ms, 约为 4 倍的提升。
将类名转化为 CSS
在这一阶段,我们仍未生成任何 CSS 规则,仍需替换原始 CSS 文件中 @tailwindcss
规则。现在我们有了潜在的 Tailwind 类名列表,可以开始生成 CSS。其中大多数可能是误报,如果检测到类名无法渲染 CSS, 我们需要快速退出。
第一步是解析前面的变体 (如果存在)。变体可以通过尾部的冒号 :
字符检测到。变体仅影响选择器,可能影响周围的媒体查询 (如果存在), 不直接用于生成 CSS 属性。解析变体需要大量工作,没有什么特别之处。如果检测到假定的变体不存在,可以尽早退出。
比变体更重要的是规则生成。大多数 Tailwind 类名没有变体。由于 Tailwind 反映许多 CSS 属性,我们需要做大量潜在匹配。我已经尝试过各种方法,最后发现一个大的 switch 语句最快,也最易维护。
arduino
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 参数。许多基于数字的属性 (如填充) 可以通过类名前加一个减号 -
来接收负值。
arduino
"pl-2"; // -> padding-left: 0.5rem;
"-pl-2"; // -> padding-left: -0.5rem;
负号会在传递给 parse()
函数之前被剥离,以便对正常和负数情况重用同一案例分支。这里没有显示,但解析器还支持任意值、important
声明、带不透明度的颜色值等。
虽然没有实现每一条规则,但都支持各种语法变化。不过,我确实实现了大约 126 条规则,约占 Tailwind 语法的 80%。即使这在很大程度上是一个原型,我也想更好地了解解析器的可扩展性。
有了生成的规则,我们可以在原始 CSS 文件中替换 @tailwind 规则。如果要支持源映射,可以使用 Magic String。
以下是最终测量:
- 提取:98ms
- 解析:21ms
- 总时间:192ms (包括启动时间)
整个项目由 5 个文件 (不包括测试) 组成,代码行数略少于 3000。
如果使用 Rust 呢?
我们这里比原始 Tailwind CSS cli 更快的原因是完全避开了 PostCSS 解析,专注于尽可能快地生成 CSS 规则。Tailwind 团队目前正在重写 Tailwind CSS 为 Rust, 据我了解,进度已经相当远。由于还未发布,所以我没有具体速度数据。重写成 Rust 的任何 JavaScript 工具剩下要解决的问题是插件故事会是什么样子。Tailwind 支持在配置中自定义变量或完整规则。一旦推出,比较两者性能将很有趣。