Loading...
Loading...
- 最后编辑:2025/11/29 15:27:47 -
Loading...
前两天,我偶然间在网络上看到了一张很有趣的梗图:

这张图想表达的意思很明显了——npm的嵌套依赖太严重了。
在这之后,我就饶有兴趣的去查了一下博客网站的node_modules体积,不查还好,这一查给我吓了一跳:前后端加起来有将近一个G!
因此,我就打算把它从npm更换为pnpm,这是一种更加现代化的node包管理方案,它使用符号地址和软链接的特性,完美地解决了传统npm依赖无限套娃式嵌套的致命缺陷,可以在某些情况下显著缩小node_modules的体积。
当然,我们这篇文章的主题并不是如何从npm切换到pnpm,所以我不会详细叙述这一过程,总之就是更换后的体积确实小了很多,我对此非常满意。
然后,在某一个瞬间,我的脑子里突然就蹦出来了一个好玩的想法——
我们都知道,在nodejs项目的开发中,经常会出现有一些包在安装后,由于后续需求发生变化,而不再被使用了,但他们有时并不会被立即卸载掉,久而久之就被遗忘了。但这些无用的包却并不会就此消失,而是会继续留在项目目录中,占用着一部分的硬盘空间。
于是,就有前人挺身而出,开发了一些能检测无用的npm依赖的工具,比如著名的depcheck。
但是depcheck作为一个发布时间比较早的工具,它的缺点也很明显:
引用一个我在项目最终的README中举的例子,就非常容易体现出这种问题的弊端了:
假设我们有个项目,使用了
react作为技术栈,而项目中的某个地方直接使用了react-dom提供的某个模块实现了某个功能,那么这个时候,react-dom确实就已经被引用了,于是在 packages.json 中,它大概是这样的:{ "dependencies": { "react": "19.2.0", "react-dom": "19.2.0" } }现在再假设在某次优化中,我们使用了另一个更成熟的依赖库实现了原本用
react-dom实现的这部分逻辑,那么react-dom此时在项目中就不会被引入了,在传统的依赖分析,但 package.json 本身并没有被改变,所以直观上来说,react-dom似乎确实是一个冗余依赖,可以被卸载,用于减少项目体积,而在部分传统工具中,也确实会出现类似的情况(当然,react-dom这种常见的情况是不会出现的,毕竟这个框架太常用了)。 但事实却是,react本身需要用到react-dom作为它的 Peer 依赖项(Peer Dependencies),也就是说react-dom在项目中是有作用的,少了这个依赖,react就不能正常运行了,所以它不能被卸载,它们之间的依赖关系也不能被这么粗暴草率的分析。
因此,在这一瞬间,我就突然想到,我能不能做一个依赖,能够利用pnpm的扁平化特性,分析各个依赖之间的引用关系,从而降低误报的概率呢?
说干就干!
我立刻就开始了最初的尝试,并给这个项目起了个非常有寓意的名字——Deplens,dep是dependencies的缩写,也就是依赖,同时也有点形似deep(虽然毫不相关),而lens则表示“透镜”,意思就是一个能看清依赖库的透镜,直接将依赖库分析的明明白白的,也算是一个非常宏大的目标了(笑)。
在最开始的时候,我的计划是做一个专为pnpm环境设计的依赖引用情况分析工具,所以从创建项目开始,就没打算适配普通的npm项目。
我首先是规划了一下技术栈和实现思路,其实这个思路非常容易理解:先扫描目录下所有的项目源文件,包括.js、.ts、.mjs等等,读取他们的内容,接着再与pnpm-lock.json这个“无所不知”的锁文件进行比较,找出那些未被使用的依赖库,最后再分析pnpm-lock.json本身的内容,找出那些被其他依赖嵌套依赖的依赖库(这句话读起来可能会有点绕,但总的来说,就是两个依赖之间有说不清的关系,无法被简单判断为未使用),把这些依赖从未使用的依赖中排除,最后得出来的结果自然就是真正未被使用的依赖库了。
而所需要用到的技术栈也不复杂,Babel提供了Parser和Traverse两个很方便的工具来操作AST节点,Parser能够把Javascript解析为AST树,而Traverse能遍历整个树,并对我们需要的节点进行各种操作。所以我只需要在全过程中维护一个Dependency对象数组,解析pnpm-lock.json,遍历所有依赖的信息,记录每个依赖的名字、版本,以及它们的使用情况等信息,再把扫描出来的文件内容交给它,然后处理每一个ImportDeclaration和与require函数有关的CallExpressionAST节点,把它们与Dependency数组比较,划掉那些使用过的依赖就可以了。
说起来简单,实际做起来也感觉还好,我仅仅用了一个下午就做出了一个初版的最小可行产品,它只能分析项目源代码中的引用,还不具备检查依赖间关系的能力,不过我认为这在目前阶段来说已经足够了,所以激动的将它运行在了我的个人博客项目中观察效果,结果如下图所示:

