12k
All articles

出问题时该用的 npm 命令

使用 npm ls、npm explain、overrides 和 npm ci 追踪意外依赖,修正错误版本,并避免 lockfile 偏移。

OpenReplay Team
OpenReplay Team
出问题时该用的 npm 命令

node_modules 里出现了一个 package.json 中并没有任何地方要求安装的包,或者出现了一个你并未固定(pin)过的版本时,先运行 npm ls <package> 查看它所处的位置,再运行 npm explain <package> 查看是哪个依赖把它引入进来的——在动手改任何东西之前。

每个开发者都有过这样的时刻:盯着 node_modules 里的某个版本号,心想”你到底是从哪来的?”接下来的条件反射也很熟悉:依赖树看起来不对,于是 rm -rf node_modules、重新安装,然后祈祷。有时问题消失了,但更多时候它会立刻卷土重来——因为安装器用同样的输入重建了同一棵依赖树,而现在你已经无从得知到底改变了什么。

本文将完整走一遍一次排查过程:一个意料之外的包或版本,追溯到请求它的那个依赖,然后在正确的层面上修复它。安装期的失败,例如 ERESOLVE、EACCES 以及原生模块构建错误,本博客已有其他文章覆盖,可参阅修复 ERESOLVE 冲突EACCES 权限错误node-gyp 构建失败的指南。本文针对的是另一种情形:还没有抛出任何错误。

关键要点

  • npm ls <package> 会显示一个包在已安装依赖树中出现的每一处位置,以及每处的版本;npm explain <package> 会显示请求它的依赖链条。
  • 不加 --all 时,npm ls 只列出你的直接依赖;加上 --all 会打印完整依赖树,而 --depth=<n> 可以在这两个极端之间设定一个明确的截断层级。
  • npm whynpm explain 的别名,因此同一个词在 npm、pnpm 和 yarn 中都适用。
  • package.json 中的 overrides 字段可以强制指定嵌套依赖的某个版本,而不管其父包声明的范围是什么——这也正是为什么应当先尝试升级父包。
  • npm ci 要求已存在 package-lock.json,它会删除 node_modules,严格按照 lockfile 的内容安装,并在 lockfile 与 package.json 不一致时以错误退出。

为什么删除 node_modules 会毁掉证据?

删除 node_modules 并重新安装,会抹掉关于某个意外的包是如何进入你项目的唯一记录。已安装的依赖树和 package-lock.json 共同编码了 npm 做出的每一个解析决策:哪个父包请求了哪个版本范围、哪个版本满足了它、以及结果最终落在磁盘上的什么位置。

重新安装会基于 package.json 和 lockfile 重放这些决策。如果输入没有变化,你会得到同一棵树和同一个意外。如果输入变了(某个配置项、某个 registry、某次范围修改),那么重新安装会覆盖掉你本来需要用来做对比的状态。无论哪种情况,都要先读取依赖树,再去重建它。读取依赖树的两个命令是 npm lsnpm explain

npm ls:这个包在哪里,版本是多少?

npm ls <package> 会把已安装的依赖树筛选为以指定包结尾的路径,并将每个位置打印为 name@version,其上方以缩进形式列出各级父包。你也可以按版本范围过滤,例如 npm ls semver@^6,适用于你只关心某个特定主版本下的那些副本时。

# Every copy of semver, with the path down to each
npm ls semver

# The complete tree, not just direct dependencies
npm ls --all

# Cap the walk at two levels
npm ls --all --depth=2

# Only what ships to production
npm ls --all --omit=dev

除非传入 --all(此时变为 Infinity),depth 设置默认为 0。这个默认值作用于不带包名参数的裸 npm ls。一旦你指定了某个包名,npm 就会不受深度限制地追踪到每一个副本的路径——这也是为什么官方文档中自己的 npm ls promzard 示例在不加 --all 的情况下也能显示出一个嵌套的命中结果;如果你想限制这种遍历,就显式传入 --depth=<n>

