视频加载失败

npm 与 pnpm 安装的到底是什么?从 JavaScript 包到原生模块

4409 字
22 分钟
npm 与 pnpm 安装的到底是什么?从 JavaScript 包到原生模块

我一开始也以为 npm 和 pnpm 安装的就是 JavaScript 或 TypeScript 包,最多再附带几段配置文件。直到我见到的包越来越多,尤其是 sharp 这类会带上原生 .node 模块、预编译二进制和平台依赖的包,这个模型终于不够用了。于是我在 WSL2 里建了一个隔离项目,沿着 registry、tarball、lockfile、store 和 node_modules 一路观察:我们安装的到底是什么,最后又是谁把它变成了一个可以执行的命令。

本文以 WSL2 中的 Ubuntu 22.04 LTS 为例,命令行提示符统一使用虚构的 user@wsl。路径中的用户名、主机名、硬件、IP、Windows 路径和日志路径均已省略;示例版本和下载数量也会随时间变化,不要把它们当成自己的环境事实。

我会先建立一份隔离实验,再回头解释每个文件和命令为什么出现。这样比背安装咒语可靠得多:包管理器就像仓库管理员,lockfile 是出入库账本,node_modules 则是当前项目真正拿来工作的装配区。

npm、pnpm、Node.js 和 nvm 分别是什么#

  • Node.js 是 JavaScript 的运行时,负责执行 JavaScript 程序。
  • npm 是 Node.js 生态的包管理器,通常随 Node.js 一起安装。它负责下载依赖、维护 package.jsonpackage-lock.json,也能执行项目脚本。
  • pnpm 是独立的 Node.js 包管理器。它与 npm 使用同一个 npm registry 生态,但命令、锁文件和 node_modules 的组织方式有所不同。
  • nvm 是 Node.js 版本管理器,用来安装和切换 Node.js 版本。它不是包管理器,也不负责安装项目依赖。
  • Corepack 是包管理器入口和版本准备工具。它可以根据项目声明准备指定版本的 pnpm 或 Yarn,但它不是 npm,也不负责安装项目依赖。

npm 和 pnpm 怎么选#

两者都能创建项目、安装依赖和执行脚本,但默认行为不同:

对比项npmpnpm
通常来源随 Node.js 发行版提供独立安装或由 Corepack 准备
锁文件package-lock.jsonpnpm-lock.yaml
默认依赖布局直接组织在项目 node_modules共享 store,加 virtual store 和链接
磁盘复用依赖具体版本和配置共享内容寻址 store,通常更节省空间
依赖声明约束相对宽松链接布局更容易暴露未声明依赖
添加依赖npm install <pkg>pnpm add <pkg>

推荐规则很简单:新项目优先考虑 pnpm,已有项目必须遵循项目声明。 如果项目已经提交了 package-lock.json,通常使用 npm;如果项目已经提交了 pnpm-lock.yaml,就使用 pnpm。不要在同一个项目里交替执行 npm installpnpm install,这样会产生两套锁文件和难以解释的依赖变化。

pnpm 并不是所有场景的唯一答案。只想快速开始一个小项目,或者参与明确要求 npm 的项目时,npm 更直接。选择的关键不是“哪个命令更酷”,而是项目是否有明确的包管理器契约,以及团队是否统一使用它。

在 WSL2 中准备 Node.js#

进入 Ubuntu 后,可以先确认系统和 shell。以下输出只保留与教程有关的部分:

Terminal window
user@wsl:~$ uname -sr
Linux 6.x.x-microsoft-standard-WSL2
user@wsl:~$ bash --version | head -n 1
GNU bash, version 5.1.16

使用 nvm 安装 Node.js。安装远程脚本前应先阅读来源和内容,因为 curl | bash 会直接执行下载的脚本:

Terminal window
user@wsl:~$ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.7/install.sh | bash
=> Downloading nvm from git to '$HOME/.nvm'
=> Appending nvm source string to '$HOME/.bashrc'

当前 shell 立即加载 nvm:

Terminal window
user@wsl:~$ . "$HOME/.nvm/nvm.sh"
user@wsl:~$ nvm install 24
Downloading and installing node v24.20.0...
Computing checksum with sha256sum
Checksums matched!
Now using node v24.20.0 (npm v11.19.0)
Creating default alias: default -> 24 (-> v24.20.0 *)
user@wsl:~$ node --version
v24.20.0
user@wsl:~$ npm --version
11.19.0

