加速JavaScript 生态系统

改变思路

浏览 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-z0-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 支持在配置中自定义变量或完整规则。一旦推出,比较两者性能将很有趣。

相关推荐
Myli_ing21 分钟前
考研倒计时-配色+1
前端·javascript·考研
余道各努力,千里自同风24 分钟前
前端 vue 如何区分开发环境
前端·javascript·vue.js
软件小伟33 分钟前
Vue3+element-plus 实现中英文切换(Vue-i18n组件的使用)
前端·javascript·vue.js
醉の虾1 小时前
Vue3 使用v-for 渲染列表数据后更新
前端·javascript·vue.js
张小小大智慧1 小时前
TypeScript 的发展与基本语法
前端·javascript·typescript
hummhumm1 小时前
第 22 章 - Go语言 测试与基准测试
java·大数据·开发语言·前端·python·golang·log4j
asleep7011 小时前
第8章利用CSS制作导航菜单
前端·css
hummhumm1 小时前
第 28 章 - Go语言 Web 开发入门
java·开发语言·前端·python·sql·golang·前端框架
幼儿园的小霸王2 小时前
通过socket设置版本更新提示
前端·vue.js·webpack·typescript·前端框架·anti-design-vue
疯狂的沙粒2 小时前
对 TypeScript 中高级类型的理解?应该在哪些方面可以更好的使用!
前端·javascript·typescript