npm 打印出来的是一张”谁依赖谁”的映射图,因此它不会与磁盘上文件夹的实际布局一致:一个被去重(deduplicated)的包会出现在每一个需要它的父包之下,而不仅仅出现在其文件实际存放的那一处。输出还会标记出多余的包(extraneous,即已安装但未声明)、缺失的包,以及版本不满足已声明范围的包;缺失的包会带有 UNMET DEPENDENCY 标签显示出来。加上 --package-lock-only,npm 会报告 lockfile 将会生成的那棵依赖树,而忽略当前 node_modules 中的实际内容。

两点关于写法的说明。当前的过滤选项是 --omit=dev--include=dev;--production--omit=dev 的已弃用别名,--dev--include=dev 的已弃用别名,而 --development 根本不是一个有文档记载的选项。另外,当某个包缺失、版本无效,或指定的包名没有匹配到任何结果时,npm ls 会以非零状态码退出,这使它可以用作 CI 检查;而仅有多余的包本身并不会导致其失败。

npm explain:是谁要求安装这个包的?

npm explain <package> 会针对每个已安装的副本,打印出导致它存在的依赖声明链条,并一路向上追溯直至到达根项目。npm ls 回答的是”在哪里”,npm explain 回答的是”是谁”。

npm explain semver
npm why semver              # identical
npm explain semver --json   # for jq

输出中的每个区块以解析出的 name@version 及其 node_modules 路径开头,随后每跳缩进一行:父包声明的范围、父包自身的版本、以及父包的路径,最后以指明根项目的一行结束。自下向上阅读,就能顺着你的 package.json 一路看到那个你没有预料到的副本。重复安装的包会为每个副本各输出一个区块,因此相互冲突的版本范围能够并列显现。你也可以传入一个文件夹路径,例如 npm explain node_modules/foo/node_modules/semver,以便只解释某一个特定的嵌套副本。

npm explain 命令概要why 列为其别名,而其他几个主流包管理器也使用同一个动词。

包管理器命令输出形态
npmnpm explain <pkg>npm why <pkg>每个已安装副本一个区块,链条向上追溯至根
pnpmpnpm why <pkg>一棵倒置的树,你查询的包位于顶部
Yarnyarn why <pkg>按 workspace 给出原因,接受 pkg@range

应该升级父包,还是添加 override?

一旦 npm explain 指明了请求那个有问题范围的父包,首选的修复方式就是把该父包升级到一个声明了更合适范围的版本。运行 npm outdated <parent> 查看是否存在更新的版本,或者用 npm view <parent>@latest dependencies 读取 registry 中该父包的 package.json。如果更新的父包声明了可接受的范围,就升级它,让 npm 重新解析子依赖。

只有在没有任何父包版本能修复该范围时,才应该动用 overrides:

{
  "overrides": {
    "semver": "^7.5.4"
  }
}

override 会替换嵌套依赖的版本,而不管父包声明的范围是什么,因此父包现在可能会运行在一个从未经过测试的版本之上。这就是其中的取舍,也正是 overrides 属于第二选择而非首选的原因。文档中有几条规则:overrides 只在根 package.json 中生效;对于你直接依赖的包,只能用与其自身声明完全相同的 spec 来 override,否则 npm 会抛出 EOVERRIDE——$name 引用写法正是为这种情况而存在;取值可以是一个精确版本、一个范围、一个 dist-tag,或者一个 npm:file:、Git 形式的 specifier。如果你希望它只作用于依赖树的某一个分支而非全局生效,就把 override 嵌套在父包名称之下。

npm config list:那些你忘了自己设过的配置

npm config list 会打印由你本人、你的环境或某个 .npmrc 文件所设置的配置项;npm config list -l 还会打印 npm 的默认值,而 --json 则以 JSON 形式返回同样的数据。当依赖树的解析结果无法仅凭 package.json 解释时,原因常常是某个谁都不记得写过的配置值。

npm config list
npm config list -l