这里的 npm 已经随 Node.js 一起提供。此时可以使用 npm,但还没有因为安装 Node.js 就自动得到 pnpm。

Corepack 到底做什么#

Corepack 最容易被误解成“另一个包管理器”。更准确的分层是:

  1. Node.js 执行 JavaScript。
  2. npm 是一个包管理器,通常随 Node.js 分发。
  3. pnpm 和 Yarn 是其他包管理器。
  4. Corepack 是包管理器版本管理器,为 pnpm、Yarn 等准备版本并提供转发入口。

Corepack 解决的问题是:应该运行哪个版本的包管理器。它不解决:项目应该安装哪些依赖。后一个问题仍由 pnpm installpnpm addnpm install 等命令负责。

这里有一个容易随 Node.js 版本变化的现实:不能假设每个 Node.js 发行版都内置 Corepack。检查命令是否存在;如果不存在,按项目文档安装 pnpm,或者使用 pnpm 官方提供的安装方式。无论采用哪种方式,最后都要检查实际运行的 pnpm --version,而不是只检查安装命令是否成功。

corepack enable pnpm 发生了什么#

在提供 Corepack 的 Node.js 环境中执行:

Terminal window
user@wsl:~$ corepack --version
0.35.0
user@wsl:~$ corepack enable pnpm
user@wsl:~$ type -a pnpm
pnpm is .../bin/pnpm

corepack enable pnpm 通常会创建或启用一个名为 pnpm 的 shim。shim 不是完整的 pnpm,它更像一个转接头:用户执行 pnpm 时,shim 把请求交给 Corepack;Corepack 再准备合适版本的 pnpm 并转发参数。

因此第一次执行 pnpm 时,可能看到下载确认:

Terminal window
user@wsl:~$ pnpm --version
! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-11.24.0.tgz
? Do you want to continue? [Y/n] y
11.24.0

这次下载的是 pnpm 自己,不是项目依赖。确认下载后,Corepack 保存或复用这个包,之后继续把 pnpm 命令交给它执行。

packageManager 和 Corepack 的关系#

项目可以在 package.json 中写:

"packageManager": "pnpm@11.22.0"

这个字段是项目声明,意思是“处理这个项目时使用 pnpm 11.22.0”。它不是依赖声明,也不会把 pnpm 写入项目的 dependencies

如果项目的 .npmrc 还包含:

manage-package-manager-versions = true

那么 pnpm 可以根据 packageManager 字段参与包管理器版本管理。可以把两者理解为:

  • packageManager:项目声明应该使用什么工具和版本。
  • manage-package-manager-versions:pnpm 是否允许按这项声明管理工具版本。
  • Corepack shim:让命令能够进入版本准备和转发流程。
  • pnpm install:真正安装项目依赖。

这个配置键是 pnpm 的设置,不是 npm 的通用配置。若同一目录还会运行 npm,npm 可能提示 Unknown project config;这不是安装失败,但说明不应把 pnpm 专属选项当成跨工具协议。项目最好只保留实际使用的包管理器配置,并把团队约定写进项目文档或自动检查中。

检查四个版本时,命令和输出应分别理解:

Terminal window
user@wsl:~$ node --version
v24.20.0
user@wsl:~$ npm --version
11.19.0
user@wsl:~$ corepack --version
0.35.0
user@wsl:~$ pnpm --version
11.24.0

如果某个 Node.js 发行版没有提供 Corepack,corepack 命令可能不存在。这时应按照项目文档安装 pnpm,最后仍然用 pnpm --version 检查实际运行版本。全局安装 pnpm、Corepack 提供命令入口、pnpm add 安装项目依赖,是三个不同动作。

Warning

不要把 corepack enable、全局安装 pnpm 和 pnpm add 混成一个动作。前两者处理“命令从哪里来、运行哪个版本”,最后一个才修改当前项目的依赖。

用隔离项目观察安装过程#

不要为了学习包管理器直接修改重要项目。创建临时目录后,在其中安装几个形态不同的包:

Terminal window
user@wsl:~$ tmp_dir=$(mktemp -d)
user@wsl:~$ echo "$tmp_dir"
/tmp/tmp.xxxxxxxx
user@wsl:~$ cd "$tmp_dir"
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm init
Wrote to /tmp/tmp.xxxxxxxx/package.json
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm add lodash typescript sharp
Packages: +9
Progress: resolved 54, reused 0, downloaded 12, added 9, done
dependencies:
+ lodash 4.18.1
+ sharp 0.35.4
+ typescript 7.0.2
Done in 6.5s using pnpm v11.24.0

