前言
从模块解析的路径及后缀名着手分析。今日前端早读课文章由 @飘飘翻译分享。
正文从这开始~~
tl;dr: 无论你是在构建、测试和 / 或检测 JavaScript,模块解析始终是一切的核心。尽管它在我们的工具中处于核心地位,但却没有花多少时间来提高这个方面的速度。通过这篇博文中讨论的更改,工具的速度可以提高 30%。
在本系列的【第2866期】加快JavaScript生态系统的速度--一次一个库中,我们找到了一些加速 JavaScript 工具中使用的各种库的方法。虽然这些低级别的补丁将总的构建时间提高了一大截,但我在想,在我们的工具中是否有一些更基本的东西可以被改进。对常见的 JavaScript 任务的总时间有更大影响的东西,比如 bundling、测试和检测。
因此,在接下来的几天里,从我们行业中常用的各种任务和工具中收集了大约十几个 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-block 捕获的所有错误都记录下来。结果发现,每一个被抛出的错误都是 ENOENT 代码或 ENOTDIR 代码。
窥视一下 node 的 fs.statSync 文档,发现它支持传递一个 throwIfNoEntry 选项,防止在没有文件系统条目存在时抛出错误。相反,在这种情况下,它将返回 undefined。
function isFile(file) {
const stat = fs.statSync(file, { throwIfNoEntry: false });
return stat !== undefined && (stat.isFile() || stat.isFIFO());
}应用这个选项可以让我们摆脱 catch 块中的 if 状态,这反过来又让 try/catch 变得多余,让我们可以进一步简化函数。
这个单一的改变使项目的提示时间减少了 7%。更棒的是,测试也从同样的改变中获得了类似的速度。
文件系统是昂贵的
随着该函数的堆栈跟踪的开销被清除,我感觉还有更多的东西要做。你知道,在几分钟的时间里,抛出几个错误不应该在跟踪中显示出来。所以在该函数中注入了一个简单的计数器来了解它被调用的频率。很明显,它被调用了大约 15000 次,比项目中的文件多 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 编译器选项或 bundler 中的解析别名完成的。这样做的问题是,这些通常与包导入没有区别。如果我在 /Users/marvinh/my-project/src/features/ 的 features 目录下添加一个路径映射,这样我就可以使用 import {...} from "features/DetailPage " 这样的导入声明,那么每个工具都应该知道这个。
但如果它不这样做呢?由于每个 JavaScript 工具都没有使用集中的模块解析包,它们有多个相互竞争的模块,支持不同程度的功能。在我的案例中,该项目大量使用路径映射,它包括一个不知道 TypeScript 的 tsconfig.json 中定义的路径映射的 linting 插件。很自然地,它认为 features/DetailPage 指的是一个 node 模块,这导致它进行整个递归向上遍历,以期找到该模块。但它从未找到,所以它抛出了一个错误。
缓存所有的东西
接下来,我加强了日志记录,以查看调用函数时使用了多少唯一的文件路径,以及它是否总是返回相同的结果。只有大约 2.5 千次对 isFile 的调用具有唯一的文件路径,并且在传递的文件参数和返回值之间有一个很强的 1:1 的映射。这仍然比项目中的文件数量多,但比它被调用的 15000 次要少得多。如果我们在其周围添加一个缓存以避免访问文件系统会怎么样?
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 有很大的好处。由于它的静态性,它为更多的工具增强功能开辟了空间,比如最著名的 tree-shaking,在这里,未使用的模块和甚至模块中的函数都可以很容易地被发现并从生产构建中删除。自然,每个人都对新的导入语法跃跃欲试。
但有一个问题。只有语法被敲定,而不是实际的模块加载或解析应该如何工作。为了填补这一空白,一些工具重新使用了 CommonJS 的现有语义。由于大多数代码库的移植只需要语法更改,并且可以通过代码解码器实现自动化,因此采用这种方法是有益的。从采用的角度来看,这是一个很好的方面。但这也意味着我们继承了导入说明符应该解析到的文件扩展名的猜谜游戏。
实际的模块加载和解析规范是在几年后定稿的,它通过强制扩展来纠正了这个错误。
// Invalid ESM, missing extension in import specifier
import { doSomething } from "./foo";
// Valid ESM
import { doSomething } from "./foo.js";通过消除这种含糊不清的来源,并始终添加一个扩展,我们避免了一整类问题。工具也会变得更快。但这需要时间,直到生态系统在这方面取得进展,或者如果有的话,因为工具已经适应了处理模糊性的问题。
接下来该怎么办?
在整个调查过程中,我有点惊讶地发现在优化模块解析方面有很大的改进空间,因为它是我们工具中的核心。本文中描述的几个更改将检测时间减少了 30%!
我们在这里做的几个优化也不是 JavaScript 独有的。这些优化在其他编程语言的工具中也可以找到。当涉及到模块解析时,四个主要的收获是:
尽可能地避免调用文件系统
尽可能地缓存以避免调用文件系统
当你使用
fs.stat或fs.statSync时,总是设置throwIfNoEntry: false。尽可能地限制向上的遍历行为
我们的工具的缓慢并不是由 JavaScript 这个语言造成的,而是因为事情根本没有被优化。JavaScript 生态系统的碎片化也无济于事,因为没有一个用于模块解析的标准包。相反,它们有多个,并且它们都共享不同的特性子集。这并不奇怪,因为需要支持的功能清单多年来一直在增长,而且在写这篇文章的时候,还没有一个单一的库能支持所有的功能。如果有一个大家都使用的单一库,那么为大家一劳永逸地解决这个问题就会容易得多。
关于本文
译者:@飘飘
作者:@marvinhagemeist
原文:https://marvinh.dev/blog/speeding-up-javascript-ecosystem-part-2/
这期前端早读课
对你有帮助,帮” 赞 “一下,
期待下一期,帮” 在看” 一下 。