Skip to content
页面导航
精简

一、pnpm对比npm的优势

1. 速度飞跃:并行安装与独创的阶段划分

  • npm 的安装流程是串行的,通常需要经历 Resolving(解析)-> Fetching(下载)-> Extracting(解压到 node_modules)等阶段,每个阶段必须等上一阶段完全结束。
  • pnpm 将安装过程拆分为不同的阶段,并针对每个依赖并行执行。这使得它的安装速度通常比 npm 快 2~3 倍。

(1). 传统 npm 的“阶段阻塞”痛点

传统的 npm(尤其是早期的版本)在安装依赖时,其核心工作流是宏观串行、按阶段阻塞的。 通常它会经历以下四个宏观阶段:

  1. Resolution(解析): 遍历 package.json,计算依赖树,锁定所有包的版本。
  2. Fetching(下载): 根据解析出的地址,去 npm 注册表(Registry)下载所有的压缩包(.tgz)。
  3. Extracting(解压): 将下载好的压缩包解压,并写入到本地的 node_modules 中。
  4. Linking/Postinstall(链接与脚本执行): 组织 node_modules 结构,并执行包里自带的 postinstall 脚本。

npm 的问题在于: 它通常需要等待整个依赖树的所有包都完成了“阶段 1”,才能集体进入“阶段 2”;所有包都下载完了,才能集体进入“阶段 3”。这就导致了严重的木桶效应——如果有一个网络极慢的包卡在下载阶段,其他已经下载好的包也只能干等着,无法进行解压和写入。

(2). pnpm 的核心:面向“单个依赖”的流式并行

pnpm 颠覆了这种宏观的阶段划分。它把安装流程拆分为三个原子阶段,但不再以“整个项目”为单位,而是以“单个依赖包”为单位进行流式流水线(Pipeline)作业。

💡 拆分的三个核心阶段:
  1. 依赖解析(Dependency Resolution): 查明包的版本,确定它的下载 URL。
  2. 目录结构计算(Directory Structure Creation): 在本地 .pnpm 中为这个特定的包规划好它未来的硬链接和软链接路径。
  3. 打包与构建依赖(Tarball Content Production): 下载、解压,并将文件写入到全局 Store 并在本地建立硬链接。如果该包有 postinstall 脚本,在此处执行。

⚡ 它是如何针对每个依赖并行执行的?

在 pnpm 中,一旦某一个具体的包(例如 lodash)完成了阶段 1(解析出了 URL),它不需要等待其他包,它自己会立刻进入阶段 2 和阶段 3(直接去下载、解压、写入磁盘)。

  • 异步非阻塞: 利用 Node.js 的异步 I/O 优势,上百个网络请求和磁盘写入同时发生。
  • 流水线作业: 想象一条汽车生产线,传统 npm 是等一千辆车都装好轮胎,再统一去喷漆;而 pnpm 是第一辆车装好轮胎,立刻送去喷漆,同时第二辆车开始装轮胎。
  • 彻底消除等待: 即使 dependency-A 因为体积大、网络慢卡在下载阶段,其他体积小的 dependency-BCD 依然会一路绿灯,直接完成解析、下载、解压、写入的全套流程。

这种设计最大化地压榨了你的网络带宽多核 CPU/硬盘读写性能,这也是为什么 pnpm 在冷启动(无缓存)安装时,速度依然能超越 npm 几倍的根本原因。

  • npm 在每个项目中都会完整复制一份依赖。如果你有 10 个项目都用到了 vue,你的磁盘上就会有 10 份一模一样的 vue 代码。
  • pnpm 会在磁盘的全局维护一个 内容寻址存储(Global Store)
  • 所有的依赖包只会在此处下载一次
  • 项目中的 node_modules 内部通过硬链接(Hard Link)指向这个全局 Store。
  • 优势: 极大地节省了磁盘空间;同时由于不需要重复下载和解压,第二次安装几乎是秒级完成。

3. 安全与规范:彻底解决“幽灵依赖(Phantom Dependencies)”

