如果你这几年维护过稍大规模的前端项目,大概率见过这样的场景。
业务没增加多少,node_modules 目录的体积和深度却越来越吓人。安装依赖越来越慢,CI 流水线越跑越久,安全扫描一拉就是一长串陌生的、不知用途的小包。你想升级一个看似普通的依赖,结果顺手扯出十几个“这玩意儿到底是谁装进来的”未知包。
这种感觉总是很别扭。很多时候,项目变重并非因为业务真的复杂到那个地步,而是我们把太多历史包袱、过度的兼容性执念,以及早就该下线的小工具,一层一层地留在了依赖树里,就像房间里堆积的杂物,越来越难清理。
最近看到一篇文章,把这个问题剖析得挺清楚。它将常见的依赖膨胀归为三类。当你把这三类看明白,再回头看自己的项目,很多包的存在一下子就变得清晰,甚至有些多余了。
第一类膨胀:在替少数人的极端兼容需求买单
这类包最常见的特征,就是代码量特别小,小到你怀疑“这也值得发一个 npm 包?”。
比如判断一个值是否为字符串、判断对象自有属性、将几个 Math.* 方法单独导出……乍一看都是一句话能写完的逻辑。可一旦你顺着依赖树往下深挖,它们后面经常还挂着一串同样微小、同样眼熟、同样让人困惑的包。
这背后通常有三种历史或技术原因:
支持老运行时。像 ES3、早期 Node.js 这类环境,很多今天被视为理所当然的原生 API 当年并不存在。Object.keys、Array.prototype.forEach,这些如今像空气一样自然的东西,在更老的环境里要么缺失,要么行为不一致,需要自己动手填补。
防止全局污染。有些库的作者对全局对象保持警惕,尽量不直接信任 globalThis 或 window。他们会提前把 Map、Math.max 等全局 API 缓存在局部变量中,以防止运行时这些对象被意外篡改,导致库的行为出现偏差。
跨 realm(领域)安全。简单说,就是当你从 iframe、vm 等另一个 JavaScript 执行上下文中拿到一个值,它的构造函数可能与当前页面的构造函数不是同一个引用。于是,像 value instanceof RegExp 这样的判断就可能失效,需要更复杂的类型检测逻辑。
问题在于,真正需要同时兼顾这三方面兼容性的项目,其实并没有那么多。大多数业务系统运行在近几年版本的 Node.js 或现代浏览器环境中。我们既不需要兼容 IE6,也不会天天在 iframe 之间传递复杂对象。
结果却是,为少数极端场景设计的兼容层,慢慢混进了大量普通项目的关键路径里。最后演变成,所有人都在为少数人可能遇到的边缘情况持续支付成本。
说实话,这种“技术债”最令人头疼的地方就在这里。表面上你只多引入了几个体积很小的依赖,实际上额外增加的是包管理器解析、下载、解压、版本决议、安全审计,以及后续维护时巨大的认知负担。
第二类膨胀:把“一点点逻辑”硬拆成“一堆包”
还有一种依赖树,看起来更像一个善意的玩笑。当你打开依赖关系图,会发现一串包的职责大概是:把一个值包装成数组、将文件路径中的反斜杠替换为正斜杠、判断当前环境是否为 Windows、判断是否为 WSL (Windows Subsystem for Linux)。
这些事当然都能讲出一点道理,但它们经常小到离谱。有些包的本体就是一个正则表达式,或者一行环境判断代码。
从理论上看,把代码拆分成足够细小的模块,未来复用会更方便。当你需要开发一个 CLI 工具时,就能从这堆“乐高积木”中直接抽取几块,无需重写。
但现实往往不是这样运转的。
很多所谓的“原子包”,最终并没有成为被广泛复用的基础设施,反而变成了“我这个包的私有零件”,甚至只有同一个作者维护的另一个包在使用。本质上,它们就不是什么公共积木,更像是本该内联在源代码里的几行逻辑,被硬生生地拆成了一个额外的供应链节点,凭空增加了复杂度。

