JS生态系统加速Tailwind CSS工作原理探究
作者:大家的林语冰 人猫神话
引言
长话短说:自破蛋以来,Tailwind CSS 已成为一种人气爆棚的 Web 项目样式方案。这次我们来瞄一下为其提供支持的架构,以及可以优化的方案。
本期《前端翻译计划》共享的是“加速 JS 生态系统系列博客”,包括但不限于:
PostCSS,SVGO 等等
模块解析
使用 eslint
npm 脚本
draft-js emoji 插件
polyfill 暴走
桶装文件暴走
Tailwind CSS
Tailwind CSS
本期共享的是第 8 篇博客 —— Tailwind CSS 方案。
诚然,我目前手头没有诉诸 Tailwind CSS 编写的大型项目。我那些使用 Tailwind 的项目太小,由此得出的性能分析不具备统计学意义。所以我有一个大胆的想法:用 Tailwind 自己的 tailwindcss.com 官网介绍 Tailwind 简直绝绝子!不过在下出师未捷身先死: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 文件中。它还有其他方方面面,但为了简单起见,我们暂且无视其他规则。
/* 输入 */ @tailwind base; @tailwind components; @tailwind utilities; .foo { color: red; }
这会被转化为:
.border { border-width: 1px; } .border-2 { border-width: 2px; } /* 等等...... */ .foo { color: red; }
基于此机制,我们可以确定 Tailwind CSS 内部流程的若干阶段:
扫描
.css
文件中的@tailwind
规则基于用户 tailwind 配置中提供的 glob 模式,查找所有文件,从中提取 tailwind 类名
一旦找到这些文件,就会提取潜在的 tailwind 类名
解析潜在的 tailwind 类名,检查它们是否是 tailwind 类名。如果是,那就从中生成某些 CSS
将原本 css 文件中的
@tailwind
规则替换为生成的 CSS
优化提取阶段
由于有且仅有三个有效的 @tailwind
规则值,我们可以使用一个基本的正则,绕过整个 PostCSS 解析步骤:
;/@tailwind\s+(base|components|utilities)(?:;|$)/gm
虽然但是,一旦读取了那些文件,且我们需要提取潜在的 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 中,这大约需要 388ms
。
我在本地给 Tailwind CSS 打补丁,显示了某些有关提取器的提取值的统计数据。
已解析文件:
454
候选字符串:
26_466
但更有趣的是,瞄一下提取程序提取最常见的前 10 个值:
- 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、JS 和 CSS。.jsx
文件中的 JSX 同理可得。当涉及 JS 代码时,我们可以假设我们只需查看字符串。
经过简单粗暴的正则处理后,我们将搜索空间从 26_466
减少到 9_633
个候选。这仍不是极致优化,但比我们开始时要更胜一筹。现在,一大坨提取字符串类似于更多潜在的 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
。在努力减少搜索空间后,我们仍剩下大约 8_000
个潜在的 tailwind 类名(粉丝请记住,之前提取的字符串可以包含多个候选)。
目前为止,我们斩获了值得褒奖的成果。我们将提取时间从 Tailwind 的原本 388ms
减少到 98ms
。这大约优化了 4 倍。
类名转 CSS
在这个阶段,我们尚未生成任何 CSS 规则。我们仍需要某些规则,替换起初原始 CSS 文件中的 @tailwindcss
规则。但我们现在可以诉诸潜在的 tailwind 类名列表来实现。其中一大坨可能是“假阳性”,因此我们需要确保,如果我们检测到不渲染 CSS 的类名,我们可以尽快退出。
第一步是解析前面的变体(如果有的话)。粉丝请记住,可以通过 :
尾冒号字符来检测变体。变体的要点之一是,如果变体存在,它们能且仅能影响选择器,且可能影响周围的媒体查询。它们本身不用于生成 CSS 属性。解析变体是一项平平无奇的体力活。如果我们检测到假定的变体不存在,我们就可以提前退出。
比变体更有趣的是规则生成方面。大多数 tailwind 类名没有变体。由于 Tailwind 映射了一大坨 CSS 属性,因此我们需要匹配的潜在数量相当惊人。我尝试了各种方案,比如预先匹配所有静态 tailwind 类名,将所有内容放入一个对象中,该对象的方法类似虚拟函数表。但最终,私以为既敏捷又易维护的方案是,一坨既大又笨的 switch
语句。
function parse(lexer, config, hasNegativePrefix) { const first = lexer.nextSegment() switch (first) { case “aspect”: //... case “block”: if (!lexer.isEnd) return // 退出 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 // 退出 } return `display: inline-${second}` // 剩下的 1000 行类似的代码 } }
这看起来可能是非常标准的解析器代码,但存在某些有趣的东东。显而易见,我们会逐步检查我们是否仍在有效路径上。这增加了一大坨额外检查,但我发现这些成本能够被提前退出的收益抵消。在之前的某些迭代中,我在提取部分犯错了,最终向该 parse
函数“投喂”了一大坨已知的“假阳性”字符串。但是因为 parse
函数很快就在无效的类名及时止损,所以我花了一段时间才注意到,它整体而言仍然很快。
粉丝请注意传递给 parse()
函数的 hasNegativePrefix
参数。一大坨数字筑基的属性(比如 padding
)可以通过在类名前加上 -
减号字符来接收负值。
'pl-2' // -> padding-left: 0.5rem; '-pl-2' // -> padding-left: -0.5rem;
前置减号字符在传递给 parse()
函数之前会被移除,这样我们可以为正常或反常情况重用相同的 case
分支。这里没有显示,但解析器还支持任意值、important
声明、透明度的 color
值等等。
尽管我没有实现所有规则,但所有语法变体都支持。不过,我确实实现了相当一部分规则,大约有 126
条。这大约占 tailwind 语法的 80%。尽管这主要是一个原型,但我想更好地了解解析器如何扩展。
有了生成的规则,我们现在终于可以替换原始 CSS 文件中的 @tailwind
规则了。如果我们希望它能够感知源码映射,那么我们可以使用 Magic String
。
万事俱备后,以下是最终测量结果:
提取:98ms
解析:21ms
总时间:192ms(包括运行时启动时间)
整个项目由 5 个文件组成(不包括测试),代码不足 3_000
行。
Rust 又如何呢?
我们这里的迷你项目比 og Tailwind CSS cli 更快的原因是,我们完全避免了用 PostCSS 解析任何内容,而是聚焦于尽快生成 CSS 规则。Tailwind 团队目前正在用 Rust 重写 Tailwind CSS,据我所知,它们已经取得了很大进展。我没有任何相关数据,因为它尚未发布。就像任何诉诸 Rust 重写的 JS 工具一样,亟待解决的是它们的插件的生态。Tailwind 确实支持在其配置中定义的自定义变体或完整规则。一旦发布,测评两者将会很有趣。
完结撒花
对我而言,Tailwind CSS 是 CSS 中的 jQuery。并不是所有人都喜欢它,但它为网络行业注入正能量毋庸置疑。它使全新一代开发者能够进军 Web 开发领域。
当我入门 Web 开发时,jQuery 正血气方刚,没有它我就永远不会和 JS 贴贴。直到职业生涯两年后,我才真正入坑 JS,并学习了基础知识。在 CSS 方面,Tailwind CSS 正在为当今的开发者做类似的事情。
免责声明
本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 Speeding up the JavaScript ecosystem - Tailwind CSS[1]。
以上就是JS 生态系统加速Tailwind CSS工作原理探究的详细内容,更多关于JS Tailwind CSS的资料请关注脚本之家其它相关文章!