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.json和package-lock.json,也能执行项目脚本。 - pnpm 是独立的 Node.js 包管理器。它与 npm 使用同一个 npm registry 生态,但命令、锁文件和
node_modules的组织方式有所不同。 - nvm 是 Node.js 版本管理器,用来安装和切换 Node.js 版本。它不是包管理器,也不负责安装项目依赖。
- Corepack 是包管理器入口和版本准备工具。它可以根据项目声明准备指定版本的 pnpm 或 Yarn,但它不是 npm,也不负责安装项目依赖。
npm 和 pnpm 怎么选
两者都能创建项目、安装依赖和执行脚本,但默认行为不同:
| 对比项 | npm | pnpm |
|---|---|---|
| 通常来源 | 随 Node.js 发行版提供 | 独立安装或由 Corepack 准备 |
| 锁文件 | package-lock.json | pnpm-lock.yaml |
| 默认依赖布局 | 直接组织在项目 node_modules 中 | 共享 store,加 virtual store 和链接 |
| 磁盘复用 | 依赖具体版本和配置 | 共享内容寻址 store,通常更节省空间 |
| 依赖声明约束 | 相对宽松 | 链接布局更容易暴露未声明依赖 |
| 添加依赖 | npm install <pkg> | pnpm add <pkg> |
推荐规则很简单:新项目优先考虑 pnpm,已有项目必须遵循项目声明。 如果项目已经提交了 package-lock.json,通常使用 npm;如果项目已经提交了 pnpm-lock.yaml,就使用 pnpm。不要在同一个项目里交替执行 npm install 和 pnpm install,这样会产生两套锁文件和难以解释的依赖变化。
pnpm 并不是所有场景的唯一答案。只想快速开始一个小项目,或者参与明确要求 npm 的项目时,npm 更直接。选择的关键不是“哪个命令更酷”,而是项目是否有明确的包管理器契约,以及团队是否统一使用它。
在 WSL2 中准备 Node.js
进入 Ubuntu 后,可以先确认系统和 shell。以下输出只保留与教程有关的部分:
user@wsl:~$ uname -srLinux 6.x.x-microsoft-standard-WSL2user@wsl:~$ bash --version | head -n 1GNU bash, version 5.1.16使用 nvm 安装 Node.js。安装远程脚本前应先阅读来源和内容,因为 curl | bash 会直接执行下载的脚本:
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:
user@wsl:~$ . "$HOME/.nvm/nvm.sh"user@wsl:~$ nvm install 24Downloading and installing node v24.20.0...Computing checksum with sha256sumChecksums matched!Now using node v24.20.0 (npm v11.19.0)Creating default alias: default -> 24 (-> v24.20.0 *)user@wsl:~$ node --versionv24.20.0user@wsl:~$ npm --version11.19.0这里的 npm 已经随 Node.js 一起提供。此时可以使用 npm,但还没有因为安装 Node.js 就自动得到 pnpm。
Corepack 到底做什么
Corepack 最容易被误解成“另一个包管理器”。更准确的分层是:
- Node.js 执行 JavaScript。
- npm 是一个包管理器,通常随 Node.js 分发。
- pnpm 和 Yarn 是其他包管理器。
- Corepack 是包管理器版本管理器,为 pnpm、Yarn 等准备版本并提供转发入口。
Corepack 解决的问题是:应该运行哪个版本的包管理器。它不解决:项目应该安装哪些依赖。后一个问题仍由 pnpm install、pnpm add、npm install 等命令负责。
这里有一个容易随 Node.js 版本变化的现实:不能假设每个 Node.js 发行版都内置 Corepack。检查命令是否存在;如果不存在,按项目文档安装 pnpm,或者使用 pnpm 官方提供的安装方式。无论采用哪种方式,最后都要检查实际运行的 pnpm --version,而不是只检查安装命令是否成功。
corepack enable pnpm 发生了什么
在提供 Corepack 的 Node.js 环境中执行:
user@wsl:~$ corepack --version0.35.0user@wsl:~$ corepack enable pnpmuser@wsl:~$ type -a pnpmpnpm is .../bin/pnpmcorepack enable pnpm 通常会创建或启用一个名为 pnpm 的 shim。shim 不是完整的 pnpm,它更像一个转接头:用户执行 pnpm 时,shim 把请求交给 Corepack;Corepack 再准备合适版本的 pnpm 并转发参数。
因此第一次执行 pnpm 时,可能看到下载确认:
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] y11.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 专属选项当成跨工具协议。项目最好只保留实际使用的包管理器配置,并把团队约定写进项目文档或自动检查中。
检查四个版本时,命令和输出应分别理解:
user@wsl:~$ node --versionv24.20.0user@wsl:~$ npm --version11.19.0user@wsl:~$ corepack --version0.35.0user@wsl:~$ pnpm --version11.24.0如果某个 Node.js 发行版没有提供 Corepack,corepack 命令可能不存在。这时应按照项目文档安装 pnpm,最后仍然用 pnpm --version 检查实际运行版本。全局安装 pnpm、Corepack 提供命令入口、pnpm add 安装项目依赖,是三个不同动作。
不要把 corepack enable、全局安装 pnpm 和 pnpm add 混成一个动作。前两者处理“命令从哪里来、运行哪个版本”,最后一个才修改当前项目的依赖。
用隔离项目观察安装过程
不要为了学习包管理器直接修改重要项目。创建临时目录后,在其中安装几个形态不同的包:
user@wsl:~$ tmp_dir=$(mktemp -d)user@wsl:~$ echo "$tmp_dir"/tmp/tmp.xxxxxxxxuser@wsl:~$ cd "$tmp_dir"user@wsl:/tmp/tmp.xxxxxxxx$ pnpm initWrote to /tmp/tmp.xxxxxxxx/package.jsonuser@wsl:/tmp/tmp.xxxxxxxx$ pnpm add lodash typescript sharpPackages: +9Progress: 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.json,pnpm add 修改依赖声明、解析依赖并创建安装目录。
查看生成的声明:
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 的文件混在一起:
user@wsl:~$ npm_dir=$(mktemp -d)user@wsl:~$ cd "$npm_dir"user@wsl:/tmp/tmp.yyyyyyyy$ npm init -yWrote to /tmp/tmp.yyyyyyyy/package.jsonuser@wsl:/tmp/tmp.yyyyyyyy$ npm install --save-dev typescriptadded 1 package, and audited 2 packages in 1suser@wsl:/tmp/tmp.yyyyyyyy$ npm exec tsc -- --versionVersion 7.0.2这个目录会得到 package-lock.json,而不是 pnpm-lock.yaml。npm exec 和 pnpm exec 都优先使用项目本地的 CLI,但两者不会共享锁文件格式,也不应该在同一个项目里轮流负责更新依赖。这个对照实验的价值不在于哪一个命令少敲两个字符,而在于把“同一个 registry 生态”和“不同的项目安装实现”分开观察。
包不是语言
registry 发布的是带元数据的 tarball,而不是“一个 JavaScript 文件”。包中可以包含:
- JavaScript 或 TypeScript 源码
- WASM 文件
- 原生
.node模块 - 预编译二进制
- CLI 入口
- 其他包的运行时或平台依赖
实验中的三个包分别说明了不同情况:
lodash主要作为可导入的 JavaScript API 使用。typescript提供 API,也通过bin字段提供tsc命令。sharp会根据操作系统和 CPU 解析平台相关实现,安装时可能出现@img/*等平台包。
查询 registry 元数据:
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm view lodash dist.tarballhttps://registry.npmjs.org/lodash/-/lodash-4.18.1.tgzuser@wsl:/tmp/tmp.xxxxxxxx$ pnpm view typescript bin --json{ "tsc": "bin/tsc"}bin 字段表示包声明了命令入口。安装后,包管理器会在项目中创建 node_modules/.bin/tsc 的链接或 shim:
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm exec tsc --versionVersion 7.0.2tsc 不是 shell 自带命令,也不是独立安装的系统程序。它来自 TypeScript 包的 bin 声明,最终由 Node.js 执行。判断一个包能做什么,应查看它的 exports、main、bin、平台依赖和安装脚本,而不是只看包名。
一次安装改变了哪些东西
可以把项目依赖分成三层:
- 声明层:
package.json中的dependencies和devDependencies。 - 解析层:lockfile 中的实际版本、间接依赖、peer dependency 和完整性信息。
- 落盘层:store、virtual store、
node_modules链接和.bin入口。
运行 pnpm add lodash 时,大致发生四步:
- 读取项目边界和 registry 配置。
- 查询包元数据,按照 semver 范围选择版本并写入 lockfile。
- 下载 tarball,校验后写入内容寻址 store。
- 在项目中创建依赖链接和命令入口。
用命令查看这些层:
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm root/tmp/tmp.xxxxxxxx/node_modulesuser@wsl:/tmp/tmp.xxxxxxxx$ pnpm store path<home-directory>/.local/share/pnpm/store/v11user@wsl:/tmp/tmp.xxxxxxxx$ realpath node_modules/lodash/tmp/tmp.xxxxxxxx/node_modules/.pnpm/lodash@4.18.1/node_modules/lodashuser@wsl:/tmp/tmp.xxxxxxxx$ pnpm why sharpsharp@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 的目标相同,但默认的依赖布局不同。应用代码应依赖包公开的 exports 或 main,不要依赖某个包管理器偶然生成的内部路径。
^1.2.3 是版本范围,不是唯一版本。它允许一定范围内的更新;lockfile 则记录这次实际选择的版本。pnpm install --frozen-lockfile 会拒绝在安装时偷偷修改 lockfile,适合 CI 和新机器:
user@wsl:/path/to/project$ pnpm install --frozen-lockfileLockfile is up to date, resolution step is skippedAlready up to datenpm 项目通常使用 npm ci 达到类似的“按已有锁文件安装”效果。pnpm install 和 pnpm update 也不是一回事:前者按当前声明安装,后者主动寻找允许范围内的新版本。
命令为什么能运行
普通 shell 通过 PATH 查找命令:
user@wsl:/tmp/tmp.xxxxxxxx$ command -v node<node-installation>/bin/nodeuser@wsl:/tmp/tmp.xxxxxxxx$ type -a pnpmpnpm is <node-installation>/bin/pnpm执行项目脚本时,npm 和 pnpm 会临时把项目的 node_modules/.bin 放到脚本环境的 PATH 前面。因此,安装 TypeScript 后可以在 package.json 中定义:
"scripts": { "type-check": "tsc --version"}普通 shell 不会自动注入这层 PATH。可以通过以下两种入口明确执行同一个本地 tsc:
user@wsl:/path/to/project$ pnpm exec tsc --versionVersion 7.0.2user@wsl:/path/to/project$ node_modules/.bin/tsc --versionVersion 7.0.2四种常见入口可以这样区分:
node src/index.mjs:Node.js 直接运行文件。pnpm exec tsc --version:从当前项目依赖中寻找并运行tsc。pnpm run dev:读取package.json的scripts,并注入本地.binPATH。- 脚本启动 Node.js、shell 或其他子进程:子进程继承脚本环境。
npm run、pnpm run 和直接执行
npm run build 与 pnpm run build 都会读取 package.json 的 scripts 字段,并把项目的 node_modules/.bin 放到脚本环境的 PATH 前面。脚本因此可以直接调用本地安装的 tsc、astro 或 vite,不需要用户把它们全局安装一份。
我通常把入口分成三类:
node src/index.mjs:Node.js 直接运行文件,完全绕开包管理器脚本。pnpm exec tsc --version:从当前项目依赖中寻找并运行本地 CLI。pnpm run type-check:读取脚本定义,并注入.binPATH;npm 项目对应npm run type-check。
pnpm dlx、npx 和 npm exec 适合一次性获取并执行 CLI。它们的便利之处也是风险所在:命令可能触发一次临时下载,且不会把工具记录为项目依赖。需要可复现的工具,应写入 devDependencies,再通过脚本或 pnpm exec 使用。
registry、换源和网络故障
registry 是提供包元数据和 tarball 的 HTTP 服务。它可以是 npm 默认 registry、第三方镜像、公司私有 registry 或代理缓存。切换 registry 只改变查询和下载的来源,不改变项目依赖目录,也不会自动修复错误的版本范围。
我会先检查当前配置,而不是直接删除缓存:
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm config get registryhttps://registry.npmjs.org/user@wsl:/tmp/tmp.xxxxxxxx$ npm config get registryhttps://registry.npmjs.org/只验证一次时,使用命令行参数,不改变配置文件:
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 会把依赖树中的包名和版本与已知漏洞公告进行匹配。它不审查业务代码,也不能证明没有报告的包绝对安全:
user@wsl:/tmp/tmp.xxxxxxxx$ pnpm auditNo known vulnerabilities founduser@wsl:/tmp/tmp.xxxxxxxx$ pnpm audit --prodNo known vulnerabilities found安装依赖本身也可能执行代码。preinstall、install、postinstall 和 prepare 可能编译 native addon、下载平台产物、生成代码,甚至执行任意脚本。安装前应审查包来源和生命周期脚本;在 CI 中可以评估 --ignore-scripts 是否适合项目,但不要把关闭安全检查当成第一步修复。
如果项目的 package.json 声明了 devEngines.packageManager,却使用了错误的包管理器,npm 可能报:
npm error code EBADDEVENGINESnpm error Invalid name "pnpm" does not match "npm"这表示当前 npm 不符合项目要求,不是一次漏洞扫描结果。正确做法是切换到项目声明的 pnpm,而不是删除检查或绕过它。
从零建立可迁移环境
新机器可以按以下顺序操作:
- 在 WSL2 中准备 Ubuntu 和 shell。
- 使用 nvm 安装满足项目
engines的 Node.js,并确认 shell 启动文件已加载 nvm。 - 检查
node --version和npm --version。 - 启用或安装项目规定的 pnpm,并检查
corepack --version(若存在)和pnpm --version。 - 进入项目根目录,先阅读
package.json的packageManager、engines和 lockfile 类型。 - 执行项目规定的锁定安装:pnpm 项目使用
pnpm install --frozen-lockfile,npm 项目通常使用npm ci。 - 按项目文档执行开发、检查、构建和预览命令。
如果项目位于 /mnt/c 或其他 Windows 挂载路径,安装和大量小文件访问可能明显慢于 Linux 文件系统中的 ~/projects。这是 I/O 边界的代价,不是 pnpm store 损坏;我通常把源码和 store 放在 WSL2 的 Linux 文件系统里,只通过编辑器或 Git 与 Windows 侧协作。
最小排错表:
| 症状 | 先查什么 | 最小修复 | 如何确认 |
|---|---|---|---|
command not found | command -v pnpm、echo $PATH | 修正 PATH 或 Corepack 入口 | pnpm --version |
ERR_PNPM_NO_MATCHING_VERSION | 包名、registry、可用版本 | 修正版本范围或 registry | pnpm view <pkg> versions |
| registry 超时或证书错误 | registry、代理、系统时间 | 修正源、DNS、代理或 CA | 单次 pnpm view 成功 |
| lockfile 不同步 | git diff package.json pnpm-lock.yaml | 使用正确管理器更新锁文件 | pnpm install --frozen-lockfile |
| Node 不满足 engines | node --version | 切换 Node.js 版本 | 版本满足 engines |
| native 模块安装失败 | pnpm why <pkg>、平台包和安装日志 | 修复平台依赖或编译链 | 实际导入模块 |
| 脚本找不到本地 CLI | pnpm exec <cmd>、脚本 PATH | 使用项目脚本或 pnpm exec | <cmd> --version |
EBADDEVENGINES | packageManager、当前包管理器 | 使用项目声明的管理器 | pnpm --version |
整条路径可以概括为:
真正值得记住的不是一串安装咒语,而是这条边界链:包从哪个 registry 来,版本解析记录在哪里,文件落在哪一层,命令由哪个入口转给哪个 Node.js。顺着这条链排查,npm 和 pnpm 的差异就会变成可以观察和验证的实现选择。未来包管理器大概还会继续把“下载一个包”包装成更多自动化,但 lockfile、脚本和运行时边界不会凭空消失;理解它们,至少能让下一次 command not found 变成一个可定位的实验,而不是一次祈祷。
参考资料
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或打赏支持!