这里的版本和下载数量会变化。重要的是动作:pnpm init 创建 package.jsonpnpm add 修改依赖声明、解析依赖并创建安装目录。

查看生成的声明:

Terminal window
user@wsl:/tmp/tmp.xxxxxxxx$ cat package.json
{
"name": "tmp-project",
"version": "1.0.0",
"packageManager": "pnpm@11.24.0",
"type": "module",
"dependencies": {
"lodash": "^4.18.1",
"sharp": "^0.35.4",
"typescript": "^7.0.2"
}
}

pnpm init 在不同版本中生成的字段可能不同。不要把临时项目的名字、版本或格式当成固定模板。

用 npm 做一次对照#

我会再开一个目录,只安装一个开发工具。这样实验不会把 npm 和 pnpm 的文件混在一起:

Terminal window
user@wsl:~$ npm_dir=$(mktemp -d)
user@wsl:~$ cd "$npm_dir"
user@wsl:/tmp/tmp.yyyyyyyy$ npm init -y
Wrote to /tmp/tmp.yyyyyyyy/package.json
user@wsl:/tmp/tmp.yyyyyyyy$ npm install --save-dev typescript
added 1 package, and audited 2 packages in 1s
user@wsl:/tmp/tmp.yyyyyyyy$ npm exec tsc -- --version
Version 7.0.2

这个目录会得到 package-lock.json,而不是 pnpm-lock.yamlnpm execpnpm exec 都优先使用项目本地的 CLI,但两者不会共享锁文件格式,也不应该在同一个项目里轮流负责更新依赖。这个对照实验的价值不在于哪一个命令少敲两个字符,而在于把“同一个 registry 生态”和“不同的项目安装实现”分开观察。

包不是语言#

registry 发布的是带元数据的 tarball,而不是“一个 JavaScript 文件”。包中可以包含:

  • JavaScript 或 TypeScript 源码
  • WASM 文件
  • 原生 .node 模块
  • 预编译二进制
  • CLI 入口
  • 其他包的运行时或平台依赖