这是面试中的加分项

  • npm (v3 之后) 为了解决依赖层级过深的问题,采用了扁平化(Flat)node_modules 结构。这导致了一个副作用:如果你的项目依赖 A,A 依赖 B,npm 会把 B 也提升到根目录下。即使你的 package.json 里没有声明 B,你依然可以在代码中 import B from 'b'。这就是幽灵依赖。一旦 A 升级不再依赖 B,你的代码就会直接挂掉。
  • pnpm 采用了基于符号链接(Symbolic Link / Symlink)的嵌套结构
  • 它的根 node_modules只包含你真正安装的依赖
  • 真实的依赖代码和它们自身的二级依赖,全部被隐藏在 node_modules/.pnpm 目录中,并通过软链接组合。
  • 优势: 你的代码无法访问未声明的依赖,完全杜绝了幽灵依赖问题,项目更加健壮。

总结对比表

特性npm (v7+)pnpm
安装速度较慢极快(缓存机制与并行机制更优秀)
磁盘空间项目间独立,重复占用空间全局复用(硬链接,不重复占用)
node_modules 结构扁平化(Flat)基于 Symlink 的嵌套网状结构
幽灵依赖存在安全隐患完全解决(严格遵循 package.json)
Monorepo 支持支持(Workspaces)原生支持极佳(性能和隔离性更好)

二、两者在操作系统层面有什么区别?pnpm 是如何具体组合构建 node_modules 的?

在操作系统中,硬链接(Hard Link)软链接(软连接/符号链接,Symbolic Link / Symlink)是两种完全不同的文件指针机制。

pnpm 巧妙地结合了这两种技术,既解决了磁盘空间浪费的问题,又解决了依赖安全(幽灵依赖)的问题。

一、 操作系统层面的区别

为了更好地理解,我们可以把文件拆分为两部分:用户看到的文件名硬盘上实际存储的数据块(inode)

  • 本质: 是现有文件的绝对别名。它直接指向硬盘上相同的实际数据块(同一个 inode)。
  • 特点:
  • 创建硬链接不会占用额外的磁盘空间。
  • 删除源文件,硬链接依然可用,内容不受影响(只有当指向该数据块的所有硬链接都被删除时,文件才真正被删)。
  • 限制: 不能跨文件系统(跨盘符,如 C 盘链接到 D 盘),也不能链接目录。
  • 本质: 类似于 Windows 的快捷方式。它是一个独立的文件,内部存储的是源文件的绝对或相对路径
  • 特点:
  • 删除源文件后,软链接会失效(变成“死链接”)。
  • 优势: 可以跨文件系统,并且可以链接目录

二、 pnpm 是如何组合使用它们的?

如果你打开一个 pnpm 项目的 node_modules,你会发现它的内部结构既不是 npm v3 的完全扁平化,也不是旧版 npm 的无限嵌套,而是一个网状的链接结构

pnpm 的核心公式是:全局 Store --(硬链接)--> .pnpm 目录 --(软链接)--> 项目根目录

步骤 1:用“硬链接”解决空间问题(Store -> .pnpm)

  1. 当你安装依赖时,pnpm 首先把依赖下载到全局的 ~/.pnpm-store 中。
  2. 接着,pnpm 会在项目的 node_modules/.pnpm 目录下创建一个结构,并使用硬链接指向全局 Store 中对应的文件。
  3. 效果: 实际的代码文件在磁盘上只有一份,.pnpm 目录里的文件只是别名,完全不占额外空间。

步骤 2:用“软链接”解决幽灵依赖与层级结构(.pnpm -> 根目录)

由于硬链接不能链接目录,且无法构建复杂的树状网络,pnpm 开始使用软链接

  1. 对内(解决依赖的依赖): 如果 express 依赖 accepts,pnpm 会在 .pnpm/express@x.x.x/node_modules/ 下创建一个软链接,指向 .pnpm/accepts@x.x.x/node_modules/accepts。这样 express 就能顺利找到它需要的包。
  2. 对外(暴露给你的项目): 你在 package.json 里只声明了 express。pnpm 就会在项目的根 node_modules/express 创建一个软链接,指向 .pnpm/express@x.x.x/node_modules/express
  3. 效果: 你的项目根目录下只有 express,你无法 import accepts,因为 node_modules 根目录下根本没有 accepts 的软链接。这就彻底根治了幽灵依赖。

三、postinstall 脚本是什么?有什么作用?

在前端工程化中,postinstallpackage.jsonscripts 对象里一个非常特殊的生命周期钩子(Lifecycle Hook)

当你在项目中执行 npm install(或 yarnpnpm install)下载完依赖之后,包管理工具会自动、隐式地触发这个脚本。

一、 postinstall 的主要作用

它的核心目的是:在依赖包下载到本地后,自动执行一些必要的初始化、编译或环境配置工作,从而确保该依赖或整个项目能正常运行。