输出按来源分组(命令行、环境变量、项目级 .npmrc、用户级 .npmrc、全局),这能告诉你该去改哪个文件。有两个键值最值得优先查看。非默认的 registry 意味着版本是针对某个镜像源或私有 registry 解析的,而其内容可能落后于公共源。已保存的 legacy-peer-deps 设置会让 npm 在构建依赖树时完全不考虑 peerDependencies,即回到版本 6 之前的行为,于是你可能得到当前解析器本会拒绝的版本组合。这还有连带影响:一旦 lockfile 是在该标志下生成的,之后每一次 npm ci 也都需要它,否则安装就会失败。项目级 .npmrc 中一行被遗忘的配置,就足以同时解释本地一棵怪异的依赖树和一次 CI 红灯。

npm ci 与 npm install:lockfile 不一致时会发生什么?

当 lockfile 满足 package.json 时,npm install 会使用 lockfile 中的精确版本;当不满足时,npm install 会重新解析并更新 package-lock.json。而 npm ci 则会直接报错。

行为npm installnpm ci
要求存在 package-lock.json
lockfile 与 package.json 不一致重新解析,重写 lockfile以错误退出
已存在的 node_modules复用先删除
写入 package.json 或 lockfile从不
添加单个包可以不可以

npm install 文档对优先次序说得很明确:package.json 中的版本范围才是权威来源,而 lockfile 只有在其固定版本仍然落在这些范围之内时才会被保留。这恰恰是你在 CI 中最不想要的行为——被悄悄重写的 lockfile 会掩盖你正试图捕捉的漂移。npm ci 拒绝去调和这两个文件,而是明确地失败,所以请在流水线中使用它,并把 npm install 留给那台你确实打算修改依赖的机器。

结论

依赖树中一个意料之外的包,本质上是一个留有书面记录的解析决策,而 npm lsnpm explain 能在不破坏这份记录的前提下读取它。沿着链条追溯到声明了那个范围的父包,如果存在更合适的发布版本就修复父包,只有在没有时才使用 override,然后检查 npm config list,看看是否有配置项在一开始就扭曲了解析结果。在 CI 中运行 npm ci,这样下一次出现不一致时构建会直接失败,而不是默默重写 lockfile。

常见问题

npm ls 输出中包旁边的 'deduped' 是什么意思?

'deduped' 标签表示 npm ls 正在逻辑依赖图中的该位置显示这个包,但那里并不存在一份独立的副本:node_modules 中位于更上层的单一已安装副本已经满足了该父包的范围要求。这不是错误。由于 npm ls 打印的是逻辑依赖树,同一个包会出现在每一个需要它的父包之下,而只有未带标签的那一行对应着一个物理文件夹。

如何移除 npm ls 报告为 extraneous 的包?

运行 npm prune。它会删除 node_modules 中任何其他包都不依赖的内容;指定一个或多个包名可将其限定到这些包。加上 --omit=dev,或将 NODE_ENV 设为 production,你的 devDependencies 也会被一并删除。可先用 --dry-run 查看执行计划,用 --json 以 JSON 形式获取变更结果。安装过程本身就已会自动清除多余的包,因此你基本只会在崩溃或安装中断之后才需要这个命令。

npm dedupe 能修复 npm ls 显示的重复版本吗,还是必须用 overrides?

npm dedupe 只会合并已声明范围本就允许合并的副本。它会遍历依赖树,把每个依赖尽可能地提升到更高层,于是范围有重叠的父包最终会共享同一份副本,而且它绝不会从 registry 拉取任何新内容。如果两个父包要求的范围没有任何共同版本,两份副本都会保留,此时的解决办法是升级某个父包或添加一条 overrides 条目。npm find-dupes 以 dry run 的方式执行同样的流程,因此你可以先查看结果。

如何列出全局安装的 npm 包?

运行 npm ls -g。--global 标志会让 npm ls 指向全局 prefix,列出安装在那里的包而非当前项目中的包。同样的深度规则依然适用:不加 --all 时它只打印顶层全局包,而 npm ls -g --all 会把每个包展开为其完整的依赖树。加上明确的 --depth 值可限制遍历深度,或用 --json 获得机器可读的输出。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.