通过避免不必要的类型转换或避免在函数内部创建函数,可以加速大多数流行的库。
尽管当前的趋势似乎是使用其他语言(如Rust或Go)重新编写每个JavaScript构建工具,但当前基于JavaScript的工具可能可以更快。在典型的前端项目中,构建流水线通常由许多不同的工具组成。但是工具的多样化使得对于维护工具的人来说更难发现性能问题,因为他们需要知道它们自己的工具经常与哪些工具一起使用。
尽管从纯语言的角度来看,JavaScript的速度确实比Rust或Go慢,但当前的JavaScript工具可以得到相当大的改进。当然,JavaScript速度较慢,但与今天相比不应该如此缓慢。JIT引擎如今非常快!
好奇心引导我花费一些时间对常见的基于JavaScript的工具进行性能分析,以了解花费所有时间的地方。让我们从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 方法的对象。它从未是一个字符串,因此我们知道我们总是要支付一些序列化成本,以便测试正则表达式。根据经验,我知道对许多短字符串进行正则表达式匹配比对少量长字符串进行匹配要慢得多。这是一个等待优化的主要候选项!
这段代码令人不安的一点是,每个输入文件都必须支付这个成本,无论它是否包含 postcss 注释。通过知道在长字符串上运行一个正则表达式比在短字符串上重复运行正则表达式和序列化成本要便宜,我们可以保护此函数,以避免在我们知道文件不包含任何 postcss
注释时甚至不必调用 isBlockIgnored
。
应用了修复后,构建时间大幅减少了4.6秒!
优化 SVG 压缩时间
接下来是 SVGO,一个用于压缩 SVG 文件的库。对于具有大量 SVG 图标的项目来说,它非常强大且不可或缺。CPU 配置文件显示,有3.1秒的时间用于压缩 SVG 文件。我们能加速这个过程吗?
在分析数据时,有一个函数引起了我的注意:strongRound
。更有趣的是,该函数总是紧接着一点垃圾回收清理(见小红框)。
考虑到我的好奇心被激发了!让我们在 GitHub 上查看源代码:
/**
* Decrease accuracy of floating-point numbers
* in path data keeping a specified number of decimals.
* Smart rounds values like 2.3491 to 2.35 instead of 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 文件中都会有很多数字。该函数接收一个数字数组,并且预期会改变其条目。让我们看一下其实现中使用的变量类型。经过仔细检查,我们注意到在字符串和数字之间存在大量的来回转换。
function strongRound(data: number[]) {
for (var i = data.length; i-- > 0; ) {
// Comparison between string and number -> string is cast to number
if (data[i].toFixed(precision) != data[i]) {
// Creating a string from a number that's casted immediately
// back to a number
var rounded = +data[i].toFixed(precision - 1);
data[i] =
// Another number that is casted to a string and directly back
// to a number again
+Math.abs(rounded - data[i]).toFixed(precision + 1) >= error
? // This is the same value as in the if-condition before,
// just casted to a number again
+data[i].toFixed(precision)
: rounded;
}
}
return data;
}
舍入数字的操作似乎是可以仅通过一些数学运算来完成的,而无需将数字转换为字符串。一般来说,优化的一个很好的经验法则是通过数字表达事物,主要原因是 CPU 在处理数字方面非常出色。通过一些小的更改,我们可以确保始终保持在数字领域,从而完全避免字符串转换。
/ Does the same as `Number.prototype.toFixed` but without casting
// the return value to a string.
function toFixed(num, precision) {
const pow = 10 ** precision;
return Math.round(num * pow) / pow;
}
// Rewritten to get rid of all the string casting and call our own
// toFixed() function instead.
function strongRound(data: number[]) {
for (let i = data.length; i-- > 0; ) {
const fixed = toFixed(data[i], precision);
// Look ma, we can now use a strict equality comparison!
if (fixed !== data[i]) {
const rounded = toFixed(data[i], precision - 1);
data[i] =
toFixed(Math.abs(rounded - data[i]), precision + 1) >= error
? fixed // We can now reuse the earlier value here
: rounded;
}
}
return data;
}
再次运行性能分析确认,我们成功地将构建时间提速约1.4秒!我也为此向上游提交了一个PR。
正则表达式的应用
在 strongRound 附近,另一个函数看起来可疑,因为它需要将近一秒钟(0.9秒)的时间才能完成。
与 stringRound
类似,这个函数也用于压缩数字,但有一个额外的技巧,即如果数字具有小数点并且小于 1 且大于 -1,我们可以删除前导零。因此,0.5 可以被压缩为 .5,-0.2 可以被压缩为 -.2。特别是最后一行看起来很有趣。
const stringifyNumber = (number: number, precision: number) => {
// ...snip
// remove zero whole from decimal number
return number.toString().replace(/^0\./, ".").replace(/^-0\./, "-.");
};
在这里,我们将数字转换为字符串并对其调用正则表达式。很可能数字的字符串版本将是一个短字符串。而且我们知道一个数字不能同时满足 n > 0 && n < 1
和 n > -1 && n < 0
。即使是 NaN
也没有这个能力!从中我们可以推断,只有一个正则表达式匹配,或者两者都不匹配,但从不会同时匹配。至少其中一个 .replace 调用总是多余的。
我们可以通过手动区分这些情况来进行优化。只有当我们知道我们正在处理具有前导零的数字时,我们才应用替换逻辑。这些数字检查比进行正则表达式搜索要快。
const stringifyNumber = (number: number, precision: number) => {
// ...snip
// remove zero whole from decimal number
const strNum = number.toString();
// Use simple number checks
if (0 < num && num < 1) {
return strNum.replace(/^0\./, ".");
} else if (-1 < num && num < 0) {
return strNum.replace(/^-0\./, "-.");
}
return strNum;
};
我们可以更进一步,摆脱对正则表达式搜索的依赖,因为我们确切地知道字符串中前导 0 的位置,因此可以直接操作字符串。
const stringifyNumber = (number: number, precision: number) => {
// ...snip
// remove zero whole from decimal number
const strNum = number.toString();
if (0 < num && num < 1) {
// Plain string processing is all we need
return strNum.slice(1);
} else if (-1 < num && num < 0) {
// Plain string processing is all we need
return "-" + strNum.slice(2);
}
return strNum;
};
由于svgo的代码库中已经有一个单独的函数来去除前导0,我们可以利用它。又节省了0.9秒!Upstream PR.
内联函数、内联缓存和递归
有一种函数名为 monkeys 的函数引起了我的兴趣。在跟踪中,我可以看到它被自身多次调用,这是递归发生的强烈指标。它通常用于遍历类似树状结构的数据。每当使用某种遍历时,就有可能它在代码的“热”路径上。这对于每种情况都不成立,但在我的经验中,这是一个很好的经验法则。
function perItem(data, info, plugin, params, reverse) {
function monkeys(items) {
items.children = items.children.filter(function (item) {
// reverse pass
if (reverse && item.children) {
monkeys(item);
}
// main filter
let kept = true;
if (plugin.active) {
kept = plugin.fn(item, params, info) !== false;
}
// direct pass
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) {
// reverse pass
if (reverse && item.children) {
perItem(item, info, plugin, params, reverse);
}
// main filter
let kept = true;
if (plugin.active) {
kept = plugin.fn(item, params, info) !== false;
}
// direct pass
if (!reverse && item.children) {
perItem(item, info, plugin, params, reverse);
}
return kept;
});
return items;
}
我们可以通过始终明确传递所有参数而不是像以前那样通过闭包捕获它们来摆脱内部函数。这种变化的影响相当小,但总体上又节省了0.8秒。
幸运的是,这在新的3.0.0主要版本中已经得到解决,但直到生态系统切换到新版本为止,这需要一些时间。
小心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.9 秒。
semver 的奇怪情况
对于这个问题,我不确定是否配置错了什么。基本上,性能分析显示每次转译文件时,babel 配置都会被重新读取。
这是一个比较困难的问题,但在屏幕截图中很难看出,占用大量时间的其中一个函数来自 semver 包,这是 npm 的 CLI 中使用的相同包。嗯?semver 与 babel 有什么关系?直到有一段时间我才恍然大悟:这是用于解析 @babel/preset-env 的 browserlist 目标的。尽管 browserlist 的设置可能看起来很短,但最终它们被扩展到约 290 个单独的目标。
这本身还不足为虑,但在使用验证函数时很容易忽略分配成本。在 babel 的代码库中有点分散,但基本上浏览器目标的版本被转换为 semver 字符串 "10" -> "10.0.0",然后进行验证。其中一些版本号已经符合 semver 格式。这些版本和有时是版本范围相互比较,直到找到我们需要转译的最低公共功能集。这种方法没有问题。
性能问题出现在这里,因为 semver 版本以字符串而不是解析后的 semver 数据类型的形式存储。这意味着每次调用 semver.valid('1.2.3')
都会创建一个新的semver
实例,然后立即销毁它。使用字符串比较 semver
版本时也是如此:semver.lt('1.2.3', '9.8.7')
。这就是为什么我们在跟踪中如此明显地看到 semver
的原因。
通过再次在 node_modules
中本地打补丁,我能够将构建时间再减少 4.7 秒。
结论
在这一点上,我停止了查找,但我认为你可能会在流行的库中找到更多这些小的性能问题。今天我们主要看了一些构建工具,但 UI 组件或其他库通常也有相同的低级性能问题。
这足以匹配 Go 或 Rust 的性能吗?不太可能,但问题是当前的 JavaScript 工具可能比今天更快。而我们在本文中看到的东西或多或少只是冰山一角。
原文:https://marvinh.dev/blog/speeding-up-javascript-ecosystem/
网友评论