常见的应用场景有以下 4 种:

1. 编译原生模块(C/C++ Addons)

有些高性能的前端工具库(如旧版的 node-sass、各种图片压缩工具 imagemin、或者一些加密库)底层是用 C/C++ 编写的。

  • 作用: 它们被下载到用户的电脑后,必须根据用户当前的操作系统(Windows、macOS 还是 Linux)以及当前的 Node.js 版本,在本地进行一次即时编译(Compile)postinstall 此时就会触发如 node-gyp rebuild 的命令来执行编译。

2. 生成特定的本地代码或类型文件(如 Prisma、Playwright)

现代很多 Specification-Driven(规范驱动)或 Type-Safe(类型安全)的工具,需要根据你的本地配置生成定制化代码。

  • 作用: 例如 ORM 框架 Prisma,在你安装它时,它的 postinstall 脚本会自动读取你的数据库 Schema,并在 node_modules 中为你生成专属的 TypeScript 类型提示文件。

3. 自动配置开发环境工具(如 Husky)

在团队协作的项目中,通常需要强制规范 Git 提交信息。

  • 作用: 前端常用的 Git Hooks 工具 Husky 推荐的配置就是在项目根目录的 package.json 中写上:
json
"scripts": {
  "prepare": "husky" 
}

(注:现代 npm/pnpm 中更推荐使用 prepare 钩子,它的执行时机紧随 postinstall 之后,作用类似)。这样任何新员工拉取代码执行 pnpm install 后,Git 钩子就会被自动初始化,不需要人工干预。

4. 打印企业赞助、广告或温馨提示

  • 作用: 你可能在安装完某些开源库(如 core-js)后,控制台会打印出一段“请给开源作者买杯咖啡/求赞助”的文字,这同样是在 postinstall 脚本里通过 node index.js 执行输出的。

二、 进阶知识:postinstall 的安全隐患(供应链攻击)

由于 postinstall 具有“下载完自动执行”的特性,它成为了黑客进行供应链攻击(Supply Chain Attack)的重灾区。

  • 攻击原理: 黑客如果盗取了某个常用 npm 包作者的账号,或者发布了一个仿冒的包(钓鱼包)。他在 postinstall 中写上一段恶意脚本(比如 node ./malicious.js),该脚本可以窃取你电脑中的 .env 环境变量、SSH 密钥、Git 凭证,并悄悄发送到黑客的服务器。
  • 防御手段: * 现代包管理工具(如 pnpm)默认支持配置控制。你可以通过 pnpm install --ignore-scripts 来强制禁止执行所有依赖包的生命周期脚本。
  • 也可以在 .npmrc 中使用 allowed-scripts 来只允许信任的包(如 prisma)执行脚本。

四、Monorepo 是什么?

Monorepo(Monolithic Repository,单体仓库) 是一种代码管理架构概念。简单来说,就是将多个独立的项目、应用或软件包,统统放在同一个 Git 代码仓库中进行管理

为了更好地理解,我们可以将它与传统的 Polyrepo(多仓库/Multirepo) 模式进行对比。

传统的 Polyrepo 模式痛点

在传统的开发模式中,通常是一个项目对应一个 Git 仓库。假设你正在开发一个面向用户的 Nuxt 3 官网,一个面向内部运营的 React 管理后台,以及一套供这两个项目使用的公共 UI 组件库。

在 Polyrepo 模式下,你有 3 个仓库:

  1. nuxt-website-repo
  2. react-admin-repo
  3. common-ui-repo

痛点在于: 当你需要修改 common-ui-repo 中的一个按钮组件并应用到前台应用时,你需要经历:在 UI 仓库修改代码 -> 提交 PR -> 合并代码 -> 触发 CI 打包 -> 发布到 npm 私服 -> 切回 Nuxt 仓库 -> 更新 package.json 版本号 -> 重新 pnpm install -> 重启项目。 这个“发布-订阅”的链路非常漫长,调试起来极其痛苦。

Monorepo 的目录结构与破局之道

在 Monorepo 架构下,这 3 个原本独立的项目被合并到了一个代码仓库中。它的标准目录结构通常长这样:

