前言
有意思的做法,通过分析流行库然后去发现并解决问题。今日前端早读课文章由 @飘飘翻译分享。
正文从这开始~~
tl;dr: 大多数流行的库可以通过避免不必要的类型转换或避免在函数中创建函数来加快速度。
虽然现在的趋势似乎是用其他语言(如 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*offb/i.test(rule.toString());
}
rule.toString()的调用很快引起了我的注意。如果你要解决性能问题,通常值得再看看那些将一种类型转换为另一种类型的地方,因为不必进行转换总是可以节省时间。在此场景中有趣的是,rule 变量总是持有一个具有自定义 toString 方法的 object。它从一开始就不是一个字符串,所以我们知道我们在这里总是要付出一些序列化的代价,以便能够对搜索规则进行测试。根据经验,我知道将正则表达式匹配到许多短字符串比将其匹配到少数长字符串要慢得多。这是一个等待被优化的主要候选者!
这段代码相当麻烦的一点是,每一个输入文件都要付出这个代价,不管它是否有 postcss 注释。知道在一个长字符串上运行一个正则表达式比在短字符串上重复运行一个正则表达式要省时间,而且序列化成本也低,所以我们可以保护这个函数,如果我们知道这个文件不包含任何 postcss 注释,我们甚至可以避免调用 isBlockIgnored 。
在应用了这个修正后js时间格式转换,构建时间大大减少了 4.6 秒。
优化 SVG 压缩速度
接下来是 SVGO,一个用于压缩 SVG 文件的库。它非常棒,是拥有大量 SVG 图标的项目的主力军。但是,CPU 配置文件显示,在压缩 SVG 时花费了 3.1 秒。我们能不能提高这个速度?
在分析数据中搜索了一下,有一个函数很突出:strongRound。更重要的是,这个函数之后不久总是会有一点 GC 清理(见小红框)。
这激起了我的好奇心!让我们在 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 文件中都有很多这样的函数。该函数接收一个numbers数组,并期望对其条目进行变异。让我们来看看在其实现中使用的变量类型。仔细观察一下,我们会发现在字符串和数字之间有很多来回转换的过程。
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。我也为此向上游提交了一份 PR。
短字符串的 Regexes (第二部分)
在strongRound附近的另一个函数看起来很可疑,因为它几乎占用了整整一秒钟(0.9s)来完成。
与stringRound类似js时间格式转换,这个函数也可以压缩数字,但有一个额外的技巧,如果数字有小数,并且小于 1 和大于 – 1,我们可以去掉前导的 0。因此,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和n>-1&。甚至NaN也没有这种能力!由此我们可以推断,要么只有一个正则表达式匹配,要么一个都不匹配,但绝不会同时匹配。至少有一个.replace调用总是被浪费的。
我们可以通过手工区分这些情况来进行优化。只有当我们知道我们处理的是一个有前导 0 的数字,我们才应该应用我们的替换逻辑。这些数字检查比正则表达式搜索更快。
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;
};
我们可以更进一步,完全去除正则表达式搜索,因为我们可以 100% 确定地知道字符串中前导 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 秒!上游 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的浏览器列表目标的。虽然浏览器列表的设置可能看起来很短,但最终它们被扩展到大约 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 工具可能比现在的速度更快。而我们在这篇文章中看的东西或多或少只是冰山一角。
关于本文
译者:@飘飘
