node_modules 为什么总是这么大:一次从原理到实践的瘦身记录
做前端做久了,多少都会被 node_modules 气到一次。
明明业务代码没多少,仓库一拉下来,磁盘先掉几百 MB。再来几个子项目,风扇开始转,空间开始红,最后你只能盯着 Finder 里那个熟悉的文件夹发呆。
我以前对这事的态度也很典型:知道它大,但懒得追。直到本地项目越来越多,机器空间越来越紧,我才认真拆了一次,想看看到底是谁在吃磁盘。
拆完之后,结论其实不复杂:
很多时候不是“代码大”,而是“开发环境大”。
真正占地方的,往往是 TypeScript、构建工具、本地运行时、图片处理库、测试环境这些东西。业务源码反而没你想的那么重。
也是因为这次排查,我后面基本把新项目都切到了 pnpm。它不是什么银弹,但在“本地有很多 Node 项目”这个场景里,确实比 npm 省心。
最表层的原因当然是依赖多,但这句话没什么帮助。更准确一点说,node_modules 容易膨胀,通常是几个因素叠在一起。
先是依赖树本来就深。你在 package.json 里看到的是几十个直接依赖,落到磁盘上往往已经变成一大片间接依赖。
装一个构建工具,背后一般会跟出来这些东西:
- 转译器
- polyfill
- 文件系统工具
- 日志工具
- 路径处理工具
- 颜色输出库
- 各种平台兼容层
表面上你只装了一个包,实际上你是把一整串开发链一起搬回来了。
第二个问题更实际:不同项目会重复存同样的依赖。
如果你电脑里只有一个 Node 项目,这个问题还不算明显。可一旦项目变成 5 个、10 个,重复成本就会很夸张。
比如 react、typescript、vite、eslint 这种常见依赖,很多项目都会用。传统安装方式下,这些东西很容易在每个项目的 node_modules 里各放一份。
单看一个仓库好像没什么,放到整台机器上看,就很肉疼。
pnpm 官方文档讲得很直接:如果有 100 个项目都用同一个依赖,npm 可能会在磁盘上留下 100 份;pnpm 则会把包存到一个统一的内容寻址存储里,再通过链接复用。
来源:pnpm Motivation
第三个问题是版本不一致。
很多重复不是因为“你装太多了”,而是因为同一个包在不同地方解析成了不同版本。哪怕只差一点点,也没法完全复用。
比如:
- A 依赖
foo@^1.0.0 - B 依赖
foo@^1.1.0 - C 依赖
foo@~1.1.2
看起来都是 foo,最后落下来的未必是同一个版本。于是磁盘上就得保留多份。
最后还有个经常被忽略的点:node_modules 不只是大,它还很碎。
目录多、小文件多,会让下面这些事情都变得更慢:
- 文件系统元数据开销变大
- 复制、删除、索引、杀毒扫描都会变慢
所以你感受到的往往不只是“占空间”,而是整个开发环境都显得很笨。
二、看一个真实例子:重的不是源码,而是开发环境
前面这些还是泛泛而谈。真正让我下决心换包管理器的,是我把手头项目拆开看了一次。
当时有三块体积特别显眼:
- 主项目的
node_modules:262M worker:169Mwebsite:147M
第一眼确实有点吓人。但继续拆下去以后,事情反而变简单了:业务代码没那么重,真正重的是每个子项目背后的工具链。
1. 为什么 node_modules 会有 262M
这 262M 基本就是主扩展项目自己的前端构建环境。
占得比较多的有:
typescript:22Mhappy-dom:16M- 多个版本的
esbuild:单个大约 9M - 多个版本的
vite/rollup/jsdom
这里还有个很容易看走眼的点。因为项目本身已经在用 pnpm,所以 node_modules 顶层很多包看起来像 0B。那不是真的没占空间,只是软链接。真正的内容在 node_modules/.pnpm/ 里。
所以这 262M 不是“代码写多了”,而是 TypeScript、构建器、DOM 模拟环境这些开发依赖在占地方。
2. 为什么 worker 会有 169M
worker 也差不多,大头几乎全在 worker/node_modules/.pnpm/。本质上是 Cloudflare Worker 这一套本地开发链很重。
主要是这些:
@cloudflare/workerd-darwin-arm64:83Mtypescript:23M@img/sharp-libvips-darwin-arm64:15M@cloudflare/workers-types:10Mwrangler:6.8Mworkerd:4.2Mminiflare:3.0M
换句话说,重的不是业务逻辑,而是:
- Cloudflare 本地运行时
- 类型定义包
- 图片处理库
- 本地调试与编译工具
3. 为什么 website 会有 147M
website 也是同样的路数。主要体积都在 website/node_modules/.pnpm/,背后是 Astro 那套静态站点构建链。
比较显眼的几项:
typescript:23M@img/sharp-libvips-darwin-arm64:15M- 两个版本的
esbuild:各 9-10M @shikijs/langs:9.8Mastro:5.4M@astrojs/compiler:5.1M
再加上 zod、shiki、prismjs 这些,单个看着还行,堆起来就不小了。
所以这 147M 主要还是构建、Markdown 解析、代码高亮相关的依赖,不是网站源码本身。对应的 website/src,当时其实只有 168K。
4. 这组数据说明了什么
这次排查最有意思的地方就在这儿:
node_modules262M:主扩展构建链worker169M:Cloudflare Worker 本地开发链website147M:Astro 官网构建链
真正小的是源码,真正重的是开发环境。
所以很多时候你去折腾业务代码体积,对本地磁盘没什么帮助。空间压力常常不在 src/,而在 TypeScript、构建器、运行时、测试环境、代码高亮、图片处理这些配套工具上。
三、为什么传统办法治标不治本
碰到 node_modules 太大,最常见的反应一般有三种。
第一种,删了重装。
最常见的命令大概是这样:
rm -rf node_modules package-lock.json
npm install
这招有时能清掉一点历史残留,但本质上只是重建,不是瘦身。如果依赖结构没变,装回来还是一样大。
第二种,跑 npm dedupe。
npm dedupe 会尝试把依赖往上提,减少重复项。npm 官方文档的描述是:它会搜索本地依赖树,并尝试简化整体结构,让多个依赖更有效地共享同一个包。
来源:npm dedupe 文档
这当然不是没用,但边界也很明显:
- 它只能在当前项目内部尽量去重
- 它不能解决“多个项目之间重复存储”的问题
- 遇到版本不兼容时,也没法强行合并
所以它更像收拾房间,不是搬家。
第三种,告诉自己以后少装点包。
方向没错,但实际很难靠意志力解决。
因为现在很多体积不是你主动选出来的,而是构建工具、测试工具、代码规范工具一路带出来的。你当然可以克制一点,但光靠“少装几个包”很难真正见效。
四、pnpm 为什么能明显省空间
我后来切到 pnpm,最有价值的地方其实不是“更快”,而是它换了一种存储方式。
简单说,pnpm 不会让每个项目都老老实实拷一整份依赖。
它用的是 content-addressable store,也就是内容寻址存储。可以粗暴理解成这样:
- 同样内容的包,只在磁盘上保存一次
- 各个项目安装时,不再复制整份文件
- 而是通过硬链接、符号链接把它们组织成可用的
node_modules
pnpm 官方文档也专门提到这一点:同版本依赖可以跨项目共享,哪怕版本更新,也只需要为真正变化的文件增加存储。
来源:pnpm Motivation
它还有一个容易让人误会的地方:目录看起来有点怪。
很多人第一次看 pnpm 的 node_modules 都会有点愣,因为里面会多出一个 .pnpm 目录,再通过一层层链接把依赖串起来。
官方文档的解释其实挺清楚:
node_modules中真正的文件会硬链接到全局 store- 再通过符号链接搭出依赖关系
- 这样既能节省空间,也能兼容 Node.js 的模块解析机制
来源:pnpmSymlinked node_modules structure
所以它不是把依赖变没了,而是少做了很多没必要的重复拷贝。
还有一点我自己挺喜欢:它更严格。
传统扁平化 node_modules 有个老问题:有些包明明没写进 package.json,但因为被提升到根目录,项目里居然也能跑。
这种隐式依赖平时不一定出事,一换环境就容易炸。pnpm 默认更严格,通常会更早把这类问题暴露出来。官方文档也提到,非扁平结构的一个好处就是,只有真实依赖图里的包才可访问。
来源:pnpm Symlinked node_modules structure
五、我在实践里是怎么切到 pnpm 的
我切 pnpm 的目标挺朴素,不是为了跑分,就是想解决两个现实问题:
- 本地有多个 Node 项目,重复依赖占空间明显
- 删除和重装
node_modules的成本越来越高
最后我的做法也很简单:新项目直接用 pnpm,老项目慢慢迁。
安装方式不复杂。
如果 Node 版本比较新,我更建议直接用 Corepack:
corepack enable
corepack prepare pnpm@latest --activate
当然也可以全局装:
npm install -g pnpm
已有项目切过去,一般这样就够了:
rm -rf node_modules package-lock.json
pnpm install
如果仓库之前用的是 Yarn,那就把 yarn.lock 也清掉。一个仓库最好只留一种包管理器。
我后来顺手把团队里的习惯也统一了:
- 提交
pnpm-lock.yaml - README 里的安装命令改成
pnpm install - CI 改成
pnpm install --frozen-lockfile
真正的体感变化,其实不在某一个项目上。
切过去之后,最明显的不是某个项目突然小了 90%,而是整台机器上的 Node 项目没那么臃肿了。感受大概来自这几件事:
- 多项目之间重复依赖明显减少
- 重装依赖更快
- 清理和迁移项目时心理负担更小
尤其是你手上同时有这些东西的时候:
- 主业务项目
- 管理后台
- 实验项目
- 脚手架
- 若干 demo
这时候 pnpm 的优势会比单仓库明显很多。
另外,如果你只是想临时回收一点空间,最有效的办法通常也不是删源码,而是删暂时不用的构建产物和依赖目录。
比如:
- 删
extension/dist - 暂时不用哪个子项目,就先删它的
node_modules
还是拿前面的例子说:
worker/node_modules删掉,往往就能直接回收接近 169Mwebsite/node_modules再删掉,又能回收接近 145M
我后来处理磁盘紧张时,基本就是这个思路:不是到处找哪段代码最大,而是先看接下来几天不会碰哪个子项目,然后优先删它的依赖环境。
六、迁移时要注意的坑
pnpm 当然也不是完全没有代价。
第一个坑,是老项目可能会依赖一些本来就不该存在的宽松行为。
有些项目以前在 npm 下能跑,不代表它真的写对了。切到 pnpm 以后,如果某个包没显式声明却偷偷在用,往往就会直接报错。
这事烦归烦,但从长期看不算坏事。只是历史债终于被翻出来了。
第二个坑,是少数工具对符号链接支持一般。
现在主流工具链对 pnpm 基本都挺友好,但少数老工具、老脚本、老插件,还是会默认 node_modules 是传统扁平结构。
如果真遇到兼容性问题,pnpm 也不是没留后路。比如可以把 nodeLinker 设成 hoisted,尽量靠近传统结构。官方文档也明确提到,如果工具链对 symlink 支持不好,可以用这种模式。
来源:pnpm Motivation
所以它不是那种“你要么全信,要么别用”的方案。
第三个坑其实是协作问题。
如果团队里有人跑 npm install,有人跑 pnpm install,锁文件和依赖树很容易来回漂。最省心的办法还是统一:
- 一个仓库只保留一种锁文件
- CI 只认一种包管理器
- 文档、脚本、提交规范全部统一
七、什么时候我会优先推荐 pnpm
如果你符合下面这些情况,我基本都会建议试试:
- 电脑里有很多 Node 项目
- 在做 monorepo
- 经常删装依赖
- 磁盘空间比较紧张
- 希望依赖关系更严格、更可控
反过来说,如果你只有一个很小的项目,团队工具链也已经完全绑在 npm 或旧版 Yarn 上,那这件事可以先往后放。
八、我的结论
回头看,node_modules 占空间这件事,问题往往不在“你写了多少代码”,而在“你养了一整套多重的开发环境”。
删缓存、重装、少装包,这些办法不是没用,只是都偏局部。真正让我体感明显改善的,还是 pnpm 这种从存储模型上减少重复的方案。
它最打动我的地方,不是一句“更快”,而是三件更实际的事:
- 多项目之间真的能复用依赖
- 依赖关系更清楚,隐式问题更早暴露
- 对 monorepo 或多仓库开发很友好
如果你现在也正被 node_modules 折磨,我会建议别只想着“怎么清掉它”,也顺手想一下:是不是该把包管理器一起换了。