JS生态系统加速一次一库PostCSS SVGO的重构源码和性能优化探索
作者:大家的林语冰 人猫神话
引言
长话短说:大多数流行库可以通过避免不必要的类型转换,或避免在函数内创建函数来优化。
本期《前端翻译计划》共享的是“加速 JS 生态系统系列博客”,包括但不限于:
- PostCSS,SVGO 等等
- 模块解析
- 使用 eslint
- npm 脚本
- draft-js emoji 插件
- polyfill 暴走
- 桶装文件崩溃
- Tailwind CSS
本期共享的是第一篇博客 —— 一次一库的重构源码和性能优化。
虽然前端趋势似乎是用 Rust 或 Go 等其他语言重写 JS 构建工具,但目前 JS 筑基的工具可能足够快。典型前端项目中的构建管道通常由一大坨协同工作的不同工具组成。但工具的多样化使得工具维护者难以发现性能瓶颈,因为它们需要知道自己的工具使用了哪些其他工具。
尽管从纯语言的角度来看,JS 肯定比 Rust 或 Go 慢,但目前 JS 筑基的工具还有优化空间。当然,JS 速度较慢,但与现在相比,它不至于太慢。JIT 引擎现在就快得要命!
好奇心引导我费时分析常见的 JS 筑基的工具,了解其性能开销之所在。让我们从 PostCSS 开始,它是一个人气爆棚的 CSS 解析器和转译器。
在 PostCSS 中优化 4.6 秒
有一个神通广大的插件,名为 postcss-custom-properties,它在旧版浏览器中添加了 CSS 自定义属性的基本支持。不知为何,它在调试中非常扎眼,昂贵的 4.6 秒归因于其内部使用的简单正则。这有点奇葩。

正则表达式目测是疑似搜索特定注释值,更改插件行为的东东,类似于 eslint 中用于禁用特定 linting 规则的东东。它们的 README 中没有提及这一点,但偷瞄源码证实了此猜想。
创建正则表达式的位置是检查 CSS 规则或声明前面是否有所述注释的函数的一部分。
function isBlockIgnored(ruleOrDeclaration) {
const rule = ruleOrDeclaration.selector
? ruleOrDeclaration
: ruleOrDeclaration.parent
return /(!\s*)?postcss-custom-properties:\s*off\b/i.test(rule.toString())
}
rule.toString() 调用瞬间引起了我的注意。如果您要解决性能问题,那么将一种类型转换为另一种类型的地方通常值得三思而行,因为转换总会有时间成本。此场景中有趣的是,rule 变量始终持有一个携带自定义 toString 方法的 object。该变量一开始就不是一个字符串,所以我们在这里付出一些序列化开销,才能测试正则表达式。根据个人经验,我知道将正则表达式与一大坨短字符串匹配,比与一小坨长字符串匹配要慢得多。此处有待优化!
这段代码相当麻烦的一点是,无论文件是否有 postcss 注释,每个输入文件都有此开销。知道在长字符串上运行一个正则表达式比在短字符串上运行重复的正则表达式和序列化成本更低,我们可以验证此函数,避免在知道文件不包含任何 postcss 注释时,也调用 isBlockIgnored。
应用修复后,构建时间缩短了 4.6 秒!
优化 SVG 压缩速度
接下来是 SVGO,一个用于压缩 SVG 文件的库。它功能强大,并且是具有大量 SVG 图标的项目的主要工具。CPU 配置文件显示,压缩 SVG 花费了 3.1 秒。我们能提速吗?
在分析数据中搜索一下,有一个函数很突兀:strongRound。更重要的是,该函数之后总是会进行一些 GC 清理。