实验中的三个包分别说明了不同情况:

  • lodash 主要作为可导入的 JavaScript API 使用。
  • typescript 提供 API,也通过 bin 字段提供 tsc 命令。
  • sharp 会根据操作系统和 CPU 解析平台相关实现,安装时可能出现 @img/* 等平台包。

查询 registry 元数据:

Terminal window
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm view lodash dist.tarball
https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm view typescript bin --json
{
"tsc": "bin/tsc"
}

bin 字段表示包声明了命令入口。安装后,包管理器会在项目中创建 node_modules/.bin/tsc 的链接或 shim:

Terminal window
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm exec tsc --version
Version 7.0.2

tsc 不是 shell 自带命令,也不是独立安装的系统程序。它来自 TypeScript 包的 bin 声明,最终由 Node.js 执行。判断一个包能做什么,应查看它的 exportsmainbin、平台依赖和安装脚本,而不是只看包名。

一次安装改变了哪些东西#

可以把项目依赖分成三层:

  1. 声明层package.json 中的 dependenciesdevDependencies
  2. 解析层:lockfile 中的实际版本、间接依赖、peer dependency 和完整性信息。
  3. 落盘层:store、virtual store、node_modules 链接和 .bin 入口。

运行 pnpm add lodash 时,大致发生四步:

  1. 读取项目边界和 registry 配置。
  2. 查询包元数据,按照 semver 范围选择版本并写入 lockfile。
  3. 下载 tarball,校验后写入内容寻址 store。
  4. 在项目中创建依赖链接和命令入口。

用命令查看这些层:

Terminal window
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm root
/tmp/tmp.xxxxxxxx/node_modules
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm store path
<home-directory>/.local/share/pnpm/store/v11
user@wsl:/tmp/tmp.xxxxxxxx$ realpath node_modules/lodash
/tmp/tmp.xxxxxxxx/node_modules/.pnpm/lodash@4.18.1/node_modules/lodash
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm why sharp
sharp@0.35.4
└── tmp-project@1.0.0 (dependencies)

pnpm 的 store 像共享零件仓,node_modules/.pnpm 像当前项目的装配区,顶层 node_modules/lodash 是给 Node.js 看的入口。多个项目可以复用 store,但项目自己的 package.json、lockfile、链接和 peer 组合仍然独立。

npm 的目标相同,但默认的依赖布局不同。应用代码应依赖包公开的 exportsmain,不要依赖某个包管理器偶然生成的内部路径。

^1.2.3 是版本范围,不是唯一版本。它允许一定范围内的更新;lockfile 则记录这次实际选择的版本。pnpm install --frozen-lockfile 会拒绝在安装时偷偷修改 lockfile,适合 CI 和新机器:

Terminal window
user@wsl:/path/to/project$ pnpm install --frozen-lockfile
Lockfile is up to date, resolution step is skipped
Already up to date

npm 项目通常使用 npm ci 达到类似的“按已有锁文件安装”效果。pnpm installpnpm update 也不是一回事:前者按当前声明安装,后者主动寻找允许范围内的新版本。

命令为什么能运行#

普通 shell 通过 PATH 查找命令:

Terminal window
user@wsl:/tmp/tmp.xxxxxxxx$ command -v node
<node-installation>/bin/node
user@wsl:/tmp/tmp.xxxxxxxx$ type -a pnpm
pnpm is <node-installation>/bin/pnpm

执行项目脚本时,npm 和 pnpm 会临时把项目的 node_modules/.bin 放到脚本环境的 PATH 前面。因此,安装 TypeScript 后可以在 package.json 中定义:

"scripts": {
"type-check": "tsc --version"
}

普通 shell 不会自动注入这层 PATH。可以通过以下两种入口明确执行同一个本地 tsc

Terminal window
user@wsl:/path/to/project$ pnpm exec tsc --version
Version 7.0.2
user@wsl:/path/to/project$ node_modules/.bin/tsc --version
Version 7.0.2

四种常见入口可以这样区分:

  1. node src/index.mjs:Node.js 直接运行文件。
  2. pnpm exec tsc --version:从当前项目依赖中寻找并运行 tsc
  3. pnpm run dev:读取 package.jsonscripts,并注入本地 .bin PATH。
  4. 脚本启动 Node.js、shell 或其他子进程:子进程继承脚本环境。

npm runpnpm run 和直接执行#

npm run buildpnpm run build 都会读取 package.jsonscripts 字段,并把项目的 node_modules/.bin 放到脚本环境的 PATH 前面。脚本因此可以直接调用本地安装的 tscastrovite,不需要用户把它们全局安装一份。

我通常把入口分成三类:

  1. node src/index.mjs:Node.js 直接运行文件,完全绕开包管理器脚本。
  2. pnpm exec tsc --version:从当前项目依赖中寻找并运行本地 CLI。
  3. pnpm run type-check:读取脚本定义,并注入 .bin PATH;npm 项目对应 npm run type-check

pnpm dlxnpxnpm exec 适合一次性获取并执行 CLI。它们的便利之处也是风险所在:命令可能触发一次临时下载,且不会把工具记录为项目依赖。需要可复现的工具,应写入 devDependencies,再通过脚本或 pnpm exec 使用。

registry、换源和网络故障#

registry 是提供包元数据和 tarball 的 HTTP 服务。它可以是 npm 默认 registry、第三方镜像、公司私有 registry 或代理缓存。切换 registry 只改变查询和下载的来源,不改变项目依赖目录,也不会自动修复错误的版本范围。

我会先检查当前配置,而不是直接删除缓存:

Terminal window
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm config get registry
https://registry.npmjs.org/
user@wsl:/tmp/tmp.xxxxxxxx$ npm config get registry
https://registry.npmjs.org/

只验证一次时,使用命令行参数,不改变配置文件:

Terminal window
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm view lodash version --registry=https://registry.npmjs.org/
4.18.1

项目级 .npmrc 适合项目配置,用户级配置适合个人默认值。认证信息不要写死在仓库中:

//registry.example.invalid/:_authToken=${NPM_TOKEN}

遇到 ERR_PNPM_NO_MATCHING_VERSION,先检查包名、registry 和可用版本,再检查 semver、lockfile 和平台限制。遇到网络超时或证书错误,依次检查 registry、DNS、代理、系统时间和 CA。不要把清空整个缓存当成万能修复。

audit 与生命周期脚本#

audit 会把依赖树中的包名和版本与已知漏洞公告进行匹配。它不审查业务代码,也不能证明没有报告的包绝对安全:

Terminal window
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm audit
No known vulnerabilities found
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm audit --prod
No known vulnerabilities found

安装依赖本身也可能执行代码。preinstallinstallpostinstallprepare 可能编译 native addon、下载平台产物、生成代码,甚至执行任意脚本。安装前应审查包来源和生命周期脚本;在 CI 中可以评估 --ignore-scripts 是否适合项目,但不要把关闭安全检查当成第一步修复。

如果项目的 package.json 声明了 devEngines.packageManager,却使用了错误的包管理器,npm 可能报:

npm error code EBADDEVENGINES
npm error Invalid name "pnpm" does not match "npm"

这表示当前 npm 不符合项目要求,不是一次漏洞扫描结果。正确做法是切换到项目声明的 pnpm,而不是删除检查或绕过它。

从零建立可迁移环境#

新机器可以按以下顺序操作:

  1. 在 WSL2 中准备 Ubuntu 和 shell。
  2. 使用 nvm 安装满足项目 engines 的 Node.js,并确认 shell 启动文件已加载 nvm。
  3. 检查 node --versionnpm --version
  4. 启用或安装项目规定的 pnpm,并检查 corepack --version(若存在)和 pnpm --version
  5. 进入项目根目录,先阅读 package.jsonpackageManagerengines 和 lockfile 类型。
  6. 执行项目规定的锁定安装:pnpm 项目使用 pnpm install --frozen-lockfile,npm 项目通常使用 npm ci
  7. 按项目文档执行开发、检查、构建和预览命令。

如果项目位于 /mnt/c 或其他 Windows 挂载路径,安装和大量小文件访问可能明显慢于 Linux 文件系统中的 ~/projects。这是 I/O 边界的代价,不是 pnpm store 损坏;我通常把源码和 store 放在 WSL2 的 Linux 文件系统里,只通过编辑器或 Git 与 Windows 侧协作。

最小排错表:

症状先查什么最小修复如何确认
command not foundcommand -v pnpmecho $PATH修正 PATH 或 Corepack 入口pnpm --version
ERR_PNPM_NO_MATCHING_VERSION包名、registry、可用版本修正版本范围或 registrypnpm view <pkg> versions
registry 超时或证书错误registry、代理、系统时间修正源、DNS、代理或 CA单次 pnpm view 成功
lockfile 不同步git diff package.json pnpm-lock.yaml使用正确管理器更新锁文件pnpm install --frozen-lockfile
Node 不满足 enginesnode --version切换 Node.js 版本版本满足 engines
native 模块安装失败pnpm why <pkg>、平台包和安装日志修复平台依赖或编译链实际导入模块
脚本找不到本地 CLIpnpm exec <cmd>、脚本 PATH使用项目脚本或 pnpm exec<cmd> --version
EBADDEVENGINESpackageManager、当前包管理器使用项目声明的管理器pnpm --version

整条路径可以概括为:

Shell PATH

npm 或 pnpm

registry 元数据

tarball

pnpm store

virtual store

node_modules 链接

.bin shim

Node.js 进程

package.json scripts

Shell PATH

npm 或 pnpm

registry 元数据

tarball

pnpm store

virtual store

node_modules 链接

.bin shim

Node.js 进程

package.json scripts

真正值得记住的不是一串安装咒语,而是这条边界链:包从哪个 registry 来,版本解析记录在哪里,文件落在哪一层,命令由哪个入口转给哪个 Node.js。顺着这条链排查,npm 和 pnpm 的差异就会变成可以观察和验证的实现选择。未来包管理器大概还会继续把“下载一个包”包装成更多自动化,但 lockfile、脚本和运行时边界不会凭空消失;理解它们,至少能让下一次 command not found 变成一个可定位的实验,而不是一次祈祷。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!

打赏
npm 与 pnpm 安装的到底是什么?从 JavaScript 包到原生模块
https://bfmhno3.github.io/posts/npm-pnpm-tooling-guide/
作者
bfmhno3
发布于
2026-08-28
许可协议
CC BY-NC-SA 4.0
随机文章随机推荐

评论区

Profile Image of the Author
bfmhno3
Hello, I'm bfmhno3.
公告
本站曾使用 Jekyll + Minimal Mistakes 主题,现已迁移至 Astro + Firefly。博客内容未变,但文章链接有所调整,请自行查找需要的文章。
分类
标签
最新动态
站点统计
文章
36
分类
2
标签
58
总字数
136,569
运行时长
0
最后活动
0 天前
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.16.7
文章许可
CC BY-NC-SA 4.0
文章目录