Node.js 包管理器选型:pnpm、npm 与 yarn 原理对比

Node.js 生态中有四个常用的包管理相关工具:npm、yarn、pnpm 和 npx。选错工具或混用会带来依赖冲突、CI 构建慢、磁盘浪费等问题。本文从底层原理出发,用类比解释设计差异,重点在于 “ 为什么 “,而非只罗列 “ 怎么做 “。

1. 四个工具的定位

1.1 核心定义

  • npm:Node.js 官方包管理器,随 Node.js 一同安装,是最广泛使用的基准工具
  • yarn:Facebook 2016 年发布的替代品,引入 yarn.lock 解决了 npm 早期安装结果不可复现的问题
  • pnpm:通过硬链接共享全局缓存,节省磁盘空间,并通过严格的目录结构从根本上解决幽灵依赖
  • npx:npm 内置的执行器,专门用于临时运行一次性命令,无需全局安装

npm / yarn / pnpm 是包管理器,负责安装和维护项目依赖;npx 是执行器,负责按需运行命令。

这四个工具管理的是 npm registry 上的包——registry 是存储和分发包的远程仓库(类似 App Store),默认地址是 registry.npmjs.org,国内可配置为镜像源加速下载。覆盖范围不只是 Node.js 后端,还包括浏览器端 JavaScript:

