[Part1]JavaScript生态加速攻略:一次一个库

本文首发于微信公众号:大迁世界, 我的微信:qq449245884,我会第一时间和你分享前端行业趋势,学习途径等等。
更多开源作品请看 GitHub https://github.com/qq449245884/xiaozhi ,包含一线大厂面试完整考点、资料以及我的系列文章。

快来免费体验ChatGpt plus版本的,我们出的钱
体验地址:https://chat.waixingyun.cn
可以加入网站底部技术群,一起找bug.

该系列是由@marvinhagemeist撰写的,旨在通过一系列文章加速JavaScript生态系统。这些文章提供了有关如何加速JavaScript生态系统的有用信息。文章涵盖了各种主题,包括PostCSS、SVGO、模块解析、eslint和npm脚本。

今天我们来看第一部分。在第一部分文章[1]中,作者分享了许多流行库的加速技巧。作者建议避免不必要的类型转换,避免在函数内部创建函数等。

尽管趋势似乎是将每个JavaScript构建工具重写为其他语言,如Rust或Go,但当前基于JavaScript的工具可以更快。典型前端项目中的构建流水线通常由许多不同的工具组成。但是,工具的多样化使得工具维护者更难以发现性能问题,因为他们需要知道自己的工具通常与哪些工具一起使用。

从纯语言角度来看,JavaScript肯定比Rust或Go慢,但当前的JavaScript工具可以得到相当大的改进。当然,JavaScript比较慢,但与今天相比,它不应该那么慢。JIT引擎现在非常快!

在 PostCSS 中节省了 4.6 秒

有一个非常有用的插件叫做 postcss-custom-properties,它在旧版浏览器中增加了对 CSS 自定义属性的基本支持。不知何故,它在跟踪中非常突出,被归因于它内部使用的单个正则表达式,导致了高达 4.6 秒的成本。这看起来很奇怪。

image.png

正则表达式看起来很像搜索特定注释值以更改插件行为的内容,类似于 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 分析显示,花费了 3.1 秒来压缩 SVG 文件。我们能加快这个过程吗?

在分析数据时,有一个函数引起了注意: strongRound 。更重要的是,该函数总是紧随着一小段垃圾回收清理(请参见小红框)。

image.png

查看源代码

/**
 * 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秒!

短字符串上的正则表达式(第二部分)

strongRound 的紧密邻近,另一个功能看起来很可疑,因为它需要近乎一秒钟(0.9秒)才能完成。

image.png

类似于 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 < 1n > -1 && n < 0 。甚至 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;
};

我们可以更进一步,完全摆脱正则表达式搜索,因为我们可以百分之百确定字符串中前导 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 秒!

内联函数、内联缓存和递归

一个名为 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.9s

语义化版本号、案例

对于这个问题,我不确定是否配置有误。基本上,该配置文件显示每当它转换文件时,整个 Babel 配置都会被重新读取。

image.png

在截图中有点难看清楚,但其中一个占用大量时间的功能是来自 semver 包的代码,这个包也是 npm 的 cli 中使用的包。嗯?semver 与 babel 有什么关系?直到一段时间后我才明白:它是用于解析 @babel/preset-env 的 browserlist 目标的。虽然 browserlist 设置可能看起来相当简短,但最终它们被扩展为大约 290 个单独的目标。

仅仅这些还不足以引起关注,但在使用验证函数时很容易忽略分配成本。这在babel的代码库中有点分散,但基本上浏览器目标的版本被转换为semver字符串 "10" -> "10.0.0" ,然后进行验证。其中一些版本号已经匹配了semver格式。这些版本号和有时版本范围会相互比较,直到找到我们需要转码的最低公共功能集。这种方法没有任何问题。

性能问题在这里出现,因为 semver 版本被存储为 string 而不是解析后的 semver 数据类型。这意味着每次调用 semver.valid('1.2.3') 都会创建一个新的 semver 实例并立即销毁它。当使用字符串比较 semver 版本时,情况也是如此: semver.lt('1.2.3', '9.8.7') 。这就是为什么我们在跟踪中经常看到 semver 的原因。

通过在 node_modules 中再次进行本地修补,我能够将构建时间再次缩短 4.7s

代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug

交流

有梦想,有干货,微信搜索 【大迁世界】 关注这个在凌晨还在刷碗的刷碗智。

本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

@大迁世界

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值