本文首发于微信公众号:大迁世界, 我的微信: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 已收录,有一线大厂面试残缺考点、材料以及我的系列文章。