这会带来三个显而易见的麻烦:
获取成本激增。哪怕逻辑只有一行,只要它被发布为一个独立的包,你的项目就必须额外经历一次完整的包解析、下载、解压、缓存和版本处理流程。几十上百个这样的包叠加起来,成本就完全不是一回事了。
版本重复。在许多项目中,同一种功能的小包不止被安装一次,而是同时挂着两个、三个不同的版本。你会在 package-lock.json 或 yarn.lock 文件中看到一堆重复的包名,它们功能逻辑相似,但版本号却各不相同,徒增管理混乱。
供应链攻击面扩大。引入的包越多,你需要关心的维护状态、权限安全、投毒风险等问题就越难被忽略。每一个小包都是一个潜在的入口。
内联代码当然也可能导致逻辑重复,但内联之后,重复的代价会小很多。包管理系统不用为那几行逻辑操心,项目的供应链攻击面也不会因为它而继续变宽。
第三类膨胀:很多 Ponyfill 早就该退休了
第三类问题更为隐蔽,因为它们当年被引入时,往往确实有其价值。
先厘清这两个概念。polyfill 大家比较熟悉,它会将缺失的 API 直接修补(Polyfill)到运行时环境的全局对象上。但对于库(Library)的作者来说,通常不适合这样做,因为你不能随意修改使用你库的用户的全局环境。
因此,后来很多库转而采用 ponyfill 模式。你把某个功能的实现当作普通模块引入,需要时调用它;如果运行时原生支持该能力,就直接走原生路径;如果不支持,则使用降级的实现。这样既不污染全局,也能让库安全地使用一些“未来”的 API。
这个思路在特定的历史阶段是完全合理的。问题出在后半段——许多 ponyfill 所要解决的问题,其实早就被现代运行时原生覆盖了。按理说,当项目的长期支持(LTS)版本都已原生支持某个 API 后,这类兼容包就该慢慢退出舞台。但现实往往是,它们被安装进去之后,就再也没被认真评估和清理过。
于是,你会在今天的大量项目中,高频看到一些充满年代感的东西。比如 globalThis 的 ponyfill、Array.prototype.indexOf 的替代包、Object.entries 的兼容实现等等。

这类包最容易让人放松警惕。因为它们名字眼熟,下载量也高,看起来像是“大家都在用,应该没问题”。但如果你仔细追问,经常会发现团队里没人能说清楚它为什么还留着,只知道“它一直就在那里”。
这很像办公室里那种十年前留下来的、无人敢动的部署脚本或配置文件。每个人都不敢删,不是因为它真的关键,而是因为没人愿意第一个承担“万一删错了”的责任。结果就是,它一直挂在那里,持续占用着资源和注意力。
真正的麻烦:我们默认接受了这些包袱的持续存在
依赖数量多本身并不是原罪。当你使用一个成熟的框架、一套完整的测试工具链、或一个功能丰富的构建系统时,后面跟着不少必要的依赖,这很正常。
真正需要警惕的是另一种情况:很多依赖既不是项目的核心能力提供者,也不能带来明显的工程化收益,它们只是由于历史原因、特定的架构偏好,或者一句“以前需要兼容,所以现在懒得动”,才一直存活着。
一旦这类“僵尸依赖”变多,整个项目就会进入一种非常熟悉且令人沮丧的状态:
- 没人敢升级:担心牵一发而动全身。
- 没人敢删除:不清楚到底谁在用,怕引起线上事故。
- 新人接手后,第一反应不是“这套工程很稳健”,而是“这个
package.json 和锁文件我还是少碰为妙”。
这就是依赖膨胀真正消磨团队士气的地方。它不一定立刻导致项目崩溃,但会缓慢而持续地抬高维护成本,将工程演进的步伐一点点拖慢。最后,拖住你脚步的可能不是业务的复杂度,而是身后不断积累的历史“垃圾”在拽着你。
那我们该如何开始清理?
很多膨胀的依赖已经埋得很深,尤其是间接依赖,真要一个个拆解,工作量不小。但我们可以先做几件非常现实、可落地的事:
第一步,先问一个最简单的问题:这个包为什么在这里?
如果是直接依赖(dependencies 或 devDependencies 里的),先别急着找替代品,花点时间搞清楚它解决的到底是什么问题。很多时候你一查文档或源码就会发现,原来当前的运行时早已原生内置了该功能,或者你的项目压根就没调用过它。
第二步,用工具自动化扫描一遍。
像 knip 这类工具能帮你分析出哪些导入(import/require)的依赖实际上根本没有在代码中被使用。我之前用它扫描一个维护了两三年的项目,光是发现并清理掉那些“明明安装了但完全未被调用”的包,就减少了近二十个。
第三步,向前看,寻找现代替代方案。
有些工具如 @e18e/cli 会提供依赖替换建议,明确指出某些兼容包现在可以直接用原生 API 替代。这个阶段不要求一次性全部迁移,但至少能把那些明显已经过时、存在更优选择的依赖先标记出来。
最后
我现在越来越觉得,JavaScript 生态当下真正需要的,不是继续习惯性地为每一个微小的问题都发布一个独立的 npm 包,而是重新捡起那句最基础的质问:我到底为什么要安装这个东西?
许多依赖在当年被引入时并没有错,很多维护者当时的选择也完全可以理解。在那个平台能力参差不齐、兼容性棘手、工具链薄弱的年代,大家只能这么做。
但现在已经不是那个时代了。
如果今天我们仍然让大多数项目持续背负这些陈旧的历史成本,那么问题就不只是“生态历史包袱重”,而是我们默许并接受了它继续“重”下去的事实。
所以,真要开始清理,不必一开始就制定宏大的治理方案。回到你自己的项目里,找一个你早就看不顺眼、却又一直没敢动的包,先问它一句:
你到底为什么还在这里?
面对日渐臃肿的 node_modules 和复杂的依赖关系,保持清醒和定期反思是每个前端项目维护者的必修课。技术的本质是解决问题,而非堆积工具。当你在Node.js生态中航行时,不妨偶尔来云栈社区看看,和其他开发者一起聊聊如何构建更清爽、更可维护的工程体系。