1. 依赖解析到底在解决什么问题
1.1 从一次版本崩溃说起
先讲一个我实际经历过的场景。某天我把一个内部工具包升到新版本,顺手把这个包从 dependencies 挪到了 devDependencies,然后跑测试。结果测试环境一切正常,生产构建却抛出一堆莫名其妙的类型错误,报错指向同一个三方库的同一个方法签名对不上。
我第一反应是“缓存坏了”,清掉 node_modules 重新 install,还是报错。最后用yarn why顺着依赖链往上查,发现问题出在:生产环境里某个底层库被解析到了 2.x,而开发环境因为另一个工具链依赖,把它压回了 1.x。同一份 package.json,在不同依赖组合下产生了不同的解析结果。
这就是依赖解析的问题。Yarn 的选择性依赖解析,说白了就是帮你回答一个问题:项目里某个包到底该装哪个版本,以及这个决定是怎么做出来的。它的核心价值不在于“自动装最新版”,而在于让你有能力在多层依赖交错时,主动干预解析结果,保证不同环境下拿到的是同一套依赖。
这篇文章适合两类人:一类是正在被 node_modules 体积膨胀、类型冲突、构建不稳定折磨的工程负责人;另一类是想彻底搞懂yarn.lock和resolutions机制、准备从 npm 迁移到 Yarn 的开发者。我会把解析机制的底层逻辑、命令行工具的使用方法、以及实际踩过的坑都拆开讲清楚。
1.2 选择性依赖解析的目标拆解
在 npm 早期,依赖是严格嵌套的。A 依赖 B,B 依赖 C,那 C 就会被装到 B 的 node_modules 里。好处是“绝对隔离”,坏处是三重嵌套之后目录爆炸,同一个包可能存在十几个副本,每个副本版本还不一样。更麻烦的是,有些包对同一版本的 C 也有自己的期望,但你根本没有办法统一。
Yarn 的依赖解析机制,目标可以拆成三层:
第一层:确定唯一版本。对于一个包的同一个版本范围,在锁文件里只保留一个解析结果。这就是选择性解析的基础——先决定“选哪个版本”。
第二层:尽量扁平化。把可以共存的依赖提升到顶层 node_modules,减少重复安装。这部分机制在 Yarn 1.x 里叫 hoisting,通过启发式算法决定哪些包能提升,哪些必须留在原地。
第三层:允许人为干预。当默认启发式算法给出的结果不满足需求时,Yarn 提供了resolutions字段、nohoist配置、以及 Berry 版本里的nmHoistingLimits,让开发者可以绕过默认策略,强制执行某个解析版本或禁止提升。
理解这三层,再看“选择性”这个词就清楚了:不是 Yarn 帮你随便选,而是它在确定性和灵活性之间做权衡,同时把选择权留给了你。
2. Yarn 解析机制的核心细节
2.1 semver 范围与解析优先级
Yarn 决定“选哪个版本”时,会经历一个从宽到窄的收敛过程。
首先,它会读取 package.json 里声明的 semver 范围。比如"lodash": "^4.17.0",这个范围允许 4.x 内所有大于等于 4.17.0 且小于 5.0.0 的版本。下一步,Yarn 会去看 registry 上这个包里有哪些版本符合条件,从中挑一个。问题来了:在没有锁文件时,它通常会挑满足范围的最新版;有了 yarn.lock 之后,它会先看锁文件里有没有已经锁定的解析结果,如果有就直接复用,不再查询 registry。
这里容易踩坑的是 tag 的使用。发布 npm 包时经常会打next、beta这类 dist-tag,Yarn 在解析"package": "next"时会直接解析到next对应的版本。这种写法会绕过版本范围的约束,如果某天nexttag 被指向了一个破坏性的版本,yarn.lock 也救不了你,因为 tag 的含义是动态的。
实际项目里,我见过不少团队把依赖写成"some-lib": "latest"。这是非常危险的做法,因为latest本身就是一个动态 tag,每次安装都可能解析到新版本。锁文件虽然能锁定第一次安装时的版本,但只要有人删掉 lockfile 重新安装,结果就会漂移。这是“选择性解析”失效的高发场景之一。
2.2 yarn.lock 是如何锁定解析结果的
yarn.lock 不是把 node_modules 里每个包的物理位置记下来,而是记录“解析请求”与“实际解析结果”的映射关系。
以 Yarn 1.x 的 lockfile 为例,一个典型条目长这样:
lodash@^4.17.0, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz" integrity sha512-...左侧的 key 是所有引用方声明的版本范围列表。当多个依赖都要求^4.17.0或^4.17.21时,它们会共享同一个解析结果,也就是 4.17.21。这条机制是 Yarn 去重的核心:只要版本范围兼容,就不需要安装两份不同的 4.x。
Yarn Berry(2.x 及以上)的 lockfile 格式不同,采用resolution结构:
lodash@npm:^4.17.0, lodash@npm:^4.17.21: version: 4.17.21 resolution: integrity: sha512-...注意这里的 key 带了npm:协议前缀。Berry 把依赖协议(npm:、workspace:、patch:等)一并纳入解析范围。这意味着不仅版本范围会影响解析结果,获取方式也会影响。workspace:*和npm:^1.0.0指向的是同一个包的两种不同来源,此时 Yarn 会认为它们是两个不同的解析输入,可能产生重复安装。
2.3 resolutions 字段的干预逻辑
如果 semver 范围和 lockfile 已经足够确定,为什么还需要resolutions?
因为默认解析规则只关心版本范围,不关心“业务上的最优版本”。比如 A 包依赖某个库的^1.0.0,B 包依赖^2.0.0,Yarn 的默认逻辑会让这两个范围各自解析成两个不同的大版本,导致该库被安装两份。但你可能不想看到两份,或者你发现 B 包声明的^2.0.0里有 bug,希望强制让 B 也使用你测试过稳定的1.5.0。
resolutions的写法在 Yarn 1.x 里是:
{ "resolutions": { "some-lib": "1.5.0" } }它会强制所有解析到some-lib的依赖,最终都落到 1.5.0。注意,这里的强制是“全局”的。它不关心是哪个父级依赖引用了some-lib,只要范围能被 1.5.0 满足,就都会被替换。
但有个限制需要在 Yarn 1.x 里特别注意:resolutions不能干预 peerDependencies 的解析结果。Peer 依赖的解析发生在包安装之后,由宿主环境决定,Yarn 1.x 无法通过resolutions强制改变。Yarn Berry 在此基础上有更多扩展,但也会区分普通依赖和 peer 依赖的处理方式。
2.3.1 resolutions 的通配符写法
除了直接指定版本,resolutions还支持通配符,可以更精细地控制某个范围内的包。
{ "resolutions": { "**/some-lib": "1.5.0", "@my-scope/**": "1.2.3" } }**/some-lib表示不管路径多深,所有some-lib都强制到 1.5.0。@my-scope/**则是对某个 scope 下所有包生效。通配符的价值在于:如果你只想干预某个特定父依赖下的子依赖,可以写成parent-lib/some-lib,但这在 Yarn 1.x 里并不总是可靠,需要测试验证。
实际操作中,我倾向于把resolutions的用法收敛到几种明确场景:统一版本避免类型重复、修复某个间接依赖的安全漏洞、兼容 lockfile 中被错误解析的 peer 依赖版本。频繁使用**/通配符会让依赖解析变得难以预测,不推荐当作常规手段。
2.4 peerDependencies 的解析特殊性
Peer dependencies 的设计意图是“我依赖于你提供某个包,但我不直接安装它”。最经典的例子是 React 组件库:组件库本身不装 React,而是要求宿主项目提供 React。这种模式的好处是避免同一个应用程序里出现两份 React,坏处是一旦宿主提供的版本不满足组件库的 peer 范围,就会产生运行时错误。
Yarn 在解析 peerDependencies 时,会向上查找 node_modules 里已有的包。如果找到的版本符合 peer 范围,直接用;如果不符合,Yarn 1.x 会给出警告,有时甚至会把 peer 依赖隐式安装一份副本,导致“虽然表面上只有一个 React,实际上运行时混用了两份”的诡异问题。Yarn Berry 在这方面做了严格化处理,不满足要求时会直接抛出ERR_PNPM_PEER_DEP_ISSUES之类的错误(这里串了概念,Berry 实际是报YN0060之类的警告或错误),要求开发者显式安装 peer 依赖。
这套行为的差异,直接影响了在不同 Yarn 版本之间的切换体验,也是选择“选择性依赖解析”时最容易忽略的隐性成本。
3. 实操:用 Yarn 完成一次完整的依赖解析控制
3.1 构造一个可复现的冲突场景
纸上谈兵没意思,我用一个模拟项目来演示。假设我们要开发一个内部组件库,它依赖dayjs,同时某个工具链依赖moment,而moment的一个插件依赖dayjs的^1.9.0,直接写在项目 dependencies 里的dayjs则要求^1.11.0。
项目 package.json 初始大概长这样:
{ "name": "demo-project", "version": "1.0.0", "dependencies": { "dayjs": "^1.11.0", "moment": "^2.29.0", "some-toolchain": "^1.2.0" } }直接执行:
yarn install然后查看 node_modules 目录,大概率会看到两个dayjs副本:一个在顶层,版本是 1.11.x;另一个嵌套在some-toolchain下面的 node_modules 里,版本是 1.9.x。如果这两个版本的类型定义不一致,就会出现“同一个包有两个类型来源”的尴尬局面。
用yarn why dayjs可以精确看到依赖来源:
yarn why dayjs输出类似:
├─ dayjs@1.11.5 │ └─ demo-project@1.0.0 → dependencies └─ dayjs@1.9.8 └─ some-toolchain@1.2.0 → dependencies这就是“选择性”开始介入的时刻:系统默认选了两个版本,但你可能根本不希望出现嵌套副本。
3.2 使用 resolutions 强制统一解析结果
要让所有dayjs都落到同一个版本,最常见的手段是在 package.json 里加:
{ "resolutions": { "dayjs": "1.11.5" } }然后重新安装:
yarn install这时yarn why dayjs会显示只有一个版本被解析,嵌套的那层消失,顶层 node_modules 里的dayjs被复用。如果1.11.5不在some-toolchain声明的^1.9.0范围内,Yarn 1.x 会强行忽略范围检查并替换,因为resolutions的设计目的就是覆盖默认解析规则。
这里有一个值得注意的细节:如果some-toolchain对dayjs的依赖声明是精确版本1.9.0(没有^),resolutions依然能生效。Yarn 1.x 在解析过程中,会把 resolutions 视为最高优先级,直接改写解析结果。
但如果你只是在 package.json 里改了版本号,并没有删除已有的 yarn.lock,可能会出现“锁文件里还是旧版本”的情况。正确流程是:修改 resolutions 后,尽量让 Yarn 重新计算 lockfile。
yarn install这通常会自动更新 lockfile。如果遇到诡异的不生效,可以尝试:
yarn cache clean yarn install --force注意--force会绕过某些缓存校验,但不是所有场景都能解决,后面我会单独讲排查思路。
3.3 用 yarn dedupe 整理重复依赖
有时候项目里并没有明显的版本冲突,但 node_modules 里还是堆了很多重复副本。这些副本不是解析到不同版本,而是同一个版本被解析了多遍。这种情况多发生在 lockfile 被人工修改、多次安装升级之后。
Yarn Berry 提供了yarn dedupe命令:
yarn dedupe它会根据当前的依赖图,找出可以被合并的解析结果并统一版本。比如 lockfile 里有lodash@npm:4.17.21和lodash@npm:^4.17.21两条记录,如果二者解析到同一版本,dedupe会把它合并成一条记录,减少磁盘占用和安装时间。
yarn dedupe执行后需要重新看一下依赖图,确认合并没有破坏预期的版本范围。我之前在某个项目里跑完 dedupe 后,某个老插件的依赖被从2.4.0提升到了2.5.0,功能正常但行为有细微变化。所以:dedupe 不是无脑执行的优化,执行前后最好跑一遍完整测试。
3.4 通过 hoisting 限制控制解析位置
除了版本解析结果,Yarn 还会决定“解析出来的包安装到哪一层”。Yarn 1.x 默认是提升到顶层,Berry 的 node-modules 插件也类似,但提供了更多限制参数。
如果你不希望某个包被提升到顶层,可以在 package.json 里配置:
{ "nohoist": ["**/some-package"] }这个配置在 Yarn 1.x 中可用,但注意它调整的是安装位置,而不是版本解析。它适合的场景是:某个包的设计依赖于“自己能在 node_modules 里找到 sibling 包”,如果被提升到顶层,反而会破坏它的相对查找逻辑。
在 Yarn Berry 中,nohoist被移除了,替代方案是在.yarnrc.yml里设置:
nodeLinker: node-modules nmHoistingLimits: workspacesnmHoistingLimits可以取none、dependencies、workspaces。workspaces会阻止依赖被提升到 workspace 的根目录之外,适合 monorepo 场景下的依赖隔离。默认是none,对应传统提升行为。
这个参数的取舍,本质上是在“依赖重复安装的体积开销”和“依赖查找的确定性”之间做平衡。在大规模 monorepo 里,我倾向于workspaces,因为查找确定性比省几百 MB 磁盘更重要。
3.5 用 workspace 解析协议处理本地包
Monorepo 场景下,依赖解析多了一种选择:workspace:协议。某个 workspace 里声明依赖另一个 workspace 中的包时,可以写成:
{ "dependencies": { "internal-lib": "workspace:*" } }workspace:*让 Yarn 在解析时优先指向本地 workspace,而不是去 registry 拉取。这比"file:../lib"更规范,因为 lockfile 会明确记录这是一个 workspace 依赖,并且版本范围可以随 workspace 的发布版本联动。
由此带来的一个好处是:本地开发时每次改动内部包,都会立即被下游 workspace 感知,不需要手动执行yarn link。同时,在准备发布时,你可以选择把workspace:*替换成实际版本号,或者保留它并配合发布流程做转换。
这类解析协议是“选择性依赖解析”在 monorepo 时代的延伸:你不仅可以选择版本,还可以选择依赖的“来源”是本地代码还是远程 registry。
4. 常见问题与排查技巧实录
4.1 改了 resolutions 却没生效
这是最常被问的问题。我见过很多同事在 package.json 里加了 resolutions,跑完yarn install之后,发现 node_modules 里依然是旧版本。排查步骤如下:
第一步,确认 lockfile 是否真的更新了。直接搜索 yarn.lock 里对应的包名,看版本号有没有变化。如果没变,说明 Yarn 认为当前锁文件仍然有效,不会主动重算。解决方法:删除锁文件里对应的条目,或者干脆把 yarn.lock 删掉重新生成(但要小心,删整个锁文件可能导致大范围版本漂移)。
第二步,确认你的 Yarn 版本。Yarn 1.x 的 resolutions 对某些情况下不生效,比如嵌套在 peerDependencies 里、或者某些通过patch修改过的依赖。排查时可以运行:
yarn install --verbose看输出的解析日志里有没有应用 resolutions 的记录。
第三步,确认 resolutions 的键名是否写对了。"dayjs"和"some-toolchain/dayjs"作用范围不同,前者全局生效,后者只在某个父依赖链下生效。如果写错层级,看起来没生效,其实只是作用范围不对。
4.2 lockfile 被手工改动后的迷宫效应
lockfile 是“选择性解析”的落地点,但它也是人最容易手滑的地方。团队协作时,经常出现合并冲突后有人手工编辑 yarn.lock 的情况。Yarn 1.x 的 lockfile 采用 YAML 格式,用缩进和别名表达依赖关系,手工改动很容易破坏语义。
我曾经遇到一个项目,lockfile 里同一个包名出现了三次不同范围的 key,版本也各不相同。表面上 Yarn 安装时没有报错,但实际产物里同一个包被安装了三个副本。打开 node_modules,发现两个副本嵌套在深层依赖里,还有一个在顶层,而顶层版本的来源根本不在项目的直接依赖列表里。
排查这类问题,最有效的手段不是人肉看 lockfile,而是运行:
yarn why some-package如果输出了多条解析路径,就说明存在多个版本。然后对照 package.json 的依赖声明,逐步判断哪些是正常的,哪些是历史遗留。清理时,先把 package.json 里多余的依赖删掉,再考虑删除整个 lockfile 重新生成。
4.3 peerDependencies 导致的“选择性失效”
前面提过,Yarn 1.x 的 resolutions 对 peerDependencies 无效。这意味着你加了一条"react": "17.0.2"的 resolutions,但某个库的 peerDependencies 要求"react": "^16.0.0",Yarn 仍然会把这个库解析成“不满足 peer 条件”的状态,并发出警告。有些库在运行时只是 console.warn 一下,有些则直接报错。
正确处理方式是:在项目的依赖里显式安装符合要求的版本,而不是依赖 resolutions。对于确实需要强制的情况,可以先把 peer 依赖“升级”成普通依赖安装一遍,再通过 lockfile 固定。Yarn Berry 对 peer 依赖的处理更严格,在第一次安装时就会提示解决方案,需要你在package.json里显式添加 peer 依赖。
我的个人建议:组件库的开发者更应该重视 peerDependencies 的范围声明。范围写得太宽,会在用户项目里产生大量解析警告;写得太窄,又会让用户无法升级宿主版本。最佳实践是跟随你实际测试过的版本区间,同时把 peerDependencies 的版本声明收紧到兼容范围。
4.4 Yarn 1.x 与 Berry 的行为差异
切换到 Berry 后,最大的直观变化是默认不再生成 node_modules,而是采用 Plug'n'Play(PnP)模式。依赖解析结果直接映射到缓存里的 zip 文件,解析路径通过.pnp.cjs完成。这意味着解析行为更确定,但也会带来兼容性问题:部分工具链如果不支持 PnP 协议,就会找不到依赖。
如果你用的是 Berry,但团队里某些工具还对 node_modules 有硬编码依赖,可以把链路切回 node-modules 模式:
nodeLinker: node-modules这并不丢人。解析机制的终极目标是“稳定可复现”,而不是“必须用最新模式”。选择什么样的 linker,取决于你的工具链生态。
此外,Berry 的resolutions支持更复杂的选择逻辑,比如可以针对某个 peer 依赖做处理,还可以配合patch协议修改依赖源码。但功能越强,维护成本越高。使用前想清楚:你是真的需要修改依赖源码,还是只是希望锁定版本?如果是后者,传统resolutions就够了。
4.5 依赖解析问题的排查速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 安装后出现多个版本副本 | 版本范围不兼容或 lockfile 有历史残留 | 运行yarn why定位来源,考虑 resolutions 统一 |
| 修改 resolutions 不生效 | 锁文件未更新或键名写错 | 检查 lockfile,用yarn install --force,确认作用范围 |
| 构建环境与本地环境依赖不一致 | 未提交 lockfile 或 tag 解析导致漂移 | 确保 lockfile 入库,避免使用latest声明 |
| peerDependencies 警告频繁 | 宿主版本不满足库的声明范围 | 显式安装匹配版本,或收紧 peer 范围 |
| dedupe 后行为变化 | 版本被合并到了不同版本 | dedupe 前后完整回归测试,必要时回滚 |
| monorepo 内依赖互相干扰 | 提升策略过于激进 | 使用nmHoistingLimits: workspaces限制提升层级 |
写在最后的一点经验
依赖解析这个主题,文档里写得很抽象,但实际踩坑之后才会真正理解它的分量。我现在的习惯是:每次升级依赖前后,先跑一遍yarn why确认受影响的面;任何新的 resolutions 配置,都留一个注释说明为什么要强制覆盖;lockfile 绝不手工改,遇到冲突老老实实重新生成。
如果你的项目还停留在“只要不报错就不管依赖”的阶段,我强烈建议你花一个下午,把所有yarn why的输出看一遍。你会发现很多隐蔽的问题:被覆盖的修复版本、多个没用的嵌套副本、甚至是某个已经被弃用的包还在被深层依赖引用。看清楚一次,后面就再也不会被这类问题打蒙了。