让我们在 GitHub 上获取源码:
/**
* 降低路径数据中浮点数的精度
* 保持指定数量的小数。
* 智能四舍五入:比如 2.3491 取舍为 2.35 而不是 2.349
*/
function strongRound(data: number[]) {
for (var i = data.length; i-- > 0; ) {
if (data[i].toFixed(precision) != data[i]) {
var rounded = +data[i].toFixed(precision - 1);
data[i] =
+Math.abs(rounded - data[i]).toFixed(precision + 1) >= error
? +data[i].toFixed(precision)
: rounded;
}
}
return data;
}
所以这是一个用于压缩数字的函数,在任何典型的 SVG 文件中都有一大坨类似的函数。该函数接收 numbers 数组,并预计会改变其元素。让我们瞄一下其实现中使用的变量类型。通过仔细检查,我们发现字符串和数字之间存在一大坨来回转换。
function strongRound(data: number[]) {
for (var i = data.length; i-- > 0; ) {
// string 和 number 比较 -> string 转换为 number
if (data[i].toFixed(precision) != data[i]) {
// 基于 number 创建 string,然后立刻转换为 number
var rounded = +data[i].toFixed(precision - 1);
data[i] =
// number 转 string,然后直接转换为 number
+Math.abs(rounded - data[i]).toFixed(precision + 1) >= error
? // 这和之前的 if 条件的值相同
// 只是再次转换为 number
+data[i].toFixed(precision)
: rounded;
}
}
return data;
}
对数字进行四舍五入的操作似乎只需一点点数学就欧了,而无需将数字转换为字符串。作为一般经验法则,大部分优化都是用数字来表达事物,主要原因是 CPU 非常擅长处理数字。通过若干更改,我们可以确保我们始终保持在数字范围内,完全避免字符串转换。
// 类似 Number.prototype.toFixed 的功能
// 但返回值不转换为 string
function toFixed(num, precision) {
const pow = 10 ** precision;
return Math.round(num * pow) / pow;
}
// 重写避免 string 转换
// 调用我们自己的 toFixed() 函数
function strongRound(data: number[]) {
for (let i = data.length; i-- > 0; ) {
const fixed = toFixed(data[i], precision);
// 我们可以使用严格相等比较
if (fixed !== data[i]) {
const rounded = toFixed(data[i], precision - 1);
data[i] =
toFixed(Math.abs(rounded - data[i]), precision + 1) >= error
? fixed // 现在这里我们可以复用之前的值
: rounded;
}
}
return data;
}
再次运行分析证实,我们能够将构建时间加快约 1.4 秒!
短字符串上的正则
在 strongRound 附近的另一个函数看起来很可疑,因为它花费了约 1 秒(0.9 秒)才完成。

