
先说个真实经历。去年我给一个老项目加地图组件package.json里只多了一行依赖结果node_modules直接涨了 300 多 MB装包时间从 40 秒拉到将近三分钟。一开始我以为是网络问题换镜像、清缓存都没用最后用 npm 的依赖树命令一层层翻才发现是那个地图库的某个子依赖又拖进了一套老旧工具链连带装了几十个根本用不到的包。那次之后我就养成了习惯但凡依赖有异常第一件事就是拉依赖树。这篇就把查看 npm 依赖树的完整思路、常用命令和踩过的坑一次讲清楚适合刚接触前端工程化、以及被依赖问题折腾到怀疑人生的同学参考。1. 看依赖树之前先搞清楚你究竟在查什么1.1 三种最常见的查依赖树需求依赖树不是没事看着玩的实际工作中几乎每一次查询背后都有明确诉求。我总结下来绝大多数人打开依赖树就是为了下面三件事之一。第一种是排查体积膨胀。就像我开头遇到的场景package.json看着很干净但node_modules大得离谱。这时候你需要看清某个包到底带了哪些东西进来因为依赖是分层的直接依赖只占一层真正吃空间的是那些藏在深处的间接依赖。第二种是版本冲突。你在项目里写了lodash^4.0.0结果运行时有段老代码还在按 lodash 3 的 API 写报错报得莫名其妙。打开依赖树一看项目里其实装了 lodash 4 和 lodash 3 两个版本一个在根目录一个藏在某个上古依赖的node_modules里。这种多副本现象在现代 npm 的扁平化结构里非常常见。第三种是追溯这个包为什么会出现在我的项目里。有些包你从没直接声明过但它就是被装进来了可能是某个依赖的依赖也可能是某个工具链的附带产物。你自己都不知道它存在的包一旦出了安全漏洞或者版本冲突就需要顺着依赖树一层层往上找看是谁把它拉进来的。1.2 现代 npm 的 node_modules 布局为什么你会看到一堆嵌套目录要真正看懂依赖树得先理解 npm 的目录结构逻辑。npm v2 及更早的版本是纯粹的嵌套式安装每个包把自己所有的依赖都装进自己的node_modules一层套一层。这样做的优点是隔离彻底、版本永不冲突缺点是同一个包会被重复装几十次路径能长到让人崩溃还容易触发 Windows 的路径长度限制。npm v3 之后改成了扁平化加局部嵌套的组合策略能提升的依赖尽量提升到项目根目录的node_modules下提升不了的比如和根目录已有的同名单包版本冲突就装在父级包的node_modules里。这就是为什么你经常能在依赖树里看到deduped标记也能看到同一个名字出现在不同层级。理解这一点之后看依赖树就不会被吓到。那些多出来的嵌套目录不是灵异事件是 npm 在尽量减少重复安装和保证版本满足每个依赖方的 semver 范围之间做的取舍。node_modules 里结构越乱通常说明项目里的间接依赖版本跨度越大。1.3 什么时候你其实不用看完整棵树依赖树信息量大但没必要每次都翻到最深。如果你的项目很小、依赖不超过二三十个、也没出过冲突报错npm ls带个--depth0看一层就足够了这相当于检查我直接依赖的那些包版本是否正常。真正需要展开完整树的时候往往是出现了以下几种信号装包警告里有UNMET DEPENDENCY、运行时提示找不到某模块、npm install之后锁文件出现大量异常变更、或者单纯就是觉得项目体积和使用体验不符。没有这些信号时过度分析依赖树反而浪费时间。2. 主力命令 npm ls参数、输出符号和组合用法2.1 从最简单的接法开始查看依赖树的首选命令是npm lsnpm list和npm la也都是它的别名。在项目根目录直接执行npm ls它会从当前项目出发把node_modules里实际安装的依赖以树状结构打印出来。注意它是实际安装的树不是package.json 里声明的树——这一点很重要因为并不是所有声明过的依赖都装得上也不是所有装上来的依赖都在声明里。我最常用的一条其实是带深度的版本npm ls --depth0这条只打印第一层直接依赖输出非常干净适合快速确认项目的直接依赖状态。如果需要看子依赖再逐步加大深度npm ls --depth1 npm ls --depth2也可以用npm ls --all一次性展开整棵树。我的建议是别急着用--all项目稍微大一点输出能淹没整个终端屏幕反而找不到重点。查看全局安装的包同样简单npm ls -g --depth0这个在排查全局工具版本、确认某个 CLI 工具装到哪个路径时很有用后面讲权限报错时还会再提到。2.2 输出符号对照表这些标记到底在说什么很多人第一次看npm ls输出会被各种括号和英文标记搞懵。其实总共就那么几种状态把它们记住依赖树就基本会读了。标记含义典型场景deduped该包在更上层已有同版本可复用为节省空间被去重同一个包被多个依赖引用但版本一致extraneous当前包不在 package.json 依赖声明里手动安装后忘了--save或安装后又被移除声明invalid已安装但版本不满足依赖声明的版本范围package.json 要求 ^4.0.0但实际装的是 3.xmissing声明了依赖但 node_modules 里没有安装中断、手动删过目录UNMET DEPENDENCY某个包需要的子依赖没有安装子依赖安装失败或版本冲突无法解析optional可选依赖安装失败也不会导致整体失败平台相关包装不上时会跳过举个例子当你看到这样的输出project1.0.0 /path/to/project ├── lodash4.17.21 └─┬ old-tool2.3.0 └── lodash3.10.1这里就是典型的版本副本共存根目录的 lodash 4 和 old-tool 自己嵌套的 lodash 3 同时存在。之所以出现嵌套是因为 old-tool 声明依赖lodash^3.10.0和项目直接声明的^4.0.0冲突npm 无法用同一个版本同时满足两边只能在 old-tool 下面单独放一份旧的。如果看到extraneous或者UNMET DEPENDENCYnpm ls会以非零状态码退出这在 CI 里可以当成一道校验防止有人把不该有的依赖提交上去。2.3 输出格式参数JSON、parseable 和长格式除了树状字符串npm ls还支持几种程序化输出格式排查问题时非常有用。# 输出为 JSON方便脚本处理 npm ls --json # 每行一个依赖便于 grep 和排序 npm ls --parseable # 长格式附带依赖的 resolved 地址等信息 npm ls --long我实际用最多的是--parseable配合grep。比如我只想知道 lodash 到底被装在哪几个路径下npm ls --parseable | grep lodash输出结果里每一行是一个完整路径一眼就能看出 lodash 是装在根目录还是嵌套在某层。配合--all能查得更全。JSON 格式我一般在写自动化脚本时用比如定期扫描项目里哪些依赖出现了多副本。--long模式下能看到每个包从哪里被安装的虽然啰嗦但排查诡异问题时经常能发现关键线索。还有两个常用过滤参数# 只看生产依赖 npm ls --omitdev # 同时想看开发依赖就去掉 omit或反过来 npm ls --includedev默认输出包含生产依赖和开发依赖如果只想关注运行时依赖--omitdev非常实用。3. 真实案例两个 lodash 版本共存怎么定位并处理3.1 现象还原与初步定位说个实际发生过的排查过程。项目运行一段时间后某天构建突然报错错误信息指向 lodash 的_.flatten方法不存在。我心里很疑惑_.flatten明明从很老的版本就有怎么会不存在结果去node_modules/lodash里一翻发现根目录装的是 4.17.21API 已经改了_.flatten在 4.x 里改名叫_.flattenDeep。但项目里有段第三方代码它内部依赖的还是 lodash 3按 3.x 的 API 调用而 npm 为了让这段代码能跑本来应该给它装一份独立的 lodash 3。问题就出在某个依赖升级后lodash 3 的嵌套副本被去重逻辑误删了导致老代码运行时找到了根目录的 lodash 4。第一步当然是打开依赖树确认现状npm ls lodash输出果然成了这样project1.0.0 /path/to/project ├── lodash4.17.21 └─┬ legacy-plugin1.8.0 └── UNMET DEPENDENCY lodash^3.10.0UNMET DEPENDENCY明晃晃地摆在那说明 legacy-plugin 要求的 lodash 3 并没有装上。这时候问题从依赖树怎么会有两个版本变成了为什么 npm 没有装出第二个版本来。常见原因有两个一是安装过程中使用了--legacy-peer-deps之类的参数导致部分依赖被跳过二是某个 lockfile 损坏或手动删除过文件夹依赖记录对不上。3.2 用 npm explain 精确回溯引入路径定位到子依赖缺失后下一步是找出 legacy-plugin 究竟是被谁带进来的。如果项目里 legacy-plugin 也不是直接依赖就需要一条一条向上回溯。推荐直接用npm explain这是 npm 8.7 之后内置的命令npm explain lodash它会列出 lodash 在依赖树里所有存在的位置以及每一处是经由哪些依赖链到达的。输出类似下面这样lodash4.17.21 node_modules/lodash project1.0.0 /path/to/project └── project - lodash^4.17.21 lodash3.10.1 node_modules/legacy-plugin/node_modules/lodash legacy-plugin1.8.0 └── legacy-plugin - lodash^3.10.0npm explain比人肉翻树高效太多。它本质上是把npm ls --all --json的结果按包名反查了一遍把所有指向该包的依赖路径汇总出来展示。我在 npm 7 时代遇到这个问题只能用一串npm ls --parseable | grep手工拼链路有了npm explain后基本一步到位。如果当前 npm 版本太老升级一下或者临时用npx npmlatest explain 包名都能救急。3.3 处理冲突npm dedupe、overrides 和升级上游定位清楚之后解决方案有几种按推荐顺序排列。第一种是尝试让 npm 自己去重归位npm dedupenpm dedupe会尝试把嵌套的重复依赖提升到更上层或者为缺少的版本重新装上。它解决的是结构混乱问题但对我的案例并不完全适用因为 legacy-plugin 需求的 3.x 和根目录的 4.x 确实版本冲突提升不上去需要的是补装而不是去重。第二种是显示声明整个项目强制覆盖某个版本。npm 8 之后的overrides字段很实用在package.json里加上{ overrides: { legacy-plugin: { lodash: ^3.10.0 } } }重装之后依赖树就变成两份独立的 lodash各用各的版本互不干扰。注意overrides是强制的它会让 legacy-plugin 锁死在你指定的 lodash 版本上所以只在真正需要时用。第三种也是最推荐的根治方式升级上游。如果 legacy-plugin 的新版本已经兼容新 API直接升级它让旧的 lodash 3 退出舞台npm install legacy-pluginlatest npm ls lodash升级后如果只剩一个 lodash 4.17.21那问题才算真正解决。处理完任何一次依赖结构调整我都建议立刻跑一遍npm ls --depth1加完整测试防止 dedupe 或 overrides 让某个老模块意外拿到了不兼容的版本。4. 看依赖树时绕不开的报错执行策略、权限和镜像4.1 npm.ps1 无法加载文件PowerShell 执行策略导致的假失败很多同学第一次跑npm ls -g --depth0或者任何全局 npm 操作时报的第一条错不是什么目录结构问题而是下面这句npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1因为在此系统上禁止运行脚本。这跟依赖树一点关系没有是 Windows PowerShell 默认执行策略限制造成的。npm 是通过.ps1脚本启动的而当前系统的执行策略是RestrictedPowerShell 不允许任何脚本运行。解决办法有两种我建议用用户级别修改不要动系统级别Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser这条命令只对当前用户生效允许运行本地脚本远程下载的未签名脚本依然会被拦截安全性可控。改完再打开终端验证Get-ExecutionPolicy -List看到CurrentUser那一行是RemoteSigned就对了。如果公司环境不允许改策略也可以不经过 PowerShell直接用cmd或者 Windows Terminal 里切到命令提示符执行 npm绕开.ps1启动链路。这个小问题是经验性报错跟依赖树本身无关但很容易在排查链路初期浪费大量时间所以先把它按死。4.2 全局目录没有写权限npm prefix 与工具自动更新失败另一个高频报错看起来像是少装了包实际是权限问题Error: EACCES: permission denied, mkdir /usr/local/lib/node_modules/xxx npm error: no write permission to npm prefix在 Windows 上表现通常是全局工具装完无法自更新比如有些 CLI 工具的auto-update failed就明确写着no write permission to npm prefix。这个报错的逻辑很简单全局包的安装目录本身没有当前用户写权限或者当前用户的 npm 前缀指向了一个受保护的系统目录。先确认 npm 认为的全局路径是什么npm config get prefixWindows 常见输出是C:\Users\你的用户名\AppData\Roaming\npmmacOS/Linux 上常见是/usr/local或/usr/local/lib/node_modules。如果 prefix 指向系统保护目录而且你平时不用 sudo 装东西最简单稳妥的办法是把全局目录改到用户目录下npm config set prefix $HOME/.npm-globalWindows 上也可以把 prefix 指向%APPDATA%\npm并确保该目录有写权限。改完 prefix 之后之前装在旧目录的全局包不会自动迁移需要把 PATH 和已有工具一起整理一遍。你可以在依赖树里检查一下当前全局到底装了什么npm ls -g --depth0用这条命令确认迁移后的全局依赖状态顺便看看有没有残留的extraneous包。注意全局依赖的树状输出和本地项目逻辑一样只是根节点变成了全局 prefix 目录。4.3 镜像源配置换了源之后依赖树对不上怎么办排查依赖树时有时会发现npm ci或npm install装出来的结果和 lockfile 记录的树结构不一致。这种诡异现象很大概率和镜像源切换有关。很多人会在.npmrc里配置镜像源加速npm config set registry https://registry.npmmirror.com镜像源本身没有问题但如果你在一个已经生成过package-lock.json的项目里切换了镜像源而新源上某些包的 metadata 与原源不一致npm 会选择重解析一部分依赖导致 lockfile 与最终安装结果出现对不上的情况。遇到这种问题我的处理步骤是先备份package-lock.json。删除node_modules和 lockfilerm -rf node_modules package-lock.jsonWindows 上用rimraf或直接删文件夹。重新安装npm install。用npm ls --depth1对比前后差异。这样做的同时我还会检查一下本地 npm 配置有没有历史残留用npm config get registry确认当前生效的源到底是哪个。很多时候不是换源本身有问题而是多人协作时各人本地配置不一致同一份 lockfile 在不同机器上装出了不同版本的依赖树。5. 比肉眼更快一步的工具以及我现在的日常习惯5.1 npm explain 和 npm-why 的组合拳前面已经介绍了npm explain这个内置命令这里再补一个我常用的社区工具npm-why。它的定位和npm explain相似但输出更口语化专门回答一个问题我明明没直接装这个东西它为什么会出现在依赖树里npx npm-why lodash运行后会输出 lodash 存在的所有依赖路径并且标注链路上每一层是直接依赖还是间接依赖。我在快速排查时习惯这样用npm-why先给结论npm explain再看详细链路上的版本和来源两者配合比单用其中一个省事很多。注意npm-why本质上是扫描本地node_modules它要求目标包已经安装所以在还没装起来的环境里不适用。5.2 yarn why 和 pnpm why换包管理器后的对应操作如果你不是 npm 单一用户或者手里同时维护着 npm、yarn、pnpm 的项目查询依赖树的命令其实是同一套心智模型。yarn 对应的是yarn why lodashpnpm 对应的是pnpm why lodash两者都支持跟包名查询为什么这个包会被安装也都能列出具体的依赖链。pnpm 还有额外的结构优势它默认使用硬链接加内容寻址存储不会像 npm 那样产生大量重复副本所以pnpm ls输出里几乎看不到deduped这类标记。如果你的项目里同时存在几个不同的锁文件要学会区分当前的包管理器是哪个不要拿 npm 的package-lock.json去对应 pnpm 的pnpm-lock.yaml工具和答案对不上时所有排查都是白费。5.3 把依赖树导出成图来自 JSON 的最后一步当依赖树特别大、终端输出已经没法看清结构时我的兜底方案是导出 JSON 再转成可视化图形。npm ls本身就支持 JSON 输出npm ls --all --json deps.json拿到deps.json后可以写一个很小的脚本把它转成 Graphviz 的 DOT 格式再用dot命令生成图片。下面是一个我实际用过的简化版 Node 脚本const deps require(./deps.json); const edges []; function walk(node, parent) { if (parent) { const pkgName node.name node.version; edges.push(${parent} - ${pkgName}); } const children node.dependencies || {}; for (const key of Object.keys(children)) { const child children[key]; // node 的依赖在 json 里是以名字为键的 if (child child.version) { const childName child.name || key; walk({ ...child, name: childName }, parent || node.name node.version); } } } walk(deps, null); console.log(digraph deps {); edges.forEach((line) console.log( line ;)); console.log(});然后执行node convert.js deps.dot dot -Tpng deps.dot -o deps.png生成的图里每个节点是一个唯一版本每条边是从上层依赖指向下层依赖。肉眼找严重循环依赖或者巨型冗余版本时图片比终端滚动输出直观得多。这个脚本比较粗糙但作为应急排查已经够用。5.4 我现在的依赖维护习惯看完这篇你大概率不会被依赖树和各种奇怪报错吓到了。最后分享几个我在实际工作中验证过的小习惯。第一所有项目里都要在 CI 流程加一道依赖检查比如npm ls --depth0作为构建前置步骤。只要有invalid、extraneous、UNMET DEPENDENCY出现构建直接失败问题在合并前就被拦住了比线上炸了再查省太多事。第二升级依赖后必查依赖树。不管是大版本升级还是小版本升级升级完我都会跑一次npm ls 关键包确认版本确实落在预期区间同时看一眼有没有多出奇怪的嵌套副本。第三遇到莫名其妙的运行时错误先花两分钟查依赖树再改代码。很多时候代码没错但跑不起来的真相是某个依赖在树里装了三份不同版本而运行时加载到的恰好是不兼容的那个。这时候加补丁代码毫无意义把依赖树理清楚一个版本解决问题。依赖树这东西不会天天用但属于每个前端和 Node 开发者必须掌握的基本功。不用畏惧终端输出里的层层嵌套只要会看符号、会追链路、会用npm explain大部分和依赖相关的疑难杂症都能在几分钟内找到方向。