前端依赖版本重写指南

简介: 感谢神奇的 Semver 动态规则,npm 社区经常会发生依赖包更新后引入破坏变更的情况(应用没有使用依赖锁的话),而应用开发者就要在自己的依赖声明里先临时绕过,避免安装到有问题的版本,如果是一级依赖,只需要改 package.json 的声明就可以了,但如果是子依赖,就需要进行版本重写(overrides/resolution)了。本文是一篇针对版本重写功能的指南性文章,当你遇到如下的问题时,就可以按照对应的依赖重写语法,解决这些依赖问题了。

引言

感谢神奇的 Semver 动态规则,npm 社区经常会发生依赖包更新后引入破坏变更的情况(应用没有使用依赖锁的话),而应用开发者就要在自己的依赖声明里先临时绕过,避免安装到有问题的版本,如果是一级依赖,只需要改 package.json 的声明就可以了,但如果是子依赖,就需要进行版本重写(overrides/resolution)了。

本文是一篇针对版本重写功能的指南性文章,当你遇到如下的问题时,就可以按照对应的依赖重写语法,解决这些依赖问题了:

  1. 临时版本修复:需要临时把有问题的子依赖(依赖的依赖)版本降级或升级到正常的版本上。如降级有 Breaking Change 的 @babel/generator。
  2. 强制升级子依赖:使用的一个包更新频率较慢,想更新其子依赖时。如一个老的构建依赖依赖的 css-loader 需要更新到新版本。
  3. 依赖多版本统一:React/Webpack 等包出现多个版本,造成构建或运行时问题,需要统一项目对应依赖的版本到一个版本上。
  4. 子依赖重写:某个子依赖 A 依赖特定版本的 B 时会造成问题,仅需要重写依赖 A 下 B 的版本时。
  5. 删除子依赖:想删除某些子依赖不正确的依赖时,如子依赖把 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"
}

最后安装的结果是:

  1. 所有  foo 包的版本被设定成  1.0.0
  2. foo 下的所有  boo 包被设定成  3.0.0
  3. 除了  foo 下的,所有其它的  boo 包被设定成  1.0.0

另外需要注意的是,成文时的 npm 版本有个小问题,在嵌套重写中判断一个规则是否被删除时,判断的是父依赖的规则是否存在,下面的变动将不会把原来的嵌套重写规则从锁中删除:

首次重写:

{
  "overrides": {
    "debug": {
      "ms": "1"
    }
  }
}

仅删除嵌套重写,锁文件依赖保持被重写的状态:

{
  "overrides": {
    "debug": {
    }
  }
}

删除父级依赖的声明,才会把依赖状态修正成无修改的状态:

{
  "overrides": {
  }
}

特殊规则

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@3debug@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),都算是这个规则被应用了。结合上面的遇到第一个应用的规则即停止的逻辑,就可以得到确定性的结果,即:

  1. 先匹配  debug@4.3.4,得到  debug@4.3.3
  2. 进行下一轮匹配, debug@4.3.3 与首条规则的 ".": "4.3.3"  匹配,则  debug@4.3.4 整条规则视为已匹配,结束后续匹配。
  3. 最后的重写结果就是  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 的包管理软件还道阻且长呢。前端同学们学不动也得学,毕竟这就是前端生态的特色之一嘛!

参考资料

使用工具

目录
相关文章
|
JavaScript 前端开发 API
[译] 如何更新 package.json 中的依赖项
[译] 如何更新 package.json 中的依赖项
[译] 如何更新 package.json 中的依赖项
|
缓存 前端开发 JavaScript
Vite 打包优化:全面解析与实践
Vite 作为新一代前端构建工具,以其快速开发体验和高效打包能力著称。然而,在实际项目开发中,为了进一步提升性能和用户体验,我们仍需对 Vite 打包进行优化。本文将深入探讨 Vite 打包优化策略,涵盖代码拆分、资源压缩、缓存利用、构建配置等多个方面,并提供实践案例和最佳实践建议,帮助开发者充分释放 Vite 的潜力。
3457 1
|
JSON 前端开发 JavaScript
前端如何使用WebSocket发送消息
WebSocket是一种在Web应用程序中实现实时双向通信的协议。相比传统的HTTP协议,WebSocket提供了更高效、更快速的双向通信方式,可以在客户端和服务器之间实时交换数据。本文将详细介绍前端如何使用WebSocket发送消息,包括创建WebSocket对象、监听WebSocket事件、发送消息以及关闭连接等步骤,帮助开发者深入了解和应用WebSocket技术。
1451 2
|
前端开发 API 开发者
乱花迷人眼 - 一文彻底看懂 package.json 中的各种 dependencies
package.json 中存在各种各样的依赖定义:dependencies、devDependencies、peerDependencies、optionalDependencies、bundleDependencies,很容易让初学的开发者晕头,到底有什么区别。
|
JavaScript API 索引
js中的reduce()方法 讲解 和实现
`reduce()` 方法对数组元素依次应用一个回调函数,将结果累计并最终返回单一值。语法为 `reduce(callback(accumulator, currentValue, currentIndex, array), initialValue)`。参数包括累计器(初次为初始值或首元素)、当前元素值、索引及数组自身。此方法需返回值供下一轮迭代使用。常见应用场景包括计算数组总和与平均值、统计元素频率、过滤与转换数组内容及去除重复项等。例如,可通过 `reduce()` 快速计算 `[1, 2, 3, 4, 5]` 的总和或对对象属性值求和。此外,还可自定义实现 `reduce()` 方法
7625 1
Node安装版本低于工程版本时打包绕过校验
在开发中,若本地Node版本低于项目配置要求,导致打包报错(如图所示),可在不变更本地环境的情况下,通过在执行`npm run build`前输入命令`set NODE_OPTIONS=--openssl-legacy-provider`来绕行此问题,确保构建顺利进行。
1135 10
|
前端开发 网络架构
如何在React Router中传递参数?
如何在React Router中传递参数?
580 57
|
资源调度 前端开发
antd table表头拖拽实现(一)
antd table表头拖拽实现
2542 0
|
Shell 开发工具 git
(极简解决)git commit 时出现:please enter the commit message for your changes
(极简解决)git commit 时出现:please enter the commit message for your changes
2211 0
|
JavaScript 前端开发
如何解决 Critical dependency: the request of a dependency is an expression ?
如何解决 Critical dependency: the request of a dependency is an expression ?
561 0