与 stringRound 类似,此函数用于压缩数字,但增加了一个技巧,如果数字有小数且小于 1 和大于 -1,我们可以删除前导零。所以 0.5 可以压缩为 .5,-0.2 则是 -.2。特别是最后一行看起来很有趣。
const stringifyNumber = (number: number, precision: number) => {
// 从十进制数中删除零整数
return number.toString().replace(/^0\./, ".").replace(/^-0\./, "-.");
};
在这里,我们将数字转换为字符串,并对其调用正则表达式。该数字的字符串版本很可能是一个短字符串。而且我们知道一个数字不能同时是 n > 0 && n < 1 和 n > -1 && n < 0。连 NaN 无法如此逆天!由此我们可以推断,要么有且仅有一个正则表达式匹配,要么没有正则表达式匹配,但绝不会两者都匹配。至少有一个 .replace 调用总被浪费。
我们可以通过手动区分这些情况来优化它。当且仅当我们正在处理一个具有前导 0 的数字时,我们才应用替换逻辑。这些数字检查比执行正则表达式搜索更快。
const stringifyNumber = (number: number, precision: number) => {
// 从十进制数中删除零整数
const strNum = number.toString();
// 使用简单数字检验
if (0 < num && num < 1) {
return strNum.replace(/^0\./, ".");
} else if (-1 < num && num < 0) {
return strNum.replace(/^-0\./, "-.");
}
return strNum;
};
我们可以更进一步,完全摆脱正则表达式搜索,因为我们 100% 确定前导 0 位于字符串中,因此可以直接操作字符串。
const stringifyNumber = (number: number, precision: number) => {
// 从十进制数中删除零整数
const strNum = number.toString();
if (0 < num && num < 1) {
// 我们只需要简单的字符串处理
return strNum.slice(1);
} else if (-1 < num && num < 0) {
// 我们只需要简单的字符串处理
return "-" + strNum.slice(2);
}
return strNum;
};
由于 svgo 的代码库中已经有一个单独的函数可以移除前导 0,因此我们可以利用它。又节省了 0.9 秒!
内联函数、内联缓存和递归
一个名为 monkeys 的函数只因其名就引起了我的兴趣。在调试中,我可以看到它在其内部被多次调用,这是一个有力证据,表明这里正在发生某种递归。它通常用于遍历树状结构。每当使用某种遍历时,它就有可能在代码的“热路径”中。并非所有情况都如此,但根据个人经验,这是一个很好的经验法则。
function perItem(data, info, plugin, params, reverse) {
function monkeys(items) {
items.children = items.children.filter(function (item) {
// 反向通过
if (reverse && item.children) {
monkeys(item)
}
// 主要过滤
let kept = true
if (plugin.active) {
kept = plugin.fn(item, params, info) !== false
}
// 直接通过
if (!reverse && item.children) {
monkeys(item)
}
return kept
})
return items
}
return monkeys(data)
}
这里我们有一个函数,它在其体内创建另一个函数,该函数再次调用内部函数。如果我不得不盲猜一手,我会假设这样做是为了节省一些敲键次数,而不必再次传递所有参数。问题是,当外部函数被频繁调用时,在其他函数内部创建的函数很难优化。
function perItem(items, info, plugin, params, reverse) {
items.children = items.children.filter(function (item) {
// 反向通过
if (reverse && item.children) {
perItem(item, info, plugin, params, reverse)
}
// 主要过滤
let kept = true
if (plugin.active) {
kept = plugin.fn(item, params, info) !== false
}
// 直接通过
if (!reverse && item.children) {
perItem(item, info, plugin, params, reverse)
}
return kept
})
return items
}
我们可以通过始终显式传递所有参数,而不是像以前一样通过闭包捕获它们,从而摆脱内部函数。此变更的影响相当小,但总共又节省了 0.8 秒。
小心 for..of 的转译
@vanilla-extract/css 中出现了几乎相同的问题。已发布的软件包附带以下代码:
class ConditionalRuleset {
getSortedRuleset() {
//...
var _loop = function _loop(query, dependents) {
doSomething()
}
for (var [query, dependents] of this.precedenceLookup.entries()) {
_loop(query, dependents)
}
//...
}
}
这个函数的有趣之处在于,它没有出现在原始源码中。在原始源码中,它是一个标准的 for...of 循环。
class ConditionalRuleset {
getSortedRuleset() {
//...
for (var [query, dependents] of this.precedenceLookup.entries()) {
doSomething()
}
//...
}
}
我无法在 babel 或 typescript 的 repl 中复现此问题,但我可以确定它是由它们的构建管道引入的。鉴于这似乎是构建工具的共享抽象,我假设还有更多项目受到此影响。因此,现在我只是在 node_modules 内部本地修补了该包,并且很高兴看到,这缩短了 0.9s 的构建时间。
semver 的奇怪案例
对于此例子,我不确定我是否配置错误。本质上,该配置文件表明,每当转译文件时,整个 babel 配置总是会被重新读取。

在屏幕截图中很难看出,但占用大量时间的函数之一是 semver 包中的代码,该包与 npm 的 cli 中使用的包相同。我花了一段时间才明白:它是用于解析 @babel/preset-env 的 browserlist 目标。尽管浏览器列表设置可能看起来很短,但最终它们扩展到大约 290 个单独的目标。
仅这一点还不足以引起关注,但在使用验证函数时很容易忽略分配成本。它在 babel 的代码库中有点分散,但本质上浏览器目标的版本被转换为 semver 字符串 "10" -> "10.0.0",然后进行验证。其中一些版本号已经与 semver 格式匹配。这些版本(有时是版本范围)会相互比较,直到找到需要转译的最低通用功能集。此方案问题不大。
这里出现了性能问题,因为 semver 版本存储为 string,而不是解析的 semver 数据类型。这意味着,每次调用 semver.valid('1.2.3') 都会创建一个新的 semver 实例,并立即销毁它。使用字符串 semver.lt('1.2.3', '9.8.7') 比较 semver 版本时也是如此。这就是为什么我们在调试中看到如此明显的 semver。
完美谢幕
我假设您会在流行库中发现更多这些性能细节瓶颈。今天我们主要关注若干构建工具,但 UI 组件或其他库通常也有同样容易出现的性能问题。
免责声明
本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 Speeding up the JavaScript ecosystem - one library at a time
以上就是JS生态系统加速一次一库PostCSS SVGO的重构源码和性能优化探索的详细内容,更多关于JS PostCSS SVGO的资料请关注脚本之家其它相关文章!
