实话讲,在用上 npm 的头一两年里,我几乎没有认真看过本地包目录长什么样。直到某天发现 D 盘只剩 2 个 G,打开资源管理器一层层翻下去,才看到真相:全局工具装完一次就再没用过,缓存目录里堆着几个 G 的历史压缩包,十几个项目的 node_modules 里同一个版本的 lodash 被复制了十几份。这种乱象让我决定手写一套极简的本地 NPM 包目录管理方案——不引入数据库、不搞服务端,一个几百行的 Node 脚本加一个 JSON 索引文件,就能把全局包、缓存包、项目依赖这三块地盘统一管起来。方案我实际跑了快一个季度,稳定、够用、对新手也友好。如果你平时用 npm 安装全局工具、在多个项目里反复执行 npm install,并且开始被磁盘占用和依赖混乱困扰,这篇可以直接当作参考模板。
1. 动手之前先看清本地包的三块地盘
很多人一提到"NPM 包目录",第一反应是node_modules,其实这只是三块地盘之一。npm 在你的机器上实际维护着三个互不相通的位置:全局包目录、缓存目录、项目级依赖目录。每一块的产生机制不同、清理方式不同、风险等级也不同。不先把这三块分清楚,后面任何管理方案都是空中楼阁。
1.1 全局包目录:最乱也最容易被忽略
全局包目录就是npm root -g返回的路径。它具体在哪,取决于你安装 Node.js 时选的路径以及 npm 的全局前缀配置。我用npm prefix -g查过自己的机器,Node 装在D:\nodejs,所以全局包集中在一个D:\nodejs\node_modules或者用户级前缀下的node_modules里;如果你当初用的是默认安装,路径大概率落在C:\Program Files\nodejs\node_modules或C:\Users\<用户名>\AppData\Roaming\npm\node_modules。
为什么说它最乱?因为全局安装的包是我们"手动选择"的,但几乎没人记录自己装过哪些。npm 本身也没有类似"最近使用时间"的清单接口,你唯一能拿到的时间信息就是包目录的修改时间。更麻烦的是,很多重量级 CLI 工具自带一大串依赖,装一个表面上很小的包,实际能把几百 MB 带进来。我机器上就出现过@vue/cli占掉 405MB 的情况。
还有一点容易被忽略:全局包对应的可执行文件(bin)在 Windows 上会生成.cmd和.ps1文件放在全局根目录下,而不是放在包目录里。这意味着你光看node_modules下的包目录,未必能对应上命令行里敲的那个命令到底属于谁。这也是 PATH 环境变量配置里经常出问题的地方——很多人报npm不是内部或外部命令,其实不是 npm 坏了,而是 PATH 里没有包含全局 bin 目录。
1.2 npm 缓存目录:大块头都藏在这里
npm config get cache会告诉你缓存目录在哪。Windows 上默认是C:\Users\<用户名>\AppData\Local\npm-cache,Linux 和 macOS 上常见的是~/.npm。这块地盘的实际体积往往比全局包目录还大,因为它保存的是 npm 安装依赖时下载过的所有压缩包和元数据。
npm 的缓存用的是内容寻址(Content-Addressable Storage)方式,同一版本号的包理论上只存一份,不会重复;但不同版本、不同时间的安装记录都会保留下来。你的项目越多、换版本越频繁,缓存就越膨胀。我见过一台普通开发机,缓存常年 3~5GB。
缓存目录最让人头疼的一点:它里面的文件按哈希命名,从文件名完全看不出是哪个包。哪怕你用npm cache verify去检查,也只能看到一些统计信息,无法知道某个具体包占了多少空间。所以很多人的应对方式就是一刀切:直接在磁盘告急时跑npm cache clean --force。这可行,但时机很重要。如果这时候你正准备离线安装依赖,缓存一清,后续npm install就得全部重新下载。特别是配了国内镜像源的场景,清缓存后再安装,网速快还好,网速慢就是一场煎熬。
1.3 项目级 node_modules:重复占用的大户
第三块地盘是每个项目自己的node_modules。这是增量最夸张的目录,同一个版本的 webpack、react、lodash,在十个项目里就有十份物理副本。很多人以为 npm 会做全局去重,实际上 npm 的依赖提升(hoisting)只在单个项目内生效,它不会跨项目共享依赖。npm dedupe也只能优化某个项目内部的重复版本,对于跨项目的重复是无能为力的。
管理项目级node_modules的正确姿势也不是"发现了就删",而是先量化。你需要知道三件事:每个项目的依赖整体多大、顶层直接依赖有多少个、node_modules里有没有不在package.json声明中的"孤儿依赖"。孤儿依赖通常是被依赖提升带上来的传递依赖,直接删可能让某个间接引用断裂,必须谨慎处理。搞清楚这些,你才有资格谈清理。
这三块地盘的分析结论很简单:全局包目录杂乱无主,缓存目录大而无当,项目依赖重复冗余。它们各自的清理策略完全不同,所以我把它们纳入了同一个管理方案里统一看。
2. 方案的骨架:一份 JSON 索引 + 三个子命令
这套方案我起了个名字叫nlpm,全称是 npm local package manager。它不追求做成一个完整平台,只围绕一个核心原则:把"看清楚"和"删得对"分开。看清楚靠扫描,删得对靠人工确认过的清理脚本。整体的骨架非常朴素,配置目录默认在~/.nlpm/下,里面放索引文件、历史报告和生成的清理脚本。
2.1 为什么不直接拿现成工具将就
动手之前我认真比较过现成方案。npkill确实能按目录挑着删 node_modules,但它不看全局包也不管缓存;depcheck和npm-check更关注项目依赖的版本更新和缺失情况,和"目录体积管理"是两个方向。我想要的是同时覆盖全局、缓存、项目三块地盘、并且输出可执行清理建议的小工具,现成选项里没有一个完全贴合。
也考虑过用 PowerShell 脚本或 Python 来写,最后都否了。原因很直接:用这个方案的人大概率已经装好了 Node,用它写脚本不需要额外运行时;读取package.json、调用npm命令、处理跨平台路径都是 Node 的舒适区。何况这个方案本质上是给 npm 用户用的,一个全局 npm 包反而更符合直觉。
安全性上我给自己立了一条规矩:方案本身绝对不做侵入式操作。不移动目录、不创建软链接、不修改任何包的文件,最多只在~/.nlpm里写索引和脚本。因为 node_modules 一旦被动了结构,全局 CLI 可能直接瘫痪,这种风险不值得为省几个 G 去冒。
2.2 目录约定:~/.nlpm 下的东西各管什么
整个方案的目录设计精简到五个文件:
~/.nlpm/ config.json # 三块目录的路径配置 index.json # scan 生成的索引主文件 reports/ # 每次报告的快照,按日期归档 scripts/ clean-xxx.cmd # clean 生成的清理脚本 clean-xxx.shconfig.json里并不需要用户填一堆东西。全局路径和缓存路径都留空,脚本会自动调用npm root -g与npm config get cache去解析;唯一需要手动配置的是项目根目录列表。
{ "global": null, "cache": null, "projects": ["~/code", "D:/work"] }index.json是核心产物。它长这样:
{ "generatedAt": "2025-01-15T10:00:00.000Z", "global": { "location": "D:\\nodejs\\node_modules", "packages": [ { "name": "@vue/cli", "version": "5.0.8", "sizeBytes": 405000000, "modifiedAt": "2024-11-03T08:30:00.000Z" } ] }, "cache": { "location": "C:\\Users\\admin\\AppData\\Local\\npm-cache", "sizeBytes": 3760000000, "archives": 1245 }, "projects": [ { "name": "blog", "path": "D:\\code\\blog", "sizeBytes": 850000000, "depCount": 1563 } ] }为什么用 JSON 而不是 SQLite?因为数据规模撑死几千条记录,JSON 完全够用,而且任何编辑器都能打开检查、调试、手动修复。可读性对排查问题太重要了。
2.3 scan / report / clean:三命令各司其职
命令设计的逻辑我总结成一句话:scan 是照相,report 是看相册,clean 是开药方,药方要不要执行由你决定。
nlpm scan:重新扫描三块地盘,生成新索引,同时把上一份索引归档到 reports。nlpm report --top 20:按大小排序输出文本报表。加--json可以直接输出完整索引,方便接入其他脚本。nlpm clean --dry-run:基于最新索引,按规则算出待清理列表。nlpm clean --confirm:把待清理列表转成可执行的.cmd或.sh脚本,打印路径,不直接执行。
这套分离设计看起来很绕,但恰恰是我认为整个方案里最重要的一点。所有破坏性动作都经过一次人工确认,风险被压到最低。
3. 核心实现:几百行 Node 脚本如何跑起来
这部分我把关键代码和背后的考虑讲透。整个 CLI 没有用commander之类的参数库,直接解析process.argv,能少一个依赖就少一个依赖。
3.1 拿到三块地盘的路径,并搞定 scoped 包扫描
第一步永远是通过 npm 自己获取路径,而不是硬编码。这样无论你把 Node 装在 C 盘还是 D 盘,脚本都能自适应。
const { execSync } = require('node:child_process'); const path = require('node:path'); const fs = require('node:fs'); function getDirs() { const globalRoot = execSync('npm root -g', { encoding: 'utf8' }).trim(); const cacheDir = execSync('npm config get cache', { encoding: 'utf8' }).trim(); return { globalRoot, cacheDir }; }这里有一个隐藏前提:execSync执行的是当前 PATH 里的 npm。如果你连npm命令都找不到,那说明环境变量本身就没配好,得先回到 PATH 配置这一步,再谈目录管理。这和很多人遇到"npm 不是内部或外部命令"是同一个根因。
拿到全局目录之后,扫描包的时候要特别注意 scoped 包。以@vue/cli为例,它在磁盘上的结构是node_modules/@vue/cli,也就是说fs.readdirSync第一层看到的是@vue这个目录,而不是包名。如果直接把@vue当成一个包来统计,结果会完全对不上。正确的做法是:遇到以@开头的目录时,多下钻一层处理子目录。
function listTopPackages(globalRoot) { const result = []; for (const name of fs.readdirSync(globalRoot)) { if (name.startsWith('.')) continue; const full = path.join(globalRoot, name); if (name.startsWith('@') && fs.statSync(full).isDirectory()) { for (const sub of fs.readdirSync(full)) { const scopedPath = path.join(full, sub); if (fs.existsSync(path.join(scopedPath, 'package.json'))) { result.push(scopedPath); } } } else if (fs.existsSync(path.join(full, 'package.json'))) { result.push(full); } } return result; }读取每个包的信息很简单,package.json里已经有name、version、bin字段。真正费劲的是统计体积。
3.2 目录大小统计要防符号链接循环
算目录大小最直接的想法是递归遍历累加文件字节数。这个思路没问题,但实际跑的时候必须处理两个坑:符号链接循环和.bin快捷方式。
现代包管理器(npm 7+ 的 arborist)会在node_modules里创建符号链接来优化重复依赖。如果递归时不加保护,遇到node_modules/pkg/node_modules/pkg指回上层的情况,轻则重复计费,重则栈溢出。我的处理方案是维护一个"已访问真实路径"的 Set,用fs.realpathSync去重。
function getDirSize(root, seen = new Set()) { const real = fs.realpathSync(root); if (seen.has(real)) return 0; seen.add(real); let total = 0; for (const entry of fs.readdirSync(root, { withFileTypes: true })) { const full = path.join(root, entry.name); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { total += getDirSize(full, seen); } else if (entry.isFile()) { total += fs.statSync(full).size; } } return total; }跳过符号链接会丢一部分真实占用,这是有意为之:符号链接指向的内容通常已经在别的目录里统计过,重复加进去只会让数字虚高。.bin目录里的快捷方式同理,它只是入口,不是包本体。
缓存目录的统计方法又不一样。缓存目录里的文件数量可能上万,而且全是哈希名,继续逐文件递归也不是不行,但可以用 Node 20+ 的fs.readdirSync(..., { recursive: true })一次拿到全量列表,再累加文件大小。我在跑的时候发现,统计一个 3GB 的缓存目录通常只需要十几秒,属于可接受范围。
3.3 识别"孤儿依赖"候选并生成清理脚本
项目级 node_modules 的清理风险最高,所以方案里对项目的处理逻辑不是"扫完就删",而是先找出孤儿依赖候选。所谓孤儿依赖,就是存在于node_modules顶层、但不在项目package.json的dependencies、devDependencies、optionalDependencies、peerDependencies声明中的包。
function findOrphans(nmDir, pkgMeta) { const declared = new Set([ ...Object.keys(pkgMeta.dependencies || {}), ...Object.keys(pkgMeta.devDependencies || {}), ...Object.keys(pkgMeta.optionalDependencies || {}), ...Object.keys(pkgMeta.peerDependencies || {}), ]); const orphans = []; for (const entry of fs.readdirSync(nmDir, { withFileTypes: true })) { if (entry.name.startsWith('.') || entry.name === '.bin') continue; if (entry.name.startsWith('@')) { for (const sub of fs.readdirSync(path.join(nmDir, entry.name))) { if (!declared.has(`${entry.name}/${sub}`)) orphans.push(`${entry.name}/${sub}`); } } else if (!declared.has(entry.name)) { orphans.push(entry.name); } } return orphans; }需要注意的是,孤儿候选不等于"该删"。它可能是被 npm 合法提升上来的传递依赖,直接删除会让某些深层 import 瞬间失效。我的方案只把它们列出来,最终的清理动作交给npm prune或者开发者手动判断。
clean --confirm生成的脚本同样不是用来直接执行的,它只是一张"已被确认的动作清单"。Windows 上我生成.cmd而不是.ps1,原因后面细说。脚本内容大致是这个风格:
@echo off REM generated by nlpm on 2025-01-15 call npm uninstall -g @vue/cli call npm cache clean --forcenpm cache clean --force来自缓存超阈值后的建议,而全局卸载命令来自全局包分析。用户看到脚本内容后,可以整段执行,也可以删掉不想执行的某一行。这种"药方模式"让清理过程全程可控,实测反馈非常有效。
4. 实测一个季度:数据、清理动作和踩坑记录
方案写完只是第一步,真正让它可信的是在真实环境里的长期运行。我拿自己的开发机当试验田,跑了完整一个季度,前后数据有明显对比,也踩了不少坑。
4.1 前后数据对照:清理不是归零,是可控
第一次扫描时,我的开发机状态是这样的:
| 项目 | 第一次 scan |
|---|---|
| 全局包 | 76 个,共 2.84 GB |
| 缓存目录 | 3.2 GB |
| 项目 node_modules | 11 个项目,共 18.4 GB |
看起来挺吓人,但实际上真正值得立刻动的没那么多。我按报告做了三轮清理:
- 全局包:卸载了
@vue/cli等 5 个超过半年没更新且确认不再依赖的 CLI 工具,释放约 900MB。 - 缓存:当时缓存超过我自己设定的 2GB 阈值,执行了一次
npm cache clean --force,直接降到约 300MB 的基础量。 - 项目依赖:对 3 个老项目跑了
npm prune,清掉约 1.1GB 的孤儿依赖。
一个季度后再扫描,数据变成了这样:
| 项目 | 一个季度后 |
|---|---|
| 全局包 | 24 个,共 680 MB |
| 缓存目录 | 稳定在 1.6 GB 左右 |
| 项目 node_modules | 13 个项目,共 21.2 GB(新增了两个项目) |
缓存没有继续膨胀回 3GB,是因为阈值触发后脚本会自动提醒;新增项目也让我清楚看到了"又多了 3GB 依赖"的增量来源。管理后的状态不是归零,而是每块盘地的体积和内容随时可查,且行动点明确。
4.2 五个真实踩坑:从 PATH 到 PowerShell 执行策略
第一坑:全局包目录在C:\Program Files下时,卸载需要管理员权限。最初我在普通权限终端里跑卸载直接报 EPERM。解决的路径不是每次都用管理员终端,而是把 npm 全局前缀改到用户目录,例如npm config set prefix "%APPDATA%\npm",然后把新 bin 目录加入 PATH。这个操作要小心,改完前缀之后,原来装在 Program Files 下的全局包不会自动迁移,需要重新安装需要的 CLI。
第二坑:PATH 漏配会导致明明装了全局包却提示"不是内部或外部命令"。在 Windows 上,Node 装在D:\nodejs时,PATH 里至少要包含D:\nodejs和全局 bin 目录。我建议扫描前先用npm prefix -g拿到准确路径,拿到的路径如果不在当前 PATH,报告第一行就应该提醒,而不是等用户敲命令时才发现。
第三坑:PowerShell 执行策略。很多人在 Windows 上直接敲npm会看到这样的报错:npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。这不是 npm 坏了,而是 PowerShell 默认的 Restricted 策略不允许执行.ps1脚本。解决办法是给当前用户放开执行权限:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser或者干脆一直调用npm.cmd而不是npm。正因为这个原因,我生成的清理脚本在 Windows 上坚持用.cmd后缀,绕开执行策略再去多解释一堆为什么。
第四坑:符号链接导致统计爆栈。这是我第一版脚本踩过的坑。某个 monorepo 项目里存在多层符号链接,递归统计时直接把栈打爆。加了realpathSync去重之后问题彻底解决,这个经验在前面代码里已经体现。
第五坑:scoped 包的统计口径。我第一版把@vue当成了一个普通包,报告里出现一个"占 500MB 的 @vue",后来细看目录才发现下面有@vue/cli和@vue/babel-plugin等一串子包。把每个 scoped 子包拆开统计之后,报告的可读性和准确度才真正可以用于决策。
5. 扩展思路:镜像源统计与定时任务
基础方案跑顺之后,我又加了两个扩展点,都属于可选增强,不影响核心的 scan/report/clean 逻辑。
5.1 用 package-lock.json 统计依赖来源
很多开发者会把 npm 的 registry 配成国内镜像源来加速安装,常见的有淘宝源、腾讯源、华为源等。这个现象其实可以利用起来:每个项目的package-lock.json里,每个依赖项的resolved字段都会包含真实下载来源的 URL,从中可以统计出当前项目的依赖来源分布。
const lock = JSON.parse(fs.readFileSync('./package-lock.json', 'utf8')); const hosts = new Map(); for (const key of Object.keys(lock.packages || {})) { const pkg = lock.packages[key]; if (pkg.resolved) { const host = new URL(pkg.resolved).hostname; hosts.set(host, (hosts.get(host) || 0) + 1); } } console.log(hosts);跑完就能看到:有多少依赖来自默认官方源、有多少来自镜像源,是否混用了多个源。混源在一些公司内网环境里会导致锁文件不一致,这个统计能尽早发现问题。
5.2 定时扫描与半自动清理的平衡
第二个扩展是自动化扫描。核心命令只有一行:
nlpm scan && nlpm report --top 10 > ~/.nlpm/report.txt在 Windows 上可以用任务计划程序,在 Linux 和 macOS 上可以用 cron。我自己的节奏是每周一早上跑一次,报告自动生成,不用刻意打开来看,感觉磁盘不对的时候再翻一眼。定时扫描的意义不仅仅是"养成习惯",更是让索引文件保持新鲜,这样当你突然需要判断"该不该清理"时,数据立刻可用。
但在自动化这件事上我有一个强烈倾向:不要全自动清理。全局包和项目依赖的删减都应该保留人工确认这一步。唯一可以考虑自动执行的只有缓存清理,因为它的误伤风险最低,且重新下载依赖的成本通常可以接受。我见过为了省事把自动清理全开了的人,一个月后根本不记得报告里那些包是干什么用的,最后反而更焦虑。
整套方案最让我满意的不是扫出了多少垃圾,而是把安全边界设计得很清晰:扫描只读,清理生成脚本,执行交回给用户。如果你也天天被 npm 目录体积困扰,我建议先跑一次 scan 看看真实数据,再决定动哪一块。你可以完全照抄这里的代码,也可以只借这个思路——反正脚本就几百行,跑上一周,你对本地包目录的把握感会完全不一样。最后提醒一句:改全局目录之前先备份 PATH 和 npmrc 配置,这一点比任何清理操作都重要。