包管理器
主题
发展历程
- node_modules 困境,除了 NodeJS 生态,文章还介绍了 Rust 是如何进行包管理的。
- 关于依赖管理的真相—前端包管理器探究,文章对 NodeJS 中各新老包管理器逐个点了名,还简单提及了 Deno 中的包管理。
- 深入浅出 tnpm rapid 模式-如何比 pnpm 快 10 秒,大综述。
- 一种秒级安装 npm 的方式,更详细地介绍了 tnpm Rapid 的工作原理。
古早的 NPM 是怎么管理依赖的?
早期 NPM(V1、V2) 使用原始的嵌套模式来管理依赖,没有使用优化策略,所以带来了依赖地狱和多版本共存的问题。
- 依赖地狱:依赖路径过长、占用空间过大、安装缓慢。
NPM V3 带来了哪些改进?
NPM V3 开始,使用扁平模式管理依赖,把重复依赖提升到 node_modules 一级目录,缓解了依赖地狱的问题,但却引入了幽灵依赖、多重依赖和不确定性等问题。
- 幽灵依赖:如果某依赖不是包本身的依赖但是被提升到了一级目录,那么就能在代码中引入。
- 多重依赖:首先,依赖的某版本已经提升了,却不会影响其它依赖共同依赖它的其它版本,所以还是存在多重依赖的问题;其次,由于 NodeJS 的 require 的缓存规则是按照文件名及路径而不是模块名, 此时对依赖进行有副作用的修改会破环单例模式;再者不同版本依赖的 types 可能会冲突。
- 不确定性:手动安装依赖可能会带来和 npm install 安装后不同的结构。
yarn 的 PnP 模式是什么?
Yarn(V2)带来一种独特的依赖安装模式:PnP(Plug'n'Play),它在项目中使用 .pnp.cjs 文件来缓存各模块及其位置的关系。这样一来,所有依赖都可以被统一管理,极大减少了安装依赖时 IO 操作。
pnpm 解决了什么问题?
2017 年,pnpm V1 通过统一依赖管理以及创建系统链接的方法一举解决了幽灵依赖和多重依赖的问题。所有项目的依赖都被统一安装到了磁盘特定位置。即使多个项目中用到相同的依赖也只会安装一次。此外,项目中的 node_modules 文件夹仍然是 结构化的,所以比 PnP 有更好的兼容性。
tnpm 的主要思路是什么?
tnpm 想给包管理工具提供一套方案,以解决所有恼人的问题。在网络端,使用服务器来生成依赖树,节约请求;在表示层,将 tgz 文件合并写入 tar,节约写入次数;在 IO 层,使用 Rust 完成 IO 操作性优于 NodeJS;在系统层, 使用 FUSE 文件系统,省去文件解压操作。

