本文首发于微信公众号:大迁世界, 我的微信:qq449245884,我会第一时间和你分享前端行业趋势,学习途径等等。
更多开源作品请看 GitHub https://github.com/qq449245884/xiaozhi ,包含一线大厂面试完整考点、资料以及我的系列文章。
快来免费体验ChatGpt plus版本的,我们出的钱
体验地址:https://chat.waixingyun.cn
可以加入网站底部技术群,一起找bug.
作者 @marvinhagemeist 于2023年1月15日阐述了如何优化JavaScript的模块解析以提高开发效率。文章提到,无论是构建、测试还是检查 JavaScript 代码,模块解析都是其中的核心环节。然而,尽管模块解析在我们的工具中占据着关键地位,但目前尚未投入足够的时间来提高这一方面的速度。
在本系列的第一部分中,我们找到了一些加速JavaScript工具中使用的各种库的方法。虽然这些低级别的补丁将总构建时间数字移动了很大一部分,但我想知道我们的工具中是否有更基本的东西可以改进。像捆绑、测试和linting这样的常见JavaScript任务的总时间影响更大的东西。
在接下来的几天里,我收集了来自我们行业常用的各种任务和工具的大约十几个 CPU 分析文件。经过一番检查,我发现了一个在我查看的每个分析文件中都存在的重复模式,它会影响这些任务的总运行时间高达 30%
。它是我们基础设施中如此关键和有影响力的一部分,值得有一篇专门的博客文章来介绍。
那个关键部分被称为模块解析。在我查看的所有跟踪中,它所花费的总时间比解析源代码还要多。
捕获堆栈跟踪的成本
在这些跟踪中最耗时的部分是在 captureLargerStackTrace
中花费的,这是一个负责将堆栈跟踪附加到 Error 对象的内部节点函数。考虑到两个任务都成功完成而没有显示任何错误被抛出,这似乎有点不寻常。
在浏览了一堆性能数据的发生后,一个更清晰的图片浮现出来,即正在发生什么。几乎所有的错误创建都来自于调用节点的本地 fs.statSync()
函数,而这反过来又被调用在一个名为 isFile
的函数内。文档提到 fs.statSync()
基本上相当于 POSIX 的 fstat
命令,并且通常用于检查磁盘上的路径是否存在、是文件还是目录。考虑到这一点,我们只应该在异常情况下出现错误,例如文件不存在、我们缺少读取它的权限或类似情况。是时候看一眼 isFile
的源代码了:
function isFile(file) {
try {
const stat = fs.statSync(file);
return stat.isFile() || stat.isFIFO();
} catch (err) {
if (err.code === "ENOENT" || err.code === "ENOTDIR") {
return false;
}
throw err;
}
}
这看起来是无害的函数,但仍然在跟踪中显示出来。值得注意的是,我们忽略了某些错误情况,并返回 false
而不是转发错误。 ENOENT
和 ENOTDIR
错误代码最终意味着磁盘上不存在该路径。也许这就是我们看到的开销?我的意思是,我们在这里立即忽略了这些错误。为了测试这个理论,我记录了 try/catch
块捕获的所有错误。结果每个抛出的错误都是一个 ENOENT
代码或一个 ENOTDIR
代码。
查看 fs.statSync 的 Node 文档,可以发现它支持传递一个 throwIfNoEntry
选项,当没有文件系统条目存在时,它可以防止错误被抛出。相反,它会返回 undefined
。
function isFile(file) {
const stat = fs.statSync(file, { throwIfNoEntry: false });
return stat !== undefined && (stat.isFile() || stat.isFIFO());
}
这个单一的改变使得项目的代码检查时间减少了7%。更令人惊喜的是,同样的改变也使得测试速度得到了类似的提升。
文件系统很昂贵
通过消除该函数的堆栈跟踪开销,我觉得还有更多的事情要做。你知道,抛出几个错误在几分钟内捕获的跟踪中根本不应该出现。因此,我在该函数中注入了一个简单的计数器,以了解它被调用的频率。很明显,它被调用了约15k次,大约是项目中文件数量的10倍。这就像是一个改进的机会。
模块化还是非模块化,这是个问题
默认情况下,工具需要了解三种类型的限定符:
- 相对模块导入:
./foo
,../bar/boof
- 绝对模块导入:
/foo
,/foo/bar/bob
- 导入包 foo ,
@foo/bar
。
从性能角度来看,三个中最有趣的是最后一个。裸导入规范符,即不以点 .
或斜杠 /
开头的规范符,是一种特殊的导入方式,通常用于引用npm包。该算法在node的文档中有详细描述。其要点是它尝试解析包名称,然后向上遍历以检查是否存在包含该模块的特殊 node_modules
目录,直到达到文件系统的根目录。我们通过一个例子来说明:
假设我们有一个位于 `/
Users/marvinh/my-project/src/features/DetailPage/components/Layout/index.js 的文件,试图导入一个模块
foo` 。然后算法将检查以下位置:
/Users/marvinh/my-project/src/features/DetailPage/components/Layout/node_modules/foo/
/Users/marvinh/my-project/src/features/DetailPage/components/node_modules/foo/
- /Users/marvinh/my-project/src/features/DetailPage/node_modules/foo/
- /Users/marvinh/my-project/src/features/node_modules/foo/
- /Users/marvinh/my-project/src/node_modules/foo/
- /Users/marvinh/my-project/node_modules/foo/
- /Users/marvinh/node_modules/foo/
- /Users/node_modules/foo/
这是很多文件系统调用。简而言之,将检查每个目录是否包含模块目录。检查的数量直接与导入文件所在的目录数相关。问题在于,这会发生在每个导入 foo
的文件中。这意味着,如果在其他地方的文件中导入 foo
,我们将再次向上爬整个目录树,直到找到包含模块的 node_modules
目录。这是缓存已解析模块的方面,极大地有所帮助。
但这还不是最好的!许多项目使用路径映射别名来节省一点打字,这样您就可以在任何地方使用相同的导入规范并避免大量的点 ../../../
。这通常是通过TypeScript的 paths 编译器选项或捆绑器中的解析别名来完成的。问题在于,这些通常与包导入无法区分。如果我在 /Users/marvinh/my-project/src/features/
的features
目录中添加路径映射,以便我可以使用像 import {...} from “features/DetailPage”
这样的导入声明,那么每个工具都应该知道这一点。
但如果它不行呢?由于没有一个所有JavaScript工具都使用的集中式模块解析包,它们有多个竞争对手,支持不同级别的功能。在我的情况下,该项目大量使用路径映射,并包含一个不知道TypeScript中定义的路径映射的linting
插件。自然地,它假定 features/DetailPage
是指一个节点模块,这导致它进行整个递归向上遍历以寻找模块。但它从未找到,所以它抛出了一个错误。
缓存所有东西
接下来,我增强了日志记录功能,以查看该函数被调用的唯一文件路径数量以及它是否总是返回相同的结果。只有约2.5k次调用 isFile
具有唯一的文件路径,并且传递的文件参数与返回值之间存在强烈的1:1映射关系。这仍然比项目中的文件数量要多,但比总共15k次调用要少得多。如果我们在周围添加缓存以避免访问文件系统会怎样呢?
const cache = new Map();
function resolve(file) {
const cached = cache.get(file);
if (cached !== undefined) return cached;
// ...existing resolution logic here
const resolved = isFile(file);
cache.set(file, resolved);
return file;
}
缓存的添加使总的代码检查时间再次加快了15%
。不错!但缓存的风险在于它们可能会变得陈旧。通常有一个时间点需要使它们失效。为了安全起见,我最终选择了一种更为保守的方法,检查缓存文件是否仍然存在。如果您考虑到工具通常在监视模式下运行,期望尽可能缓存并仅使更改的文件失效,那么这并不是一件罕见的事情。
const cache = new Map();
function resolve(file) {
const cached = cache.get(file);
// A bit conservative: Check if the cached file still exists on disk to avoid
// stale caches in watch mode where a file could be moved or be renamed.
if (cached !== undefined && isFile(file)) {
return cached;
}
// ...existing resolution logic here
for (const ext of extensions) {
const filePath = file + ext;
if (isFile(filePath)) {
cache.set(file, filePath);
return filePath;
}
}
throw new Error(`Could not resolve ${file}`);
}
我最初的期望是,由于即使在缓存的情况下我们仍然要访问文件系统,因此它会使添加缓存的好处无效。但是,看着数字,这只会使总的代码检查时间增加0.05%。与此相比,这只是一个非常小的影响,但是额外的文件系统调用不应该更重要吗?
文件扩展名
JavaScript中的模块化问题在于,该语言一开始并没有模块系统。当node.js出现时,它推广了CommonJS模块系统。该系统有几个“可爱”的特性,比如可以省略正在加载的文件的扩展名。当你编写像 require("./foo")
这样的语句时,它会自动添加 .js 扩展名并尝试读取 ./foo.js
处的文件。如果不存在,它将检查json文件 ./foo.json
,如果也不可用,则会检查 ./foo/index.js
处的索引文件。
实际上,我们在这里处理的是歧义,工具必须理解 ./foo 应该解析为什么。因此,存在高概率进行浪费的文件系统调用,因为无法事先知道文件的解析位置。工具必须逐个尝试每种组合,直到找到匹配项。如果考虑到今天存在的所有可能扩展名的总量,情况会更糟。工具通常有一系列潜在的扩展名要检查。如果包括 TypeScript,则典型前端项目的完整列表为:
const extensions = [
".js",
".jsx",
".cjs",
".mjs",
".ts",
".tsx",
".mts",
".cts",
];
这是要检查的8个潜在扩展名。而且这还不是全部。基本上必须将该列表加倍,以考虑可能解析为所有这些扩展名的索引文件!我们的工具别无选择,只能循环遍历扩展名列表,直到找到一个存在于磁盘上的扩展名。当我们想要解析 ./foo
,而实际文件是 foo.ts
时,我们需要检查:
- foo.js -> 不存在
- foo.jsx -> 不存在
- foo.cjs -> 不存在
- foo.mjs -> 不存在
- foo.ts -> bingo!
这是四个不必要的文件系统调用。当然,你可以更改扩展名的顺序,并将项目中最常见的扩展名放在数组的开头。这将增加找到正确扩展名的机会,但并不能完全消除问题。
作为 ES2015 规范的一部分,提出了一个新的模块系统。并没有在时间上详细说明所有细节,但语法已经确定。由于其静态性,它为更多的工具增强功能打开了空间,最著名的是树摇,其中未使用的模块甚至是模块中的函数可以轻松地被检测并从生产构建中删除。自然地,每个人都转向了新的导入语法。
然而,有一个问题:只有语法被确定下来了,而实际的模块加载或解析方式并没有确定。为了填补这个空白,工具们重新使用了来自CommonJS的现有语义。这对于采用来说是很好的,因为大多数代码库只需要进行语法上的更改,而这些更改可以通过codemods自动化。从采用的角度来看,这是一个很棒的方面!但这也意味着我们继承了猜测游戏,即导入说明符应该解析为哪个文件扩展名。
模块加载和解析的实际规范是在多年后最终确定的,通过强制要求扩展名来纠正了这个错误。
// 无效的ESM,导入说明符中缺少扩展名
import { doSomething } from "./foo";
// 有效的 ESM
import { doSomething } from "./foo.js";
通过消除这种歧义的来源并始终添加扩展名,我们可以避免一整类问题。工具的运行速度也会大大提高。但是,要等到生态系统在这方面取得进展或者是否会取得进展,还需要时间,因为工具已经适应了处理这种歧义。
从这里去哪里?
在整个调查过程中,我有点惊讶地发现,在优化模块解析方面还有很大的改进空间,尽管这在我们的工具中如此关键。本文所描述的一些小改动就将linting时间缩短了30%!
我们在这里进行的少量优化并不仅适用于JavaScript。这些都是可以在其他编程语言的工具中找到的相同优化。当涉及到模块解析时,主要有以下四个要点:
- 尽可能避免频繁调用文件系统
- 尽可能缓存以避免调用文件系统
- 当你使用
fs.stat
或fs.statSync
时,请始终设置throwIfNoEntry: false
- 尽可能限制向上遍历
我们工具中的缓慢并非是由于JavaScript语言本身造成的,而是因为事物根本没有得到优化。JavaScript生态系统的碎片化也没有帮助,因为没有一个统一的标准包用于模块解析。相反,有很多包,它们都共享一部分不同的功能。然而,这并不奇怪,因为随着时间的推移,需要支持的功能列表不断增长,到撰写本文时为止,还没有一个单独的库能够支持所有这些功能。如果有一个大家都在用的单一库,那么一劳永逸地解决这个问题对每个人来说都会容易得多。
原文:https://marvinh.dev/blog/speeding-up-javascript-ecosystem-par…
代码部署后可能存在的BUG没法实时知道,事后为了解决这些BUG,花了大量的时间进行log 调试,这边顺便给大家推荐一个好用的BUG监控工具 Fundebug。
交流
有梦想,有干货,微信搜索 【大迁世界】 关注这个在凌晨还在刷碗的刷碗智。
本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。
服务器托管,北京服务器托管,服务器租用 http://www.fwqtg.net