引言
感谢神奇的 Semver 动态规则,npm 社区经常会发生依赖包更新后引入破坏变更的情况(应用没有使用依赖锁的话),而应用开发者就要在自己的依赖声明里先临时绕过,避免安装到有问题的版本,如果是一级依赖,只需要改 package.json 的声明就可以了,但如果是子依赖,就需要进行版本重写(overrides/resolution)了。
本文是一篇针对版本重写功能的指南性文章,当你遇到如下的问题时,就可以按照对应的依赖重写语法,解决这些依赖问题了:
-
临时版本修复:需要临时把有问题的子依赖(依赖的依赖)版本降级或升级到正常的版本上。如降级有 Breaking Change 的 @babel/generator。
-
强制升级子依赖:使用的一个包更新频率较慢,想更新其子依赖时。如一个老的构建依赖依赖的 css-loader 需要更新到新版本。
-
依赖多版本统一:React/Webpack 等包出现多个版本,造成构建或运行时问题,需要统一项目对应依赖的版本到一个版本上。
-
子依赖重写:某个子依赖 A 依赖特定版本的 B 时会造成问题,仅需要重写依赖 A 下 B 的版本时。
-
删除子依赖:想删除某些子依赖不正确的依赖时,如子依赖把 devDependencies 写到 dependencies 里,或者写错 semver 的,开发者又没有权限发布这个不规范的包,需要删除或者替换子依赖的 Semver 声明。如不规范的依赖包中把应该在 devDependencies 里的包写在了 dependencies 里。
通用术语解释
-
一级依赖:直接声明在 package.json 的 (dev | optional | peer) dependencies 中的依赖。
-
子依赖:一级依赖的依赖,无法由应用开发者直接指定版本。
-
嵌套重写:指仅重写某个依赖下的一级依赖或者子依赖(限制 Scope 重写依赖版本)。
包管理器语法
阿里的前端开发者使用的包管理软件,除了社区常用的 npm, yarn 以及 pnpm 外,还有阿里内部特有的 tnpm 及其不同 mode。他们各自都有不同的依赖安装模式及依赖版本重写的语法,按大版本及语法的不同,把他们分为如下几类:
npm |
yarn |
tnpm |
pnpm |
||||
npm@5-6 tnpm@8 (npm mode) |
npm@8 tnpm@9 (npm mode) |
yarn@1 (classic) tnpm@* (yarn mode) |
yarn@3 (berry) |
tnpm@* |
pnpm@<6.3.1 |
pnpm@>6.3.1 |
|
依赖重写语法 |
不支持(需 Hack) |
overrides |
resolutions |
resolutions (yarn 3 语法) |
resolutions (yarn 1 语法) |
resolutions (pnpm 语法) |
pnpm.overrides |
锁文件版本 |
package-lock.json@1 |
package-lock.json@2 |
yarn.lock@1 |
yarn.lock@6 |
不支持 |
pnpm-lock.yaml |
本部分针对每一种语法都进行了详细的解释与简单的案例,在最后还有实际的例子可以供读者更好的理解使用场景。如果是紧急解决问题,建议先看对应包管理软件的语法,补充参考案例,以更快的解决问题。
Npm 8 Overrides
注:该字段主要在 npm 8 最新版上被支持,npm 7 及以下的版本中并没有这个能力,不过到还是有办法在旧版 npm 上用上这个能力,如果一定要使用 npm 6,请跳转到下一个子标题 Npm 6 Overrides (Hack)。
基本语法
使用前记得更新 npm 到最新版:npm i -g npm@latest。
基本的语法有点类似于 CSS 的选择器 - 声明语法,选择器用于匹配到包,声明用于重写版本声明。
# package.json
{
"overrides": {
"选择器": "重写版本声明"
}
}
在如下的例子中,示范了所有选择器和重写版本的语法:
{
"packageManager": "npm@8.19.2",
"dependencies": {
"react": "^18"
},
"overrides": {
# 表示将所有 webpack 的版本全部重写到 5
"webpack": "5",
# 将 react 替换成 dependencies 中声明的版本
"react": "$react",
# 表示仅将 6.0.0 的 babel 版本重写到 6.0.1
"babel@6.0.0": "6.0.1",
# 表示将满足 ms@^2 的包重写版本声明到 ^1
"ms@^2": "^1",
# 把 tnpm 这个包全局替换成一个空包,相当于删除这个依赖
"tnpm": "npm:noop-package@1.0.0",
# 把所有 underscore 替换成 lodash
"underscore": "npm:lodash",
# 把 metameta 替换成一个自己的 fork
"metameta": "github:some-group/metameta-fork"
}
}
这个例子中的声明,都是全局替换,无论是子依赖还是子依赖的依赖,只要匹配到选择符,就会应用规则。
嵌套重写
除了全局替换之外,也可以对特定包的子依赖进行重写。在嵌套结构中,用 “.“ 代表嵌套的包本身,有点像 less 里的 &。
# 仅表示 overrides 字段内容,下文不再赘述
{
"ice.js": {
".": "1.2.0", # 重写全部的 ice.js 到 1.2.0
"webpack": "5" # 重写 ice.js 下级所有的 webpack 依赖版本至 5
}
}
当出现多个规则应用到同一个包上时,嵌套越深的规则优先级越高:
{
"foo": {
".": "1.0.0",
"boo": "3.0.0"
},
"boo": "1.0.0"
}
最后安装的结果是:
-
所有 foo 包的版本被设定成 1.0.0。
-
foo 下的所有 boo 包被设定成 3.0.0。
-
除了 foo 下的,所有其它的 boo 包被设定成 1.0.0。
另外需要注意的是,成文时的 npm 版本有个小问题,在嵌套重写中判断一个规则是否被删除时,判断的是父依赖的规则是否存在,下面的变动将不会把原来的嵌套重写规则从锁中删除:
首次重写:
|
仅删除嵌套重写,锁文件依赖保持被重写的状态:
|
删除父级依赖的声明,才会把依赖状态修正成无修改的状态:
|
特殊规则
Overrides 有几条特殊规则,一般在简单情况下遇不到,但还是要学习下,以避免发生预期外的行为。
首次从 package-lock v1 升级到 package-lock v2 时,overrides 可能不生效
如开头的表格中提到的,package-lock v1 是 npm@6 生成的锁,package-lock v2 是 npm@8 生成的锁。在升级 Node 或者主动升级 npm 后,可能见到下面这个提示:
overrides 可能并没有生效,重新再跑一次 npm i 就可以了。
另外,当选择器带版本范围时(如 "ms@^2": "1"),从老锁升级的新锁也有可能不应用 overrides 规则(是 npm 的 bug,如果用到了要观察一下锁文件更新的状态)。如果没生效,把版本范围去了,或者删除锁重新生成即可。
一个相关的 Issue:https://github.com/npm/cli/issues/5051
重写不能与 dependencies 声明冲突
overrides 与各类在 package.json 中直接声明的 dependencies 不能出现冲突,如:
{
"dependencies": {
"debug": "^2.1.3"
},
"overrides": {
"debug": "^1"
}
}
会出现 EOVERRIDE 的报错:
一个包只能被一条规则匹配
也可以理解为:当一个包先匹配了一个选择器后,就会停止后续的匹配。也即先遇到的规则优先级最高,如:
{
"debug": {
".": "3"
},
"debug@3": { # foo 包的规则走不到这里,因为上面的规则把所有的 foo 包都匹配了。
"ms": "1" # 同理,因为选择器没有匹配上,该规则也不生效。
}
}
如果我们调转上面这个例子的顺序,会产生另一个问题:
{
"debug@3": {
"ms": "1"
},
"debug": {
".": "3"
}
}
比如依赖中有一个 debug@4,他会被重写到 debug@3,此时,要不要继续应用 debug@3 的重写规则呢?
答案是:会继续应用,因为重写之后重新从上至下匹配时,会先匹配到上面的规则,进而应用 ms 的重写规则。最后的结果是,debug@4 变成 debug@3,debug@3 依赖的 ms@2 也会被重写到 ms@1。
不过笔者非常不建议在 overrides 中这样写,会造成理解上的混乱。
一个包只要匹配了选择器或者重写的目标,就算该规则被匹配
成文时 npm@8.19 尚未完全实现这个能力,目前遇到多重重写的,这些重复的规则都会被忽略。但 overrides 设计文档中标注了这个行为,所以还是解释一下。
我们把上面的的例子再稍加修改,加入双层对包自身版本的重写:
{
"debug@4.3.4": {
".": "4.3.3"
},
"debug@4.3.3": {
".": "4.3.2"
}
}
注意:"debug": "3" 的语法只是 "debug": { ".": "3" } 的语法糖。
此时,debug@4.3.4 最终的版本是 4.3.3 还是 4.3.2 呢?
npm 引入了另一条规则:如果一个包匹配了选择器(debug@4.3.4),或者匹配了重写目标(debug@4.3.3),都算是这个规则被应用了。结合上面的遇到第一个应用的规则即停止的逻辑,就可以得到确定性的结果,即:
-
先匹配 debug@4.3.4,得到 debug@4.3.3。
-
进行下一轮匹配, debug@4.3.3 与首条规则的 ".": "4.3.3" 匹配,则 debug@4.3.4 整条规则视为已匹配,结束后续匹配。
-
最后的重写结果就是 4.3.3。
Npm 6 Overrides (Hack)
npm 6,7 本身并没有类似于 overrides 的能力,但我们可以利用 npm 8 的算法,生成一颗应用 overrides 规则的依赖树后,再生成 npm 6 兼容的锁文件,来间接达到在 npm 6 上使用 overrides 的能力。
利用 @ali/tnpm-lock-adapter@1.6.0 及以上的版本,可以帮我们利用 npm 8 的算法生成 npm 6 的锁。
配置好 overrides 后,在项目目录下执行如下命令,即可生成应用了 overrides 的兼容 npm 6 的 v2 版本 pacakge-lock.json,此时再利用 npm 6 进行安装,就可以安装到重写后的版本了。
tnpm i -g @ali/tnpm-lock-adapter
tnpm-lock-adapter -i -v 1
项目如果要持续使用 npm 6,可以在 package.json 的 scripts hook 中配置上面 tnpm-lock-adapter 这个命令,以持续应用 overrides 规则,避免失效。
Yarn 1 (Classic)
Yarn 1.x (Classic) 已不再持续维护,Yarn 现在活跃的维护版本是 3.x (Berry)。新老版本的重写语法不一样。
基本语法
# package.json
{
"resolutions": {
"选择器": "重写版本声明"
}
}
与 npm 的选择器不同,yarn 仅支持匹配包名,不支持在选择器中使用版本范围来选择特定包的特定版本。语法与案例如下:
{
"packageManager": "yarn@1.22.19"
"dependencies": {
"@angular/cli": "1.0.3",
"typescript": "2.3.2"
},
"resolutions": {
# 重写所有 typescript 版本到 2.3.2
"**/typescript": "2.3.2",
# 上面语法的语法糖
"typescript": "2.3.2",
# 重写除一级依赖之外,所有的 typescript 版本到 ^3
"typescript": "^3",
# 重写版本声明同样支持如 npm:noop-package@1.0.0,github 仓库等语法。
# === 嵌套重写 ===
# 仅重写 @angular/cli 直接依赖的 typescript 版本
"@angular/cli/typescript": "2.3.2",
# 重写 @angular/cli 下所有依赖的 typescript
"@angular/cli/**/typescript": "2.3.2",
#
}
}
特殊规则
dependencies 声明的优先级高于 resolutions
如果 dependencies 中的一个依赖 a 出现在 resolutions 中,则这个依赖 a 的版本依然是按 dependencies 中的声明安装,但其它包的子依赖中的 a 则应用 resolutions 规则,如:
{
"dependencies": {
"@angular/cli": "1.0.3",
"typescript": "2.3.2"
},
"resolutions": {
"typescript": "^3"
}
}
会得到如下的依赖树:
node_modules/
├─ typescript@2.3.2/
├─ @angular/cli@1.0.3/
│ ├─ node_modules/
│ │ ├─ typescript@3.9.10/
Tnpm
npminstall mode(默认安装模式)
npminstall 是 tnpm 8,tnpm 9 的默认安装模式。什么都不配的情况下 tnpm 跑的就是这种安装方式。与 yarn 1 的语法一致,不再赘述。
npm mode
即在 package.json 中配置了:
{
"tnpm": {
"mode": "npm",
"lockfile": "enable" # 可选
}
}
在 tnpm 8 中,npm mode 使用的 npm 版本为 6,不支持 overrides,如果仍需要使用,请参考本文 npm 6 overrides Hack 部分,并开启 lockfile。
在 tnpm 9 中,npm mode 使用的 npm 版本为 8,可以直接像用 npm 8 那样配置 overrides。
yarn mode
即在 package.json 中配置了:
{
"tnpm": {
"mode": "yarn",
"lockfile": "enable" # 可选
}
}
tnpm 自带的版本是 yarn 1,如果仓库没有切换过 yarn 3,则使用的就是 yarn 1 的 resolutions 语法。
不过 yarn 因为自带了版本切换机制,如果你的仓库额外配置了 yarn 3,还是要按 yarn 3 的语法进行配置。
rapid mode
在 tnpm 9 中,使用 rapid mode 安装时:
tnpm i --fs=rapid
无论模拟的文件目录结构是 npm(--by=npm) 还是 npminstall,重写的语法与 npm 8 的 overrides 一致,直接使用即可。
Yarn 3 (Berry)
基本语法
与 yarn 1 的语法相似,不过在选择器中支持了版本,且不再支持重写嵌套依赖。
另外,可以用 yarn set resolution -s 来修改 resolutions 字段。
{
"packageManager": "yarn@3.2.3",
"resolutions": {
# 重写所有 relay-compiler 到 3.0.0
"relay-compiler": "3.0.0",
# 仅重写 @babel/core 下的一级 @babel/generator
"@babel/core/@babel/generator": "7.3.4",
# 仅重写 @babel/core@7.0.0 下的一级 @babel/generator
"@babel/core@npm:7.0.0/@babel/generator": "7.3.4",
# @babel/core/**/@babel/generator 注意,** 语法不再支持!
}
}
特殊规则
resolutions 的优先级比 dependencies 高
当这两个字段中都有同一个包时,dependencies 中的版本声明会被替换掉。
Pnpm
基本语法
pnpm 的语法与 yarn 1 类似,但又不完全一样;另外 "pnpm.overrides" 和 "resolutions" 字段的功能相同,后者是前者的别名,直接看例子:
{
"packageManager": "pnpm@7.12.2",
"pnpm": {
"overrides": {
# 重写所有 foo
"foo": "^1.0.0",
# 全局替换包
"quux": "npm:@myorg/quux@^1.0.0",
# 带版本范围的选择器
"bar@^2.1.0": "3.0.0",
# 仅重写 qar@1 的直接依赖 zoo,qar 的依赖的子依赖中的 zoo 不会受到影响
"qar@1>zoo": "2"
}
},
"resolutions": {} # 同样支持,不过是用于兼容 yarn 迁移的别名
}
特殊规则
overrides 的优先级高于 dependencies
如果 dependencies 和 overrides 中都声明了一个包的版本,则 overrides 会覆盖 dependencies 中的声明。如:
{
"dependencies": {
"typescript": "2.3.2"
},
"resolutions": {
"typescript": "^4"
}
}
会生成如下的 pnpm-lock.yaml
overrides:
typescript: ^4
dependencies:
typescript: 4.8.3
嵌套依赖只会重写一级,不会覆盖全部子依赖
与 yarn 和 npm 的重写规则不同,pnpm 在嵌套使用时,只会重写一级,有点类似于 Less 中的 “>” 语法。如有这样的依赖结构:
qar@1/
├─ fab@1/
│ ├─ zoo@2/
├─ zoo@1/
在使用如下 overrides 重写时:
{
"qar@1>zoo": "3"
}
会得到这样的依赖树,仅有 qar 的直接依赖被重写了。
qar@1/
├─ fab@1/
│ ├─ zoo@2/
├─ zoo@3/
案例
临时版本降级
假设 @babel/core 依赖的 @babel/generator@7.19.0 出了 Bug,应用需要临时降级到 7.18.0。
# package.json
{
"dependencies": {
"@babel/core": "^7.19.1"
}
}
# 依赖结构
node_modules/
├─ @babel/core@7.19.1/
│ ├─ @babel/generator@7.19.0/
npm/tnpm npm mode overrides
{
"dependencies": {
"@babel/core": "^7.19.1"
},
"overrides": {
"@babel/generator": "7.18.0"
}
}
tnpm/yarn1/yarn3/pnpm resolutions
{
"dependencies": {
"@babel/core": "^7.19.1"
},
"resolutions": {
"@babel/generator": "7.18.0"
}
}
强制升级 node-saas
node-saas 因为其对 Node 版本有强要求,所以经常会在升级 Node 的过程中出现 node-sass 无法安装的问题(gyp Build Error)。
首先我们建议应用开发者更换 sass 实现到 dart-sass,可以从 sass 官网上获取。如果是子依赖中的 node-sass 版本与 node 冲突,则可以使用依赖重写来解决这个问题。
如原使用的 node-sass 为 4.12 ,不支持 Node 16+,即可利用重写强行写到 6.x。假设有如下的依赖树:
node_modules
├─ @ali/aone-editor
│ ├─ node-sass@4.12.0
├─ build-plugin-ice
│ ├─ node-sass@4.11.0
npm/tnpm npm mode overrides
{
"overrides": {
"node-sass": "^6"
}
}
tnpm/yarn1/yarn3/pnpm resolutions
{
"resolutions": {
"node-sass": "^6"
}
}
依赖多版本统一:webpack
一些不规范的包会引入低版本的 webpack,与其它依赖引用的高版本 webpack 冲突,进而造成问题。如下面这个案例,因为项目中存在多个 webpack 版本,提升(hoist)依赖后,根目录的 webpack 版本不一样,导致另一个不规范引用 webpack 的包(mini-css-extract-plugin)引用的版本不兼容。
{
"dependencies": {
"@ali/jstracker": "5.3.2",
"@alib/build-scripts": "0.1.32"
}
}
要修复,需要将项目中的 webpack 版本保持统一,或者升级 @ali/jstracker 中的 webpack 版本。
npm/tnpm npm mode overrides
{
"overrides": {
"webpack": "4.46.0",
# 或
"@ali/jstracker": {
"webpack": "4.46.0"
}
}
tnpm/yarn1 resolutions
{
"overrides": {
"webpack": "4.46.0",
# 或
"@ali/jstracker/**/webpack": "4.46.0"
}
yarn3 resolutions
{
"overrides": {
"webpack": "4.46.0",
# 或
"@ali/jstracker/webpack": "4.46.0"
}
pnpm overrides
{
"pnpm": {
"overrides": {
"webpack": "4.46.0",
# 或
"@ali/jstracker>webpack": "4.46.0"
}
}
}
尾注
不知道 npm 有一天能不能真的像它们代码里写的,干掉其它包管理软件,减少点前端开发者需要学的工具的数量……但看目前 Node 的发展趋势,是打算通过 Corepack 和 packageManager 字段,来兼容不同项目下不同的包管理软件了。
不过也正是因为各有所长,才会百花齐放,One for All 的包管理软件还道阻且长呢。前端同学们学不动也得学,毕竟这就是前端生态的特色之一嘛!