1
2
3
4
5
npm registry 的内容:
├── Node.js 库 ← 后端:express、fs-extra、fastify
├── 浏览器 JS 库 ← 前端:React、Vue、lodash(与 Node.js 无关)
├── CLI 工具 ← 用 Node.js 编写的命令行工具:eslint、vite
└── @types/* 类型声明 ← TypeScript 类型补全

它们不负责操作系统级别的包(那是 apt / brew / yum 的工作),也不管理 Python、Rust、Go 等其他语言的依赖。

1.2 为什么会有这么多工具

npm 诞生于 2010 年,随 Node.js 捆绑发布。2015 年 npm v3 主要为了减少重复依赖、降低磁盘占用,把所有嵌套依赖 “ 打平 “ 到顶层 node_modules/,同时也缓解了 Windows 路径长度限制。这个决定带来了两个长期问题:

  1. 安装速度慢:npm v3 早期没有并行下载,大项目安装动辄几分钟
  2. 幽灵依赖:打平结构让代码能访问未声明的间接依赖(下文详解)

yarn(2016)重点解决了速度问题,pnpm(2017)同时解决了速度和幽灵依赖。三者并存至今。

1.3 node_modules 的设计差异

node_modules/ 是项目根目录下存放所有已安装依赖文件的目录,运行 npm install 后自动生成,体积通常较大(几十到几百 MB),不提交到 git。三个工具对这个目录的组织方式截然不同,这是理解它们最核心的差异。

Npm / Yarn v1:各项目备一份复印件

假设你有 5 个项目,都用到了 lodash。npm 的做法是每个项目都在自己的 node_modules/ 里放一份完整的 lodash 文件——5 个项目 = 5 份复印件,各占各的磁盘空间。

“ 打平 “ 过程中,npm 把间接依赖(依赖的依赖)也提升到顶层——本意是避免相同包被重复安装多次,但带来了一个意外后果:

1
2
3
4
node_modules/
├── express/ ← 你在 package.json 里声明的直接依赖
├── accepts/ ← express 内部用的包,被"提升"到顶层 ⚠️
└── mime-types/ ← 同上 ⚠️

被提升到顶层的 acceptsmime-types,你的代码同样可以 require('accepts')——即使从未声明过它。这就是幽灵依赖:某天 express 升级后不再依赖 accepts,你的代码就静默崩溃了。

pnpm:全城共享一个图书馆

pnpm 维护一个全局 store(路径因操作系统而异,可通过 pnpm store path 查看),所有项目的所有包只存一份真实文件。pnpm 通过两层机制共享文件,避免复制:

  1. store → 虚拟存储(.pnpm):使用硬链接(在支持 reflinks 的文件系统如 macOS APFS / Linux btrfs 上优先用写时复制)将 store 中的文件映射到项目内的 .pnpm 目录
  2. .pnpm → 顶层 node_modules:使用符号链接将 .pnpm 中的包暴露给顶层
%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#E0EDFF', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#D1FAE5', 'tertiaryColor': '#FEF3C7'}}}%%
flowchart LR
    store["全局 store<br/>lodash 4.17.21<br/>唯一真实文件"]

    subgraph projA [项目 A]
        A_pnpm[".pnpm/lodash 4.17.21"]
        A_top["node_modules/lodash"]
        A_pnpm -- 符号链接 --> A_top
    end

    subgraph projB [项目 B]
        B_pnpm[".pnpm/lodash 4.17.21"]
        B_top["node_modules/lodash"]
        B_pnpm -- 符号链接 --> B_top
    end

    store -- 硬链接 零额外磁盘占用 --> A_pnpm
    store -- 硬链接 零额外磁盘占用 --> B_pnpm

    style store fill:#3B82F6,stroke:#2563EB,color:#fff
    style A_pnpm fill:#0EA5E9,stroke:#0284C7,color:#fff
    style B_pnpm fill:#0EA5E9,stroke:#0284C7,color:#fff
    style A_top fill:#10B981,stroke:#059669,color:#fff
    style B_top fill:#10B981,stroke:#059669,color:#fff

具体目录结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
~/.local/share/pnpm/store/   ← 全局唯一的 store,存储所有真实文件
└── lodash@4.17.21/

项目 A 的 node_modules/
├── lodash → .pnpm/lodash@4.17.21/node_modules/lodash ← 符号链接(顶层可见)
└── .pnpm/
└── lodash@4.17.21/
└── node_modules/
└── lodash/ ← 硬链接到 store(零额外磁盘占用)

项目 B 的 node_modules/
└── .pnpm/
└── lodash@4.17.21/
└── node_modules/
└── lodash/ ← 同样硬链接到 store 的同一份文件

同时,pnpm 不做 “ 打平 “ 操作,node_modules/ 顶层只有你直接声明的包:

1
2
3
4
5
6
7
8
node_modules/
├── express → .pnpm/express@4.18.2/node_modules/express ← 符号链接(直接依赖)
└── .pnpm/
└── express@4.18.2/
└── node_modules/
├── express/ ← 实际文件
├── accepts/ ← 隔离在 express 自己的目录下,顶层访问不到
└── mime-types/

accepts 不在顶层,require('accepts') 会直接报错,而不是静默通过后留下隐患。

1.4 Node.js 生态中的其他工具

除这四个工具外,生态中还有几个值得了解:

工具类型特点
Bun运行时 + 包管理器用 Zig 编写,安装速度快,兼容 npm registry,可直接替代 npm
Deno运行时 + 包管理直接用 URL 引用模块,有自己的 JSR registry,不依赖 node_modules
ni包管理器适配层自动检测项目用的是 npm / yarn / pnpm,统一命令入口(ni = install,nr = run)
Volta工具链版本管理器管理 Node.js 和包管理器本身的版本,类似 nvm 但支持按项目自动切换

目前主流项目仍以 npm / yarn / pnpm 为主。Bun 在速度敏感场景(如 CI)有竞争力,但生态兼容性仍在完善中。

理解了设计差异,接下来是日常使用中频率最高的命令速查。

2. 常用命令速查

2.1 项目初始化

操作npmyarnpnpm
交互式初始化npm inityarn initpnpm init
快速初始化(跳过问答)npm init -yyarn init -ypnpm init

2.2 安装与删除依赖

操作npmyarnpnpm
安装全部依赖npm installyarnpnpm install
安装生产依赖npm install <pkg>yarn add <pkg>pnpm add <pkg>
安装开发依赖npm install <pkg> -Dyarn add <pkg> -Dpnpm add <pkg> -D
全局安装npm install -g <pkg>yarn global add <pkg>pnpm add -g <pkg>
删除依赖npm uninstall <pkg>yarn remove <pkg>pnpm remove <pkg>
更新所有依赖npm updateyarn upgradepnpm update
查看可更新依赖npm outdatedyarn outdatedpnpm outdated

2.3 运行脚本

操作npmyarnpnpm
运行自定义脚本npm run <script>yarn <script>pnpm <script>
运行 startnpm startyarn startpnpm start
运行 testnpm testyarn testpnpm test

yarn 和 pnpm 可以省略 run,直接写脚本名。npm 只有 startteststoprestart 四个内置脚本可以省略 run,其余必须加 run

2.4 依赖信息与审计

操作npmyarnpnpm
列出已安装包npm list --depth=0yarn list --depth=0pnpm list
查看包详情npm info <pkg>yarn info <pkg>pnpm info <pkg>
安全漏洞检查npm audityarn auditpnpm audit
自动修复漏洞npm audit fixpnpm audit --fix(pnpm v9+)

2.5 Pnpm Store 管理

pnpm 的全局 store 需要定期维护,在 CI 或磁盘清理时有用:

1
2
3
pnpm store path      # 查看全局 store 所在目录
pnpm store status # 检查 store 中文件是否完整
pnpm store prune # 清理没有项目引用的缓存包(安全操作,不影响现有项目)

2.6 Npx 典型用法

npx 的核心是临时执行命令,执行完不留下全局安装的痕迹:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 用脚手架创建新项目(最常见用法)
npx create-react-app my-app
npx create-next-app@latest my-app
npx @vue/cli create my-app

# 运行项目本地安装的命令(避免和全局版本冲突)
npx tsc --version # 等价于 ./node_modules/.bin/tsc --version

# 指定特定版本运行(临时测试兼容性)
npx node@18 -e "console.log(process.version)"

# 一次性工具,用完即走
npx serve . # 临时起一个静态文件服务器
npx prettier --write . # 临时格式化,无需项目已安装 prettier

⚠️ 不要用 npx 代替正式的依赖安装。npx typescript 的版本不受 lock 文件控制,每次执行结果可能不一致。项目依赖应通过 npm install typescript -D 安装后,再用 npx tsc 调用。

命令速查到此完结,如何在三个包管理器之间做选择,取决于项目现状与团队共识——以下决策树梳理了常见场景。

3. 工具选型

%%{init: {'theme': 'base', 'themeVariables': { 'primaryColor': '#3B82F6', 'primaryTextColor': '#1E3A5F', 'primaryBorderColor': '#2563EB', 'lineColor': '#60A5FA', 'secondaryColor': '#10B981', 'tertiaryColor': '#F59E0B'}}}%%
flowchart TD
    A(["开始选型"]) --> B{"是否是新项目?"}

    B -->|"是"| C{"有 monorepo 需求?"}
    B -->|"否"| D{"当前在用什么?"}

    C -->|"是"| E["pnpm workspaces"]
    C -->|"否"| F["pnpm"]

    D -->|"npm"| G{"有迁移意愿?"}
    D -->|"yarn v1"| H{"有迁移意愿?"}
    D -->|"yarn Berry"| I["继续用 yarn Berry"]

    G -->|"是"| F
    G -->|"否"| J["npm"]

    H -->|"是"| F
    H -->|"否"| K["yarn v1"]

    classDef start fill:#3B82F6,stroke:#2563EB,color:#fff
    classDef decision fill:#F59E0B,stroke:#D97706,color:#1E3A5F
    classDef result fill:#10B981,stroke:#059669,color:#fff
    classDef neutral fill:#0EA5E9,stroke:#0284C7,color:#fff

    class A start
    class B,C,D,G,H decision
    class E,F neutral
    class I,J,K result

已有项目的核心原则是团队一致性优先于工具先进性:yarn v1 / Berry 项目迁移收益有限,保持现状通常是更合理的选择。npx 只用于一次性命令(脚手架创建、临时格式化等),不替代正式依赖管理。

工具选定后,有几条实践规范能避免日后的维护陷阱。

4. 最佳实践

始终将 lock 文件提交到 git。lock 文件(package-lock.json / yarn.lock / pnpm-lock.yaml)是包管理器自动生成的快照,记录了每个依赖的精确版本号和下载来源。提交它有三个好处:

  • 团队成员 clone 代码后 install,安装的版本与你完全一致
  • CI 每次构建与本地开发环境一致,避免 “ 在我机器上能跑 “ 的问题
  • 可以通过 git diff 审查依赖版本的变动历史

同时将 node_modules/ 加入 .gitignore——它可以随时从 lock 文件重建,不需要纳入版本控制。

package.json 中用 packageManager 字段锁定工具版本,配合 only-allowpreinstall 钩子中强制检测,防止团队成员误用其他工具:

1
2
3
4
5
6
{
"packageManager": "pnpm@9.0.0",
"scripts": {
"preinstall": "npx only-allow pnpm"
}
}

⚠️ 不要在同一项目中混用包管理器。 不同工具生成的 lock 文件互不兼容,混用会导致依赖版本不一致。

基础用法到此完结。以下三个进阶场景按需查阅:Monorepo、配置文件、CI 缓存。

5. 进阶场景

5.1 Monorepo Workspaces

什么是 Monorepo

Monorepo(单一仓库)是将多个相关项目/包放在同一个 git 仓库中管理的策略,与之对应的是 Polyrepo(每个项目独立一个仓库)。

1
2
3
4
5
6
Polyrepo(传统做法)          Monorepo
──────────────────── ─────────────────────
repo-ui/ my-org/
repo-utils/ → ├── packages/ui/
repo-app/ ├── packages/utils/
└── apps/app/

为什么引入 Monorepo

假设你有三个项目:一个组件库 ui、一套工具函数 utils,和一个使用它们的应用 app。用 Polyrepo 管理时,每次改了 ui 都要:发布新版本到 npm → 在 app 里更新版本号 → 重新安装 → 测试。改一个组件涉及三个仓库,协作摩擦极高。

Monorepo 解决的核心问题:

问题Polyrepo 的痛点Monorepo 的解法
跨包联调改 A 要先发版,B 才能用本地直接引用,改了立即生效
依赖一致性各仓库 eslint/typescript 版本各不相同根目录统一管理,全局一个版本
原子提交“ 修复 ui 组件并更新 app 用法 “ 需要跨仓库两个 PR一个 commit 同时改 ui 和 app
CI 配置每个仓库重复维护 workflow共享一套 CI,按变更范围选择性构建

适合的场景:多个包之间有强依赖关系、需要频繁跨包联调、希望统一代码规范和工具链。React、Vue、Babel、Next.js 等知名开源项目都采用 Monorepo。

不适合的场景:相互独立的项目,强行放在一起只会增加仓库体积和权限管理复杂度。

Workspaces:包管理器对 Monorepo 的支持

Workspaces 是 npm / yarn / pnpm 为 Monorepo 提供的原生支持,核心功能是让包管理器识别仓库内的多个子包,并在它们之间建立本地链接,无需发布到 npm。npm、yarn、pnpm 都支持 workspaces,pnpm 的支持最为完善。

典型目录结构:

1
2
3
4
5
6
7
8
9
10
my-monorepo/
├── package.json ← 根配置,声明 workspaces
├── pnpm-workspace.yaml ← pnpm 专属(可选,替代 package.json 里的声明)
└── packages/
├── ui/ ← 子包:组件库
│ └── package.json
├── utils/ ← 子包:工具函数
│ └── package.json
└── app/ ← 子包:主应用
└── package.json

声明 Workspaces

npm 和 yarn 在根 package.json 中配置:

1
2
3
{
"workspaces": ["packages/*"]
}

pnpm 还支持独立的 pnpm-workspace.yaml(推荐,避免 package.json 臃肿):

1
2
3
4
# pnpm-workspace.yaml
packages:
- 'packages/*'
- 'apps/*'

子包互相引用

monorepo 中子包之间通过 workspace: 协议引用,无需发布到 npm 即可本地链接:

1
2
3
4
5
6
7
// packages/app/package.json
{
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/utils": "workspace:^1.0.0"
}
}

workspace:* 表示始终使用本地版本。pnpm 对 workspace: 协议的支持最完整;npm workspaces 不支持该语法(本地引用需用 file:../packages/ui);yarn v1 同样不支持该语法,workspace: 协议是 Yarn Berry(v2+)引入并与 pnpm 对齐的特性。

常用 Workspace 操作(pnpm)

1
2
3
4
5
6
7
8
9
# 在所有子包中递归执行同一命令
pnpm -r run build
pnpm -r run test

# 只在指定子包中安装依赖
pnpm --filter @myorg/app add express

# 在根目录安装开发工具(供所有子包共用)
pnpm add -D typescript -w # -w 表示写入根 package.json(workspace root)

5.2 .npmrc 常用配置

以下三种情况需要修改 .npmrc:下载速度慢(需要切换国内镜像源)、项目依赖私有 registry 上的包、需要让团队成员共享统一的安装配置。

.npmrc 是 npm 和 pnpm 共同识别的配置文件(部分字段各有差异),放在项目根目录或用户主目录(~/.npmrc)。Yarn v1 可读取部分 .npmrc 字段(如 registry),但 Yarn Berry(v2+)使用独立的 .yarnrc.yml,不读取 .npmrc。项目根目录的 .npmrc 可以提交到 git,让团队成员自动获得统一配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 设置 registry(国内镜像源,加速下载)
registry=https://registry.npmmirror.com

# 针对特定 scope 使用私有 registry
@myorg:registry=https://npm.myorg.internal

# 安装时锁定精确版本(不加 ^ 或 ~,适合要求严格的项目)
save-exact=true

# pnpm 专用:设置全局 store 位置(多磁盘时可将 store 指向大容量盘)
store-dir=/data/.pnpm-store

# pnpm 专用:提升指定包到顶层(兼容不支持 pnpm 严格模式的旧工具)
# public-hoist-pattern[]=*eslint*
# public-hoist-pattern[]=*prettier*

将 registry 配置放在项目 .npmrc 而非全局,能确保新人 clone 代码后自动使用正确的镜像源,不需要任何额外步骤。

5.3 CI/CD 缓存配置

CI(持续集成)环境每次运行都是全新的容器,没有上次留下的任何文件。这意味着每次 push 代码,CI 都要从 registry 重新下载所有依赖——一个中等规模项目可能有几百个包,下载耗时几分钟,还消耗网络流量。

解决方案是缓存依赖目录:把 node_modules 或 pnpm store 在构建之间持久化,下次构建时如果 lock 文件没变就直接复用,跳过下载步骤。

GitHub Actions + pnpm(推荐)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# .github/workflows/ci.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm' # 自动缓存 pnpm store,命中缓存时跳过下载

- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run test

--frozen-lockfile 禁止 CI 修改 lock 文件,确保安装结果与提交的 lock 完全一致,发现不一致时直接报错(而非静默更新)。

GitHub Actions + Npm

1
2
3
4
5
6
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'

- run: npm ci # CI 专用命令:先删除 node_modules 再按 lock 文件重装

npm cinpm install 的区别:npm ci 先删除 node_modules/ 再重装,严格按 lock 文件安装且不修改 lock 文件,适合 CI 环境。npm install 可能更新 lock 文件,不适合 CI。

手动缓存 Pnpm store(不支持 cache: pnpm 时的备用方案)

1
2
3
4
5
6
7
8
9
- name: 获取 pnpm store 路径
id: pnpm-cache
run: echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT

- uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: pnpm-store-