常见问题
包管理器差异?
peerDependencies:
- npm v3 ~ v6 不会自动安装 peerDependencies,见:peerDependencies | npm
- npm v6.x 没有考虑 peerDependencies 的依赖结构,容易在用户安装了和 peerDepend 的包的不同版本号的依赖时导致问题,见:npm 与 yarn 对 peerDependencies 处理的差异 | 张立理
- pnpm 有特殊处理,见 How peers are resolved | pnpm
如何锁定包管理器?
- 可以使用脚本判断环境变量中的 npm_execpath 或者 npm_config_user_agent,分别根据执行指令的包管理器具体路径、执行指令的包管理器具体版本(Vue3 和 Vite 分别使用了这两种办法限制特定的包管理器) 。
- 使用 only-allow 包(Vite 使用这种方法)。
- 使用 Corepack 锁定包管理器
process.env.npm_execpath
// -> /usr/lib/node_modules/npm/bin/npm-cli.js
process.env.npm_config_user_agent
// -> npm/8.1.2 node/v16.13.2 linux x64 workspaces/false
Corepack 是什么?
Corepack 是 NodeJS v16.13、 v14.19.0 实装的包管理工具。
- 配合 package.json 中的 packageManager 字段锁定库的包管理工具,如
"packageManager": "pnpm@7.14.1"
常见问题:
- Corepack 是实验功能,所以需要手动启用
corepack enable
- Corepack 不会拦截 npm,但可以启用对 npm 指令的拦截
corepack enable npm
- Corepack 自动安装了 NodeJS 发版时截至的最新的 yarn 和 pnpm,但是可以更新
corepack prepare pnpm@<version> --activate
见:Corepack | NodeJS 见:NodeJS Corepack
如何锁定 NodeJS 版本?
在 package.json 中新增 engine 字段,并在 .npmrc 文件中开启 engine-strict 配置。如果只改了 engines 字段,npm 是不会生效的。
// package.json
"engines": {
"node": "14.x || 16.x"
}
// .npmrc
engine-strict = true
Node 16 等旧版本 Node 的依赖 engine 兼容性锁定
当项目被迫停留在旧版 Node(如 Node 16),而上游依赖不断提高 engines.node 要求时,yarn 1.x 会在 yarn install 阶段因 engine 校验直接报错。此时不能仅依赖 lockfile,
而需要主动把直接依赖和传递依赖都钉回兼容版本。
典型触发组合:
sass1.101.0+ 要求 Node ≥ 20.19.0@intlify/shared12.0.0-alpha.4 要求 Node ≥ 22.0.0vitest@0.34.6的传递依赖解析到vite@5.4.21/rollup@4.62.2,要求 Node ≥ 18
定位不兼容包时,可用脚本扫描 node_modules 中所有 package.json 的 engines.node 声明:
const fs = require('fs')
const path = require('path')
const semver = require('semver')
const NODE_VERSION = process.version
const ROOT = path.resolve(__dirname, '..')
function walk(dir, cb) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (entry.name === 'node_modules') {
for (const pkg of fs.readdirSync(full, { withFileTypes: true })) {
if (pkg.isDirectory()) {
const pkgJson = path.join(full, pkg.name, 'package.json')
if (fs.existsSync(pkgJson)) cb(pkgJson)
const nested = path.join(full, pkg.name, 'node_modules')
if (fs.existsSync(nested)) walk(nested, cb)
}
}
} else {
walk(full, cb)
}
}
}
}
const incompatible = []
walk(ROOT, (pkgJson) => {
const pkg = JSON.parse(fs.readFileSync(pkgJson, 'utf8'))
const range = pkg.engines?.node
if (range && !semver.satisfies(NODE_VERSION, range)) {
incompatible.push({ name: pkg.name, version: pkg.version, range, path: pkgJson })
}
})
incompatible.sort((a, b) => a.name.localeCompare(b.name))
console.table(incompatible)
锁定策略分两种:
- 直接依赖:用
~把次版本钉死,阻止解析到要求更高 Node 的版本。例如sass从^1.54.4改为~1.90.0,使其停留在 1.90.x。 - 传递依赖:用包管理器的覆盖字段强制解析到兼容版本。yarn 1.x 用
resolutions,npm 用overrides,pnpm 用pnpm.overrides。
{
"dependencies": {
"sass": "~1.90.0"
},
"resolutions": {
"@intlify/shared": "9.14.5",
"@intlify/message-compiler": "9.14.5",
"vite": "^3.2.11",
"rollup": "^2.79.1"
}
}
选择这些版本的原则:
@intlify系列与vue-i18n@9主版本一致,且 9.14.5 明确支持 Node 16。vite限制在 3.x 以匹配项目原有 Vite 3 基线。rollup@2.79.1是 Vite 3 时代的常见搭档,且兼容 Node 16。
修改后要删除 lockfile 和 node_modules 重新生成依赖树,并在与生产一致的 Node 镜像中复验:
rm -rf node_modules yarn.lock
yarn install
docker run --rm -v "$PWD":/app -w /app node:16.15.0-alpine \
sh -c "yarn install --frozen-lockfile && yarn test && yarn build"
这种强制锁定只是过渡方案。升级 Node 或迁移到 Vite 4/5 后,应及时移除这些 resolutions 和 ~ 锁定,避免旧版本长期占据依赖树。
见: [Node 16 依赖 engine 兼容性锁定方案](/Users/lionad/Github/86Links/auth-center/docs/research/2026-06-24-node16-dependency-locking. md)
为什么 package.json scripts 中路径宜用引号包裹起来?
因为 glob patterns 有兼容性问题,NPM 在 Linux 平台使用 sh -s 指令运行脚本,在 Windows 上使用 cmd /d /s /c。如果是编写应用代码, 则可以使用 node-glob 等工具处理路径以解决跨平台的兼容性问题。
见:Why you should always quote your globs in NPM scripts
关于菱形依赖的复杂性
dependency-resolution-methods@Andrew Nesbitt