看到结果的一瞬间,说实话,效果不好。
可以看到,一共只分析了24个依赖库,但是却查出来15个未使用依赖库,未使用概率达到了惊人的62.5%!
如果事实真如Deplens所展示出来的那样,那我这个项目就实在是太烂了,但答案显然是不可能的,就光是从这一堆结果中,我就找到了一些自己印象中的确有使用的依赖,他们不可能全都是被其他依赖嵌套依赖的库,肯定还是有些我没考虑到的漏网之鱼,而且我用depcheck验证了一遍,得出的答案与Deplens截然不同,刚好这个博客项目本来就是我开发的,我对它的结构自然了如指掌,所以我就对着源代码和pnpm-lock.json文件一项一项确认,看看这些依赖到底有没有被使用。
结果很显然,这些依赖库中就是有一些被误判的结果,所以我就针对调查出来的这些问题,优化了分析逻辑,解决了潜在的缺陷。
在做这个最小可行产品时,我的脑中冒出过一个想法——既然我能够分析pnpm的lockfile(即pnpm-lock.json),那为什么不能顺便把npm的package-lock.json也一起分析了呢?
这似乎是一个好的想法,因为这两个lockfile的结构其实有点类似,都是记录每一个依赖库的安装信息,我可以通过总结分析它们,来得出依赖之间的引用关系,至于npm那些复杂的循环嵌套依赖,我干脆就不分析了,反正它们也是被安装在各自依赖库的根目录中的,不是直接安装在node_modules,就算有极少数的依赖库被提升(hoisting)了,我也能通过lockfile中的路径分析出来——这真是个聪明的想法。
一想到这,我马上就开始着手准备完善依赖分析机制,但没想到这一步骤却阻碍重重。
一提起这个,我的脑子就有点疼,因为这段逻辑确实就是整个工具早期开发阶段最复杂的一段逻辑了,卡了我一个多星期——主要是lockfile文件的结构太阴间了,各种依赖关系绕来绕去的,乱七八糟的东西满天飞,我光是理解就花了不少功夫……
无奈之下,我只能结合AI给出的思路,一步步的复现,接着又一次次的优化和重做,因为我的想法很多,想实现的功能有很多,但有些东西做着做着又总是发现以我目前的能力实在做不出来,或是捋不清这个逻辑思路,没法想到很方便的解决方案,毕竟我总不可能一遍遍的遍历,然后空间复杂度变成O(n^n)吧(笑),所以这段功能一直做的我头晕眼花的,脑子都一度有点宕机了,原谅我在复杂场景下的逻辑分析能力实在是有点跟不上需求(
以及,我不得不提在这里的一点,lockfile的结构实在是太乱了,无论是npm还是pnpm,简直就是一坨屎山!尤其是pnpm-lock.json,不同版本之间的lockfile的结构大相径庭,彼此之间甚至不能做到互相兼容,导致相同的一套算法,在这个项目中能正常运行,在其他pnpm版本的项目中又不行了,最后还是询问AI才得知lockfile的标准居然如此混乱。
并且,依赖包的依赖关系也实在是多种多样……在一遍遍的审查和测试后,我总算是摸清了大概的类型:
npm install命令时被安装npm install --dev命令时会被安装node_modules中npm install时也会被安装,但即使安装失败或错误也不会导致进程中断,往往用于实现一些扩展功能npm中,当多个依赖共同依赖同一个依赖库时,npm可能会选择将它们直接提升到node_modules目录中,而不是对每个依赖都重新安装一次,节省硬盘占用。最终,我实现了一套比较简单的分析算法,它先是判断当前分析的是npm项目还是pnpm项目,然后根据不同的包管理器,选择不同的分析模式。
——当然,这样做也是方便我后续能够扩展(也许)其它包管理器的分析功能,比如yarn等。
此外,在pnpm中,还要多一步,判断lockfile版本,毕竟不同的lockfile版本之间的区别甚至可以堪比npm和pnpm的区别(
紧接着,算法会获取根目录下的lockfile文件,读取其中的信息,主要包括以下几类:
rootDeclared)。depSource)。将这些信息收集完成后,算法会对depSource进行遍历,分析它们的依赖,包括名字和版本信息,因为有些依赖的版本号可能携带通配符,或者在pnpm中会出现在版本号后面跟着一连串的peer dependencies的现象,所以不能在上一步中就全部粗暴的记录到depSource中,而是要专门进行一次分析,最后再记录到usedByOthers数组中,这是一个Dependency对象数组,这样一来,所有的依赖就统一了结构标准,方便我们后续进行嵌套引用关系和AST分析。
再接着,算法会遍历rootDeclared——没错,是遍历根目录中声明过的依赖,而不是遍历所有依赖(depSource),为什么呢?当然是为了节省性能开销,因为在这个分析过程中,我们是假设每个依赖库的依赖列表都是干净的,即每个包中不存在无用的嵌套引用关系,所有的嵌套依赖只要被安装,就一定是有用的,并且从原则上看,Deplens也无法插手依赖库自己的依赖,毕竟就算它们真的有没用的依赖,每次执行npm install时,受到package.json的影响,这些之前被卸载的依赖也还是会被重新安装上去,哪怕是修改了``package.json,最后也会被还原,所以这不属于Deplens应该分析的范围,干脆就假设每个既没有根目录的package.json`中直接声明,又被安装的依赖都是有用的嵌套依赖,不用分析。
因此,算法只需要分析rootDeclared,它会先初步构建出一个Dependency结构的根依赖数组(dependenciesPkg),然后遍历rootDeclared,将这些在package.json中声明过,虽然在项目中没有被引用,但是有被其他依赖引用的依赖给排除,最终才能准确分析出真正无用的依赖。
也许你此时会想,既然我从来没有用到过某个依赖,仅仅是因为其他依赖用到了它,为什么会存在于根目录的package.json中呢,它们不应该只会被记录在依赖库自己的package.json中吗?
当然不是,还是我最初举的那个react的例子,我不需要单独安装react-dom,也没有人会专门安装react-dom,但仅仅因为它是react的对等依赖(peer dependencies),所以会在npm install时被一起安装,并且也会被放到根目录的package.json中!
因此,一些项目中往往都会出现一些我没有直接在项目中用到,但是与其他依赖有关联的依赖,这些依赖不应该被判定为无用依赖,所以我们需要先排除这些特殊的依赖关系,然后再进行AST的分析。
而那些dev dependencies,考虑到在一些情况下它们可能不会被安装,所以我就在记录时顺便也把它们记录了下来,但不进行分析(大概也是因为我懒了),而是在--verbose模式下告诉用户,有些开发依赖在正式版中你可能不需要,可以考虑将它们卸载。
当然,在真正的算法中,Deplens实际上是通过遍历rootDeclared,判断这个依赖的这个版本在depSource中存不存在,如果不存在,才把它push到lockFilePkg中,如果存在,就直接跳过这一次循环,因为我们没有必要记录已经被引用过的依赖,这些信息在后续既不会被更改(已经被使用过的依赖不需要再进行AST分析),也不需要被展示出来(最终结果只会列出无用依赖,而非所有依赖的使用情况),把它们加进去反而还会扩大lockFilePkg的体积,加重后续AST分析的负担,这大概也算是算法设计的一个小巧思吧()
总之,这一套连招下来,咱们总算是设计好了这个依赖分析的算法,并且效果似乎还不错,这是我对比depcheck的分析区别:

在做完这整套逻辑后,我又加了个配置文件的功能,支持忽略指定的依赖库,避免一些意外的依赖被列为未使用依赖(比如nodemon这种不会在项目中被引入的依赖)。
随后,在完善了README之后,我就将Deplens开源了,并发布到了npm官网上。
令我意外的是,第一天就有98的下载量,即使我知道这其中大部分都是私人镜像仓库定时同步原网站造成的下载量,但我依然是有点激动,毕竟这也算是对我一周努力的一种认可吧(笑
Deplens发布后,我把这个项目给实验室的师兄看了一下,师兄在理解工具的功能后,也根据自己的经验提出了不少有用的改进建议。
首先是关于esm(ES Module)类型文件的一些特殊情况,比如:
const a = '@babel';
require(a + "/core");这类动态引入的方法,在esm中比较常见,而先前我们分析的AST语法树,是对代码进行静态分析,可在一些场景中,有一些代码的结果是只有在运行时才会知道的,比如随机数、异步请求等等,这些动态引入的场景,无法通过AST简单分析。
恰好,这些问题在其他Nodejs打包工具中也存在,在Vite中,它甚至已经摆烂了,直接告诉你这里没法简单分析,给你列出来是哪个地方,再让你自己去处理。
而师兄当时跟我提出这个问题的时候,跟我提出了一个设想——有一种名为Terser的压缩工具,它其中有一个算法,能在静态编译阶段就提前将一些简单的表达式的结果给计算出来,替换掉原来的表达式,直接输入结果,提高程序运行效率,类似于这样:
// 未经过terser压缩前
const a = 3;
const b = 4;
// 在执行console.log前计算一次 a + b的结果,再把它作为实参传给函数
console.log(a + b)
// terser压缩后,直接输出 3 + 4 的结果,也就是7
// 不用定义常量,也不用进行加法运算,直接把表达式的结果传进去
console.log(7)听到还有这种算法,我茅塞顿开,马上就去了解了一下相关信息。
我了解到,这种极其聪明的算法叫常量折叠,而将常量的值提前计算出来,并覆盖掉相关代码块的算法叫常量传播,在进行常量传播时,通常也会进行常量折叠、死代码消除等等操作,而这些正是terser最擅长的领域。
在了解完成后,我马上就尝试使用Terser对提取出来的项目源代码进行压缩,毕竟在压缩之后,就可以减少一部分特殊的动态引入情况,虽然无法完全消除,但能消除就尽量消除,这样也能提升分析结果的可信度。
但没想到这其中还发生了一段小插曲——terser无法处理Typescript代码,以及一些比较新的语法特性,比如TSX等等,这时候就需要引入Babel对这些比较高级的代码进行转义了,将它们转为标准的Javascript代码,然后再交给Terser进行压缩。
最后,对于那些实在无法静态分析的,束手无策的动态引入代码,比如await require()和import()等,我也学习了一下Vite的思想,在分析AST时,顺手将它们记录下来,然后在结果输出的时候统一展示出来。
最后的最后,让我们一起来看一下加入动态引入后的效果:

非常棒,现在我们可以直观的看到分析结果了!
这部分比较简单,因为目前主流的两大框架——React和Vue,都比较容易分析,尤其是React,它的TSX语法能被Babel转义为标准的Javascript代码,所以我不用考虑这个问题
然后是Vue,我只需要提取出*.vue文件中被<script></script>包裹的代码,然后再正常进行分析就好了,很直接,也很方便。
至于其它框架,我没用过,也不太了解语法特性,所以大概需要我有使用经验了,或者被提Issue了之后才会兼容()
这次项目开发经历对我而言帮助非常大,尤其是其中涉及到依赖关系视图的构建、AST的分析与遍历,以及后续的动态分析等,对我来说都是比较新的概念,我在做这些功能的时候,顺便也能学到一些我之前不了解的知识,并运用在实践中,对我的编程思想和专业知识都有非常大的提升,虽然这篇文章只讲了一些比较泛的问题,但实际的开发过程中,我还遇到了很多比较细节的难点,最终能够成功解决,并让项目正常顺产出来(bushi),对我来说也是一次全新的体验。
总之,在未来,我会持续维护和更新Deplens这个工具,争取将它打造成一个优秀的依赖使用情况分析工具!
2周前
4个月前