text
my-enterprise-monorepo/
├── apps/                    # 存放具体的业务应用
│   ├── nuxt-website/        # 面向用户的 Nuxt 3 前端
│   │   ├── package.json
│   │   └── ...
│   └── react-admin/         # 面向内部的 React 管理后台
│       ├── package.json
│       └── ...
├── packages/                # 存放跨项目复用的公共模块
│   ├── common-ui/           # 提取出的公共 UI 组件库
│   │   ├── package.json
│   │   └── ...
│   └── shared-utils/        # 公用的工具函数 (如 axios 封装、正则等)
│       ├── package.json
│       └── ...
├── package.json             # 根目录的配置
├── pnpm-workspace.yaml      # pnpm 工作区声明文件
└── Jenkinsfile              # 统一的 CI/CD 流水线配置

在这种结构下,结合我们上一轮聊到的 pnpm workspaces(工作区),Monorepo 展现出了巨大的优势。

1. 极致的代码共享与本地调试体验

通过工作区软链接,nuxt-website 可以直接在 package.json 中以 "common-ui": "workspace:*" 的方式引入组件库。 你修改了 common-ui 里的代码,切回 Nuxt 项目刷新页面,立刻就能看到最新效果,完全跳过了繁琐的发包流程。

2. 依赖统一与版本收敛

在根目录下集中管理第三方依赖(如全局统一使用 eslint@8typescript@5)。这避免了 A 项目用旧版、B 项目用新版导致的规范不一致和本地存储空间的浪费。

3. 原子化重构(Atomic Commits)

如果 shared-utils 里某个核心函数的传参规则变了,在以往你需要去几十个仓库里挨个发 PR 修复调用。而在 Monorepo 中,你可以在一个 Git Commit 中,同时修改工具函数本身,并把 apps/ 目录下所有调用该函数的地方一次性改掉。整个系统的状态始终是保持一致和安全的。

4. 统一的基建与工作流

代码规范(Linting)、格式化(Prettier)、测试(Testing)以及 CI/CD 部署脚本(如 Jenkins 流水线)只需要在根目录配置一次,所有子应用都能继承。

必须正视的挑战:Monorepo 不是银弹

虽然优势明显,但将所有鸡蛋放在一个篮子里也会带来工程化上的挑战:

  1. 代码体积膨胀: 随着业务发展,仓库拉取(git clone)的速度会变慢。
  2. 权限控制困难: 所有人都能看到所有代码,无法像单仓库那样对外包团队或跨部门做精细的代码级权限隔离。
  3. CI/CD 构建性能瓶颈: 如果每次提交哪怕只改了一行说明文档,Jenkins 都要把所有子应用重新打包一遍,那服务器一定会崩溃。

packages/ 目录下的内容,可以被理解为“公司内部使用的公共包”。

它在物理结构上和我们平时 npm install 下载的第三方包没有任何区别——它也有自己的 package.json,可以有自己的 src 源码、测试用例和打包脚本。唯一的区别在于,它不需要被发布到外部的 npm 服务器上,而是直接在本地代码库中被复用。

那么,它是如何跨目录被引用的呢?这就要归功于包管理器(比如 pnpm)的工作区(Workspace)机制

如何被引用的?(核心机制三步曲)

假设你的目录结构如下,你希望在前端应用中引用 packages/ui 里的组件:

text
my-monorepo/
├── apps/
│   └── web-app/           # 比如你的 Nuxt 或 React 项目
├── packages/
│   └── ui/                # 你的公共组件包 (包名: @my-company/ui)
└── pnpm-workspace.yaml

第一步:在根目录声明工作区

你需要在根目录创建一个 pnpm-workspace.yaml 文件,告诉 pnpm 哪些目录是相互关联的“内部包”。

yaml
packages:
  - 'apps/*'
  - 'packages/*'

第二步:在内部包中定义包名

packages/ui/package.json 中,你给这个公共包起一个名字(通常带上公司的命名空间,防止和 npm 上的包冲突):

json
{
  "name": "@my-company/ui",
  "version": "1.0.0",
  "main": "index.js"
}

第三步:在业务应用中直接“安装”它

apps/web-apppackage.json 中,你可以像引入普通第三方依赖一样引入它,但是版本号写成特殊的 workspace:*

json
{
  "name": "web-app",
  "dependencies": {
    "vue": "^3.0.0",
    "@my-company/ui": "workspace:*" 
  }
}

关键点来了: 当你在根目录执行 pnpm install 时,pnpm 发现 @my-company/ui 后面的版本是 workspace:*。它就不会去网上的 npm 仓库下载,而是直接在 apps/web-app/node_modules 下面创建一个软链接(Symlink),指向你本地的 packages/ui 目录。