前端包管理器的演进史

75 阅读34分钟

非教程,是学习记录

一、前端为什么需要包管理器

从混乱到秩序的开端。

1.1 历史视角

谈到包管理器我们必须要回顾一下前端开发模式的演变史:

<script> -> IIFE -> CommonJS、AMD、UMD -> 前端工程化

  1. 原始阶段: Web 2.0 时代,前端项目规模很小,JS 文件之间是通过直接在 HTML 中添加 <script> 标签来引入的,缺乏正式的模块管理机制。这就带来一系列痛点:
    • 命名冲突(全局污染): 所有变量和函数都暴露在全局作用域中
    • 依赖管理混乱: 文件的加载顺序必须手动维护,依赖关系不清晰,容易遗漏或加载错误。随着项目增大,依赖关系变得极其混乱,管理多个库的版本几乎不可能。
    • 性能问题: 脚本过多会导致大量的 HTTP 请求。
  2. 社区探索阶段: 规避全局作用域,利用 JS 自身的特性模拟模块
    • IIFE(立即执行函数表达式): 最早期的“模块”模式,利用函数作用域来隔离私有变量,只通过返回值或暴露给全局的少量接口进行通信
    • 命名空间模式: 将所有功能封装在一个全局对象下,减少全局变量的数量
  3. 规范化阶段: 运行时模块化,模块加载器出现。
    • CommonJS 与 npm 这一时期,互联网应用变得越来越复杂,对高并发和实时通信的需求激增,传统的服务器技术在处理大量并发连接时,性能瓶颈明显。此时 Google 发布的 V8 JavaScript 引擎(2008年9月2日发布)极大的提高了 JavaScript 的执行速度,性能强劲。

      基于这些背景,Node.js 就诞生了(2009年发布),它的核心目的就是为了解决传统 Web 服务器在处理大量 I/O 操作时,资源消耗过大的问题,并利用 JavaScript 统一前后端开发。

      Node.js 是一个在服务器端运行时,当它拥有文件系统访问能力后,它必须解决如何高效、标准地从本地文件系统加载模块的问题。

      Node.js 借鉴了其他服务器端语言的经验,设计了 CommonJS 规范。解决了同步加载的难题,这个规范的出现,为 npm 提供了一个明确的、可操作的模块引用机制。npm 只需要将包下载到 CommonJS 规范可以找到的位置(node_modules),即可完成依赖注入。

      Node.js 一经推出,开发者就迅速创建了大量的实用模块,在没有中央仓库之前,大家只能通过 Github 或官网手动下载来共享代码,这种方式无法处理复杂的依赖树。为了避免开发者手动维护复杂的依赖关系和版本,一个自动化、统一的中央仓库和工具 势在必行。npm 正是为满足这一需求而生,它接管了 CommonJS 模块的分发和安装工作。

    • AMD 与 UMD: AMD 主要为浏览器端设计,解决了 CJS 在浏览器中同步会导致页面假死的问题,UMD 是一种通用模式,将 AMD 和 CJS 包装在一起,使模块可以同时运行在 Node 环境和浏览器环境。AMD 没有成为前端事实标准是因为缺乏一个统一的中央仓库和强大的命令行工具来进行自动化、版本化的包管理。

  4. 前端工程化: npm 反哺浏览器。虽然 npm 最初是为 Node.js 设计的,但它在合适的事件提供了当时前端急需的技术解决方案:
    • 集中式的代码仓库
    • 解决了依赖的“深度”问题
    • 本地隔离 但 CommonJS 是同步加载的,不适合浏览器环境,为了让 CommonJS 模块能在浏览器中运行,打包工具(Webpack)应运而生。package.json也成了项目的“身份证”,解决了依赖声明和自动化的问题。

1.2 机制视角

1.2.1 “包”是什么?package.json如何描述依赖?

是一个可复用的、独立的代码结合。解决了代码复用和分享混乱的问题,让你可以简单地导入功能,而不是复制粘贴文件。

当把项目交给别人或部署到另一台机器时,我们很难确切知道需要哪些外部库,以及它们的准确版本。如果手动追踪,非常容易出错。Node.js 提供了 package.json 来解决这个问题。

package.json集中化了清单,明确列出项目运行和开发所需的所有外部依赖及其版本范围,同时为了确保能自动更新获取最新的安全补丁版本,所以在设计之初设计的是限制版本范围。

package.json最主要和常见的依赖描述字段:

字段名称用途描述示例依赖类型
dependencies项目运行时必须的依赖。项目在生产环境也需要的模块React、Express、Lodash
devDependencies仅在开发和测试阶段需要的依赖。生产环境非必须,用于构建、打包、测试或代码规范的工具Webpack、Babel、Jest、ESLint
peerDependencies同级依赖。常用于库或插件开发,声明了使用者必须安装的依赖及其版本范围,但不会自动安装React Hook Form(需要React)、TypeScript 插件(需要 TypeScript)
optionalDependencies可选依赖。即使安装失败,npm也会继续安装过程,通常用于可选功能,如果可用就用,不用也不会导致项目崩溃某些需要特定操作系统原生模块的库

1.2.2 语义化版本规则(SemVer)与依赖解析逻辑

1.2.2.1 语义化版本规则

官方文档

符号含义示例接受的版本
^允许进行不破坏性的更新。会匹配主版本号(major)不变的最新版本"^1.2.3"匹配>=1.2.3<2.0.0
~允许进行补丁版本(patch)的更新。会匹配次版本号(minor)不变的最新版本"~1.2.3"匹配>=1.2.3<1.3.0
*匹配所有版本"*"匹配所有版本
>/</>=/<=比较操作符">=1.0.0"匹配大于或等于1.0.0的版本
-范围操作符"1.0.0-2.0.0"匹配从1.0.02.0.0的所有版本(包含两端)
无符号严格匹配"1.2.3"仅匹配1.2.3版本
1.2.2.2 Node.js 的依赖解析逻辑(v25.1.0)

官方伪代码

CommonJS Pasted image 20251110172657.png ESM模块的解析区别

特性CommonJS(require())ECMAScript Modules(import)
文件扩展名允许省略(会自动按照 .js.json.node 尝试补全)通常强制要求带扩展名,但可以通过package.json"exports"字段进行自定义
package.json查找"main"字段作为入口文件优先查找"exports"字段,该字段定义了哪些文件可以被导入以及其路径
模块路径始终从当前文件的路径开始解析相对路径必须以 ./../ 开头
同步/异步同步加载静态 import 是同步解析,但实际加载是异步的;动态 import() 是异步加载

当项目中同一个包存在多个版本时,Node.js 的查找机制遵循递归向上查找就近原则,通常项目结构是这样的:

/monorepo-root 
├── /node_modules 
│ └── external-dep/ (版本 2.0.0 - Hoisted) 
├── /packages/ │ ├── package-a/ │ 
│ └── /node_modules 
│ │ └── external-dep/ (版本 1.0.0 - Nested) <-- 冲突版本 
│ └── package-b/ 
│ └── ...

pnpm 对这个结构做了优化,不会在 package-a/node_modules 中安装嵌套依赖,而是将所有依赖的实际代码都保存在全局内容存储区。 在每个包的本地node_modules中,pnpm 仅创建所需的符号链接,形成一个严格的、非扁平的目录结构。

解析流程仍然基于 Node.js 的递归向上查找,但由于它的链接结构,会确保每个包只能访问到自己在 package.json 中声明过的依赖版本。具体原理留到下面讨论。

1.2.3 npm registry 的角色

npm registry 是全球最大的 JavaScript 软件包中央仓库在它出现之前,开发者每次需要常见功能(如处理日期或发送 HTTP 请求)时,都不得不自己编写或四处搜索代码片段;而现在,npm registry 解决了这一痛点,它充当了代码的发布、存储和下载中心。开发者通过 npm publish 将模块上传至此,而运行 npm install 的用户则从这里获取所需的依赖,使 npm registry 成为支撑整个 Node.js 和前端生态系统高效协作的数据枢纽

二、npm 的演进与改进

由1.1 历史视角可知,npm 让混乱的前端世界第一次有了秩序,把前端从“文件堆”带入了“生态系统”时代。

但秩序的代价,是新的复杂性,随着项目膨胀、依赖爆炸、版本飘逸——npm 的局限也开始显露。这一章就来探讨下 npm 的演进史。

2.1 历史视角

2.1.1 npm v1&v2

我们可以通过node 与 npm 版本映射可知将 node 版本降到 4 时,就可以使用 npm v2了。 官方日志:npm CLI 路线图npm@2.0.0更新日志npm 和前端打包

这个版本的 npm 设计的非常简单,所有的依赖项及其自身的依赖都会被安装到其父级依赖的 node_modules 中。 npmv2依赖管理.png 这样的设计导致的问题:

  • 嵌套地狱: 当项目依赖树的层级过深时,出现问题不利于排查和调试,依赖分支中也会出现相同版本互相依赖的问题。
  • 文件路径过长: windows 文件路径最大限制官方说明,超过最大限制时会导致 windows 下无法删除 node_modules 的一些文件,相关issue
  • 资源浪费: 巨大的 node_modules 文件夹体积,重复安装依赖导致安装时间过长
  • 多版本冲突: 尽管 Node.js 模块加载器和 npm 能通过依赖树结构有效地处理和隔离同一模块的多个版本(避免“依赖地狱”),但前端依赖(如 jQuery 或 CSS 框架)由于缺乏隔离机制,在加载时仍会相互冲突(例如“一个版本获胜”或样式互相覆盖)。

2.1.2 npm v3(2015)

官方文档:npmv3.0.0|CHANGELOG.md

这个版本解决了 v2 的核心问题:

  • 扁平化依赖树: 默认尝试将所有模块安装到项目的顶级 node_modules 目录,如果多个依赖项需要同一个库的不同版本,则只有其中一个版本会被提升到顶级,冲突的版本会继续以嵌套方式安装

  • 去重: 引入了去重的过程,在安装和更新时尝试创建最扁平的依赖树 npmv3依赖管理.png 这种扁平化方案实际的安装顺序会影响最终 node_modules 目录的结构,可能导致不同及其或不同时间安装出来的目录树结构略有不同

    官方说明:npm.github.io/how-npm-wor…npm.github.io/how-npm-wor…

与此同时,一个新的问题出现了:幽灵依赖。代码中使用了某个库,但没在配置文件中声明它,而是通过依赖另一个已声明的依赖项间接使用。

幽灵依赖.png

2.1.3 npm v5(2017)

官方文档:npm@5发布日志npm@5.0.0更新日志

引入了关键文件来保证依赖的确定性和安全性:

  • package-lock.json:锁文件,解决不同环境下依赖不一致的问题
  • 自动保存依赖:默认 --save
  • 性能提升:引入软链机制,提高安装速度
  • 新的缓存机制:blog.npmjs.org/post/161081… ,使用基于内容的地址存储来改进缓存,在这之前 npm 的缓存存储的是解压后的文件

2.1.4 npm v7(2020)

官方文档:npm v7.0.0|CHANGELOG.md

这个版本最大的变化就是引入了工作区(workspaces)和新的 peer dependencies 处理方式

  • 支持多包管理: Workspaces 是 Monorepo 项目结构的关键功能,允许在一个顶层 package.json 中管理多个子项目的依赖,简化了大型项目或多个相关包的管理。
    • Workspaces 有一个依赖别名(Aliasing)的功能,如果需要在项目中使用同一个包的两个不同主要版本,可以使用依赖别名:
      "dependencies": {
          "my-lib-v3": "npm:my-lib@^3.0.0",
          "my-lib-v4": "npm:my-lib@^4.0.0"
      }
      
  • 自动安装peer dependencies 在历史版本中peerDependencies只是警告,需要手动安装,从此版本开始后,会自动检查和安装。
    • 这个机制如果发现对等依赖冲突无法解决,安装过程会中止并报错。会迫使开发者必须显式地安装一个兼容的对等依赖版本,从而解决“运行时版本不匹配”导致的隐性冲突。

2.2 机制视角

2.2.1 npm install

npm install 的流程可以概括为三个主要阶段:resolve(解析)、fetch(获取)、link(链接)

2.2.1.1 resolve

这个阶段 npm 会确定需要安装哪些模块、它们的确切版本,以及它们所有的依赖模块的完整结构(依赖树) Pasted image 20251115081703.png 去重和扁平化策略在版本的迭代中会有所变化,可以在官方文档中查看对应版本的规则

在多包管理的环境中,通常会将所有 Workspace 下的依赖都集中提升安装到 Monorepo 的根目录 node_modules 下。此时,建立依赖树时会读取 Monorepo 根目录以及所有子 Workspace 的 package.json 文件,将它们合并成一个巨大的依赖树。

若一个 Workspace A 依赖另一个 Workspace B,构建工具会跳过从 Registry 下载 B 的步骤。会在根 node_modules 中为 B 创建一个指向其实际位置(如 packages/B)的符号链接

2.2.1.2 fetch

这个阶段会下载所有解析和确定好的模块包。 Pasted image 20251112030950.png integrity字段官方文档

2.2.1.3 link

将下载好的模块文件放置到项目指定的 node_modules 目录中,并设置可执行文件链接Pasted image 20251112032854.png

2.2.1.4 总结简版流程图
graph LR
    A[npm install] --> B{读取 package.json};
    B --> C(Resolve: 构建并扁平化依赖树);
    C --> D{生成/检查 package-lock.json};
    D --> E{Fetch: 检查本地缓存};
    E -- 命中缓存 --> F[从缓存拷贝];
    E -- 未命中 --> G(Fetch: 从 Registry 下载 Tarball);
    G --> H(验证 Integrity 并解压);
    F & H --> I(Link: 放置文件到 node_modules);
    I --> J(Link: 创建 .bin 符号链接);
    J --> K[执行 Postinstall 脚本];
    K --> L[安装完成];

2.2.2 lockfile 更新机制

  1. 首次安装
  2. 添加新依赖
  3. 升级依赖
  4. 版本不兼容
  5. 哈希算法更新
  6. 移除依赖

2.2.3 CLI 命令与 PATH 解析

  • 命令为什么能够跑起来?
  • 类如npm run devpnpm lintyarn build这类命令是怎么被找到的?
  • 为什么有时候能直接用vite,有时候却要用npx vite

以上三个问题是这节要讨论的内容。

2.2.3.1 CLI 命令的本质是什么

CLI(Command Line Interface)是一个带有执行头的 JavaScript 文件:

#!/usr/bin/env node
console.log('I am CLI')

我们常用的eslintvitewebpack这些命令,在下载包时会在 package.jsonbin 字段指定 CLI 文件。

{
    "bin": {
        "vite": "bin/vite.js"
    }
}

当我们执行 vite 时,实际上是运行了 node ./node_modules/vite/bin/vite.js 文件。

那么系统是如何知道 vite 这个命令在哪?

2.2.3.2 PATH

当我们在命令行中输入一个命令时:vite create my-app Shell(如 Bash,Zsh,CMD,PowerShell) 会执行以下步骤: Pasted image 20251115063417.png

ℹ️

  • mac 查看 PATH:echo $PATH | tr ':' '\n'
  • mac 查看 PATH 是从那些文件配置来的:
    • zsh:cat ~/.zshrccat ~/.zprofilecat /etc/zshrc
    • bash:cat ~/.bash_profilecat ~/.bashrccat /etc/profile
  • windows 查看 PATH:
    • cmd:echo %PATH%
    • Powershell:$env:PATH.Split(";")
    • 图形界面查看:环境变量

当我们全局安装一个 Node.js 包时(例如 npm install -g vite ),npm 会在全局目录(如 /Users/<User>/./nvm/versions/node/v20.19.2/binC:\Users\<User>\AppData\Roaming\npm下创建一个软链接:tsc -> ../lib/node_modules/vite/bin/vite

只要这个全局目录在 PATH 里,系统就能识别到 vite 命令。

2.2.3.3 项目本地依赖的执行机制

但更常见的是:我们只在项目中安装了依赖,比如:npm install eslint --save-dev

这时 CLI 文件位于:./node_modules/.bin/eslint,如果直接输入 eslint .,系统会报错:command not found: eslint,因为 .bin 目录不在 PATH 中。

为什么 npm run lint 能执行成功呢:

  1. npm install eslint -Deslint放入node_modules,并读取 eslintpackage.json中的bin字段在./node_moudles/.bin创建相应的快捷方式
  2. npm run lint解析项目package.json中的"scripts"字段,找到lint命令,临时把 node_modules/.bin加入 PATH,执行命令,完成后恢复 PATH
2.2.3.4 npx/pnpm exec

当我们只想执行一次命令,而不想全局安装时,可以使用:

npx vite
pnpm exec vite

二者的原理相似:

  1. 查找项目依赖是否有该命令
  2. 如果没有,则临时下载
  3. 修改 PATH
  4. 执行后清理临时缓存

三、yarn:npm 的加速器

3.1 历史视角

3.1.1 为什么 Facebook 要造 yarn

2016 年前后,Facebook 内部有三件大事:

  • React 生态爆发
  • npm v2/v3 速度极慢,大型前端项目安装一次依赖动不动 5 分钟起跳
  • 团队规模庞大

这些条件导致 npm 的两个问题在 FB 内部被放大成“工程事故级别”:

  1. npm 的安装不确定性
    1. 两个开发者构建产物不一致
    2. CI 和本地环境差异导致发布失败
    3. 同一代码在不同机器跑出不同依赖树
  2. npm 的安装速度在大型项目上基本不可用
  3. npm 的缓存机制原始且重复浪费磁盘

Yarn 就这样出现了

3.1.2 yarn 的优化点

  1. 确定性安装:yarn.lock,虽然 npm 后来也做了 package-lock.json,但:

    1. yarn 的 lockfile 结构更简洁
    2. 更新策略更稳定
    3. 解析速度更快
    4. 社区更快接受
  2. 并行安装

    • npm 是串行安装:a -> 等 -> b -> 等 -> c -> 等
    • yarn 是并行安装:a、b、c同时装
  3. 更先进的缓存策略

    npm 的缓存像 “临时文件夹”

    yarn 真正做到了可复用的 cache,达成了:

    • 第二次安装几乎秒级完成
    • CI 可以缓存 node_modules 以外的内容
    • 即使离线也能安装

3.1.3 yarn v1(停止开发)

yarn 在这个版本实现的核心功能:

  1. 缓存
  2. 并行
  3. 离线模式
  4. 锁文件
  5. 扁平模式
  6. Workspaces工作区

3.1.4 yarn v2

2019年之后,yarn 做了一个决定:干掉 node_modules

他们提出了 Plug'n'Play(PnP)

  • 不再生成 node_modules
  • 依赖全部用 zip+ 映射表索引
  • 依赖直接在虚拟文件系统中读取

这项设计从工程角度非常先进:

  • 文件体积极小
  • 加载速度超快
  • 不需要解压成几万文件
  • 项目结构更确定

但也带来了巨大的问题:

  • 大量第三方工具无法工作,webpack、eslint、prettier、各种 CLI、……都假设依赖来自 node_modules/**
  • 开发者的学习成本飙升,需要了解 PnP API、需要自定义 yarn 插件、必须适配各种工具链、与 npm/pnpm 差距越来越大
  • 大量团队选择停留在 yarn v1

更多团队开始迁移到 pnpm,因为:

  • pnpm 在磁盘占用、安装速度、工程可靠性上更强
  • 不像 yarn v2 那样破坏生态

3.1.5 yarn v3(维护中)

经历过 V2 版本的“挫折”后,V3 极大程度上改善了 v2 强制推行 PnP 导致很多工具不兼容的问题。

它内置了一个更强大的 node_modules 链接器。如果不想用 PnP,只需要在配置中改一行代码,就能像 v1 一样生成 node_modules,但在安装速度和稳定性上优于 v1

在这个版本中,yarn 也通过严格依赖管理解决了幽灵依赖的问题。

虽然 yarn3 修正了很多 v2 的问题,但这个版本更像是 v2 到 v4 之间的成熟过渡版本

3.1.6 yarn v4

在这个版本里,yarn 并没有引入颠覆性的新概念,而是专注于提升性能、优化用户体验以及强化安全性

yarn4 是目前最快的版本,它引入了新的缓存机制,让重复安装几乎是瞬间完成。

yarn4 默认开启 Hardened Mode(强化模式),在这个模式下,yarn 会严格校验 yarn.lockpackage.json 中的依赖范围是否一致,同时也校验下载的包的分辨率。这是为了防止攻击者通过篡改 lockfile 注入恶意代码。 在 CI/CD 环境中,如果 lockfile 被意外修改或不一致, yarn v4 会直接报错并停止构建。

yarn v4 版本以前对 React Native 的支持比较麻烦,v4 在这方面也做了很多兼容性修补,但仍有部分工具链对 PnP 有兼容性成本。

只支持 node18+

Pasted image 20251122033341.png

3.2 机制视角

yarn 到底是怎么快起来的?

yarn 改进了 npm 的那些底层机制?

yarn v2 为什么能抛弃 node_modules?#todo

3.2.1 yarn 的 lockfile 结构(V1)

yarn在 lockfile 中记录了每一个具体依赖路径,包括:

  • 包的精确版本
  • 依赖对应的解析 URL
  • 完整的依赖树
  • 校验完整性
lodash@^4.0.0:
  version "4.17.21"
  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz"
  integrity sha512-xxxx

可以发现,yarn 采用的是自定义文件结构,只会记录“需要什么”和“解析到了什么”,避免记录嵌套结构中大量重复的包信息,这种结构比 package-lock.json 更简洁、更具可读性

yarn 的一个解析条目可以包含多个版本请求,但它们都解析到一个单一的 version。这直接反映了 yarn 尝试最大限度地将依赖项提升到顶层(扁平化)

同时,由于是扁平结构,yarn 在解析依赖树时,查找一个包的实际版本通常更快,因为它不需要遍历深层嵌套的对象结构

package-lock.json(早期版本)是一个巨大的、嵌套的 JSON 对象。当两个分支同时添加或升级不同的依赖时,这些改动可能会发生在 JSON 文件的不同相邻部分,但由于 JSON 格式的嵌套结构,git 很难智能地合并,经常导致冲突。

yarn.lock由于每个解析条目都是一个独立的块,切实按字母顺序排序的。当添加一个新包时,它只是在文件的尾部添加一个新的块。当升级一个现有包时,他只改变包的一个块。极大的减少了手动合并冲突的需要。

npm v7 已经解决了这个问题,两者功能型差距已大大缩小

3.2.2 yarn 的速度(V1)

yarn 会根据依赖树做拓扑排序,然后并行执行可独立的下载与解压。

AB,C 并行
D 必须等 A 完成
E 必须等 B + C 完成

在大型项目上,速度优势极为明显

yarn install 流程: Pasted image 20251117213605.png

并行下载示例: Pasted image 20251117213853.png

当两个包并行请求并有依赖关系时,yarn 会依靠并发锁来确保资源共享和去重: Pasted image 20251117214223.png

现代版本 yarn v1 和 npm v5+ 的缓存原理上差异极小,yarn 的缓存优势仅限于和早期 npm v3/v4 的比较

3.2.3 Plug'n'Play

#todo

3.2.4 yarn workspace(v1)

yarn 是第一个把 workspace 做成“工业可用”的包管理器。

workspace 引入后,前端也可以实现:

  • 多项目共享依赖
  • 多包互相引用
  • 单仓库同一版本管理
  • 多包一起发布或测试

典型结构:

/packages
    /frontend
    /backend
    /shared

配置package.json

{
    "private": true,
    "workspaces": ["packages/*"]
}
3.2.4.1 依赖提升

类如

packages/frontend/node_modules/react
packages/backend/node_modules/react

yarn workspace 会把他们提升到根目录:

/node_modules/react

同时保证版本一致性,内部包不需要重复安装

当没有yarn.lock时,yarn hoisting 的逻辑: Pasted image 20251117225753.png 在 yarn workspace 中,通常只有一个位于项目根目录的 yarn.lock 文件,这时的安装逻辑和一般项目等同

3.2.4.2 内部包自动软链接(symlink)

例如结构:

packages/
    shared/
    frontend/

fontend 的 package.json

"dependencies": {
    "shared": "1.0.0"
}

yarn 安装后会自动生成链接:

fontend/node_modules/shared -> ../../shared

当我们本地修改 shared 后,frontend 自动更新,多包同时调试时不需要发版本

Monorepo 内部包之间的依赖,yarn workspace 的核心原则是内部依赖关系高于版本匹配,当 packages/frontend/package.json依赖"@my-scope/shared": "^1.0.0",而packages/shared/package.json中的版本是 2.0.0时,yarn install会忽略 ^1.0.0 的版本要求,会直接在 node_modules 中创建一个软链接,指向当前 Monorepo 中的 packages/shared的实际代码目录,但通常会发出警告来提醒您版本不匹配

❓ 在 workspaces 中使用不同版本的 shared

packages/backend 需要直接链接 packages/shared,但packages/frontend 需要使用 shared@1.0.0

解决方案:

  1. 发布 shared@1.0.0
  2. 配置 packages/frontend/package.json
{
  "name": "@my-scope/frontend",
  "version": "1.0.0",
  "dependencies": {
    // 明确要求 V1.0.0 版本
    "@my-scope/shared": "1.0.0", 
    
    // 其他依赖...
    "react": "^18.0.0"
  }
}
  1. 配置 packages/backend/package.json
{
  "name": "@my-scope/backend",
  "version": "1.0.0",
  "dependencies": {
    // 依赖 V2.0.0 版本,这将触发软链接到 packages/shared/
    "@my-scope/shared": "^2.0.0", 
    
    // 其他依赖...
    "express": "^4.17.1"
  }
}
  1. 配置packages/shared/package.json
{
  "name": "@my-scope/shared",
  "version": "2.0.0", // 实时开发版本
  "main": "src/index.js", 
  // ... 其他配置
}
3.2.4.3 统一的脚本执行机制

当我们执行 yarn workspaces run build 时,yarn 会自动找出所有包含 build 脚本的包并按依赖顺序执行

3.2.4.3 坑点
  1. 幽灵依赖 依赖提升后最常见的坑点就是幽灵依赖。通常本地能跑,但 CI、生产环境无法运行,或者换一个包管理器直接崩溃。

    解决办法:

    • 用 ESLint 的 no-implicit-dependencies
    • 所有依赖必须明确定义在自己的 package.json
    • 使用 nohoist 来阻止依赖提升(不推荐)
  2. Node.js 运行时限制

    某些工具或依赖包,特别是那些依赖于自身内部文件路径或对 node_modules 结构有严格假设的库,要求它们依赖的包必须位于它自己的 node_modules 文件夹内,而不是父级或根级的 node_modules

    对这些特定的库,可以在 package.json 中配置 nohoist 来阻止它们的提升,强制它们保持在本地。

    //package.json(根目录)
    {
        "workspaces": {
            "packages": ["packages/*"],
            "nohoist": [
                "**/react", // 组织所有工作区提升 react
                "packages/my-app/lodash" // 阻止特定工作区提升 lodash
            ]
        }
    }
    

四、pnpm:从根本解决问题

4.1 历史视角

4.1.1 pnpm 的诞生背景

当 npm 与 yarn 都围绕着 node_modules 打补丁时,pnpm 选择了重新发明地基。

pnpm 的作者 Zoltan Kochan 最初遇到的问题:

  • 在公司项目里维护多个相似项目
  • 每个项目都要安装一份相同的依赖
  • 一个 React + Webpack 项目往往要吃掉 600MB-1GB
  • 多份项目共存是灾难级的磁盘浪费 他原话的大意是:

“同一个版本的依赖我明明装过了,为什么还要再存一遍?”

同时他注意到另一个令人不爽的问题:

  • npm / yarn 为了模拟 node 的 module resolution -> 把依赖层层嵌套、展开 -> 生成了一个庞杂、脆弱的 node_modules 结构 于是他决定做一件更激进的事:

不是让 node_modules 更快,而是让它不再重复

这就是 pnpm 的起点

4.1.2 npm / yarn 结构的根本性问题

浪费

4.1.2.1 磁盘浪费

同样版本的 lodash、react、chalk,我们可能在 A 项目一份、B 项目一份、C 项目一份…… 电脑里可能有几十个 lodash 4.17.21 即使 yarn 有全局缓存,它仍然需要将包内容复制到 node_modules 下的最终位置

pnpm 的思路:既然包内容一致,为什么不共享实际文件?

4.1.2.2 时间浪费

由于 node_modules 是展开结构,即使依赖没变:

  • npm 要重新铺满目录
  • yarn 要重建 node_modules
  • CI 环境为了复用,通常会缓存整个 node_modules 目录

pnpm 认为把所有依赖都堆在一个巨大的、平铺的 node_modules 目录里,不仅效率低,还会导致“幽灵依赖”等问题。

正确的方式应当是:依赖应该像 Git 一样,通过内容寻址存储来管理文件。每个文件的内容都有一个唯一的哈希值(指纹/SHA),内容不变,哈希值就不变。

这样相同版本的依赖在硬盘上只存储一份,再次安装时,如果依赖版本没变,pnpm 只需要创建符号链接,而不是要重新“铺满”或“搬运”整个巨大的文件目录,安装速度极快

4.1.3 pnpm 的崛起

pnpm 诞生最初几年是及其小众的:

  • CLI 不如 npm 成熟
  • 社区教程少
  • Plug'n'Play 的争议让大家对“破坏 node_modules 的创新”谨慎

但随着 Turborepo、Nx、RushStack、Vite 等工具的普及,团队开始意识到:多包管理是常态,而不是例外 pnpm 的可共享 store + workspace 体验完爆 npm / yarn:

  • 不会重复安装依赖
  • 启动 monorepo 时几乎零延迟
  • hoisting 策略可控且可预测

于是 Monorepo 社区率先向 pnpm 靠拢,同时大厂也开始转向 pnpm:

  • Vite 官方推荐
  • Nuxt 新项目模板默认使用 pnpm
  • Vue、Angular 社区开始大规模迁移
  • Turbo / Nx 文档把 pnpm 当成头号选择

2023-2025 年间,pnpm 的使用进入指数增长

4.2 机制视角

4.2.1 pnpm install

Pasted image 20251122021102.png

内容寻址存储(Content Addressable Storage,CAS)

pnpm 在安装依赖时,会读取包中每个文件的内容,并基于内容计算加密哈希值。这个哈希值既是文件的唯一标识符,也是其在全局存储区中的路径组成部分。

例如:sha1(lodash@4.17.21.tgz) = 8b44ab...eac 会对应存储路径 ~/.pnpm-store/v3/8b/44/8b44ab...eac

需要注意的是,哈希计算仅基于代码文件的实际内容,不会包含 package.json、LICENSE 等元数据。

所有依赖文件最终会被放入一个全局存储区中,通常位于用户主目录或当前磁盘的 .pnpm-store 目录。我们可以使用 pnpm store path 查看 pnpm 当前使用的存储路径。

在读取配置时,pnpm 会遵循既定的优先级顺序:命令行参数 > 环境变量 > 项目级或用户级的 .npmrc 文件 > pnpm 自身的默认配置。

由于 pnpm 的存储是基于文件内容的哈希进行寻址的,如果某个包的不同版本在文件内容上完全一致,那么它们会在全局存储区中共享同一组硬链接,指向相同的数据文件。

而当新版本只修改了部分文件时,存储区只会新增这些变化的文件,其余未修改的内容继续复用已有文件,从而实现磁盘占用和安装效率的优化。

至于 pnpm 在计算哈希时所采用的哈希算法,它会根据既定优先级从不同来源中选择,优先级从左到右逐渐降低,如下图所示:
Pasted image 20251119064706.png

📝 硬链接只有在同一个磁盘或文件系统上才能工作。如果项目和存储区位于不同的磁盘,pnpm 会退化为文件复制,从而失去节省空间的优势。

4.2.2 符号链接结构图解

以安装 express 举例 官网说明

pnpm 安装依赖后不会直接生成扁平 node_modules,而是两层结构: Pasted image 20251121234705.png

node_modules/
├── .pnpm/                  <-- 虚拟存储 (这里存放所有文件的硬链接)
│   ├── body-parser@2.2.0/
│   ├── express@5.1.0/
│   └── ...
│
└── express                 <-- 符号链接 (Symlink)

虚拟 store 中的每个包的目录结构被“重排”为:

Pasted image 20251121234915.png

node_modules/.pnpm/
    express@5.1.0/
        node_modules/
            express/(指向全局 store)

当我们使用 ls -l node_modules 会发现 express 指向了 .pnpm 里的一个具体路径: Pasted image 20251121235444.png

所以,当我们使用import express from 'express' 时: Pasted image 20251122001357.png

为什么不会出现幽灵依赖?

从上面的内容可知:

  1. Node.js 在项目根目录下发现 node_modules 文件夹,并在里面找到了名为 express 的文件夹
  2. Node.js 进入 node_modules/express 文件夹,发现只是一个符号链接(快捷方式),Node.js 会自动跟随这个链接,跳转到 pnpm 的虚拟存储目录 .pnpm,找到 项目/node_modules/.pnpm/express@4.18.2/node_modules/express
  3. express的代码开始运行,并在它内部执行import 'body-parser'时,Node.js 会根据当前文件所在的位置向上查找node_modules
    • 目录结构是这样的:
node_modules/
└── .pnpm/
    └── express@5.1.0/
        └── node_modules/
            ├── express/       <-- 当前代码在这里运行
            └── body-parser/   <-- 它的依赖就在它的隔壁!

由于body-parser藏在express@5.1.0的深层目录里,项目根目录是看不到它的,所以无法在自己的代码里直接使用import 'body-parser'

4.2.3 workspace 和 hoisting 策略

pnpm 通过 pnpm-workspace.yaml 来启动 Monorepo,需要在项目根目录下创建这个文件来定义哪些目录包含包:

packages:
    - 'packages/*'
    - 'apps/*'
    - 'components/**'

整个 Monorepo 共享一个 pnpm-lock.yaml,在项目内部引用其他子包时,pnpm 推荐使用 workspace: 协议。这样能确保引用的是本地的最新代码,而不是 npm registry 上的版本

// packages/app-a/package.json
{
  "dependencies": {
    "shared-utils": "workspace:*" // 指向本地 packages/shared-utils
  }
}

它的 hoisting 策略是与 npm/yarn v1 最本质的区别,pnpm 默认不进行提升,它会使用硬链接和符号链接创建一个嵌套的结构。

但在使用某些老旧的工具库(例如 React Native 的某些版本)时,会假设依赖是扁平的,这个时候就找不到嵌套的包。为了兼容,我们可以配置 .npmrc

# 控制哪些包被提升到根 node_modules
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=@types*

or
# pnpm 会模仿 npm的行为,将所有依赖提升到根 node_modules
shamefully-hoist=true

or
# workspace 配置
packages:
  - "packages/*"

hoist:
  - "vite"
  - "jest"

4.2.4 为什么 pnpm 会快

根据上面的讨论,我们可以知道,pnpm 速度快是多个方面的原因:

  1. 不重复写入磁盘。 pnpm 只解压一次,node_modules 全部都是链接(几乎零写入)
  2. 解析更高效。 pnpm 的文件夹结构就是依赖树本身,程序不需要费力去计算或扫描文件来弄清楚“谁依赖谁”
  3. CI 场景下"fetch-only"安装表现极强。 pnpm 的 CI 安装只需要缓存 store,通过 lockfile 直接恢复链接

五、三者的核心区别于适用场景

5.1 机制层面对比

5.1.1 依赖安装

机制npmyarn v1pnpm
存储方式每个项目独立存储每个项目独立存储全局内容寻址存储(CAS)
重复依赖多次写入多次写入只存一份
安装流程下载 -> 解压 -> 写入下载 -> 并行解压 -> 写入下载一次 -> 写入 store -> 链接
性能瓶颈I/O 多次写入I/O 多次写入几乎无 I/O

5.1.2 node_modules 结构

npmyarn v1pnpm
结构扁平化扁平化symlink 图结构
幽灵依赖无(严格模式)
可预测性一般一般极强

5.1.3 lockfile

package-lock.json(v7)yarn.lock(v1)pnpm-lock.yaml
可读性一般最好
版本一致性基于版本号基于版本号基于内容 hash
monorepo 表现一般需手工配置原生支持

5.1.4 Workspace 支持

功能npm v7yarn v1pnpm
workspace有,但较弱原生可用,但在复杂发布流程常搭配 Lerna原生最强
monorepo不够工程化依赖 LernaTurbo/Nx 官方推荐 pnpm
依赖链接软链自动软链最严格、一致性最强

📝 经过长时间的优化,最新版本的 npm、yarn、pnpm 在很多方面实际上趋于雷同,都实现了内容可寻址存储、Monorepo 支持等等。

个人其实觉得三者核心功能上差距不大了

5.2 适用场景

  • 小型项目/教学项目 -> npm
    • 优势
      • 不需要额外学习成本
      • 文档最多、社区最大
      • 简单、朴素、新手友好
    • 适用于
      • 入门教程
      • 小工具项目
      • 简单 web 项目
  • 中型团队 -> yarn v1
    • 优势
      • 成熟稳定,踩坑少
      • npm bug 出现时,它可以作为保险方案
      • 许多老项目用它迁移成本最低
    • 适用于
      • 有 CI 但不使用 Monorepo 的团队
      • 遗留项目
      • 运行在 Node 14/16 的老项目
  • 大型项目/Monorepo/Turbo/Nx -> pnpm
    • 优势
      • 安装速度极快
      • store 可跨项目复用,版本一致性高
      • workspace 对 monorepo 极度友好
      • 与 Turbo/Nx 配置天然契合
      • 锁文件确定性强
      • 严格依赖隔离避免“幽灵依赖污染”

5.3 CI/CD 视角

npm v7+yarn v1pnpm
安装速度中等较快,yarn berry极快最快
磁盘空间一般一般,yarn berry好最优
Monorepo 支持内置支持,但性能和空间效率不如 pnpm内置一流支持内置一流支持,性能最好
缓存机制依赖缓存。需要在 CI 中配置缓存,但在缓存未命中时下载重复内容较多依赖缓存全局内容存储
可靠性高,package-lock.json高,yarn.lock最高,严格的 node_modules 结构防止幽灵依赖,且确保本地和 CI 环境行为一致

六、实践

一个包含前端(React)+后端(Express)+公共 UI 库的 Monorepo

6.1 创建测试项目

  1. 创建目录结构
my-app/
├── packages/
│   ├── server/
│   ├── ui/
│   └── web/

2. my-app下执行npm init -y

// my-app/package.json
{
    "name": "my-app",
    "version": "1.0.0",
    "description": "A Monorepo for web, ui, and server.",
    "main": "index.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "private": true,
    "workspaces": [
        "packages/*"
    ]
}
  1. my-app/packages/ui下执行npm init -ynpm install react react-domnpm install -D @types/react @types/react-dom typescript
  2. my-app/packages/server下执行npm init -ynpm install express corsnpm install -D @types/express @types/cors typescript ts-node
  3. my-app/packages/web下执行npm create vite@latest . -- --template react,确保reactreact-dom安装(vite 默认会装)
  4. 跨包引用,在my-app目录下执行npm install <ui包的name> --workspace=packages/web
  5. 清除缓存:npm cache clean --forceyarn cache cleanpnpm store prune
  6. 删除 package-lock.json
  7. 删除项目内所有的 node_modules 文件夹

更换yarn

  1. 删除 package-lock.json
  2. 删除所有 node_modules 文件夹

更换pnpm

  1. 创建 my-app/pnpm-workspace.yaml文件
packages:
  # 必须与 package.json 中的 workspaces 字段内容保持一致
  - 'packages/*'
  1. 修改packages/web/package.json
{
    "dependencies": {
        "ui": "workspace:^1.0.0"
    },
}
  1. 删除 package-lock.json 和 yarn.lock
  2. 删除所有 node_modules 文件夹

6.2 实测安装时间

time npm install
time yarn install
time pnpm install
工具cold installreinstallnode_modules 大小
npm 10.8.228.331s2.135s110.9 MB
yarn 1.22.221:07.61s1.009s100 MB
pnpm 10.23.020.665s0.581s100.3 MB

6.3 触发依赖冲突

web 使用 react@18 ui 使用 react@17

修改测试项目

  • 修改packages/web/package.json
{
    "dependencies": {
        "react": "18.2.0",
        "react-dom": "18.2.0",
        "ui": "workspace:^1.0.0"
    },
    "devDependencies": {
        "@types/react": "18.2.0",
        "@types/react-dom": "18.2.0",
    }
}
  • 修改 packages/ui/package.json
{
    "devDependencies": {
        "@types/react": "17.0.60",
        "@types/react-dom": "17.0.20",
        "typescript": "^5.9.3"
    },
    "peerDependencies": {
        "react":"17.0.2",
        "react-dom": "17.0.2"
    }
}
  • 删除所有 node_modules 文件夹
  • 删除锁文件

三者区别:

  • npm: 安装成功,有两个 react 版本,node_modulesreact 为 17 版本,node_moudles/web/node_modulesreact 为 18 版本
  • yarn: 安装成功,但有警告,仅有一个 react 版本,node_modules/react18
    • Pasted image 20251122064119.png
  • pnpm: 安装成功,有两个 react 版本,node_modules/.pnpm/react@17.0.2node_modules/.pnpm/react@18.2.0

6.4 CI/CD

以 GitHub Actions 为例

  1. 用 vite 创建一个模板项目
  2. 新建 my-app/.github/workflows 目录,该目录下创建 npm-ci.ymlpnpm-ci.ymlyarn-ci.yml文件 npm-ci.yml:
name: NPM CI


on:
    push:
        branches:
            - main
    workflow_dispatch: # 允许手动触发

jobs:
    build:
        runs-on: ubuntu-latest
        steps:
            - name: Checkout code
                uses: actions/checkout@v4

            - name: Set up Node.js
                uses: actions/setup-node@v4
                with:
                    node-version: "20"

            - name: Install dependencies (npm ci)
                run: npm ci

            - name: Build project
                run: npm run build

pnpm-ci.yml:

name: PNPM CI Optimized

on:
    push:
        branches:
            - main
    workflow_dispatch:

jobs:
    build:
        runs-on: ubuntu-latest
        steps:
            - name: Checkout code
                uses: actions/checkout@v4

            - name: Set up Node.js
                uses: actions/setup-node@v4
                with:
                    node-version: "20"

            - name: Install pnpm Command
                run: npm install -g pnpm

            - name: Get pnpm Store Path
                run: pnpm store path

            - name: Cache pnpm Store
                uses: actions/cache@v4
                id: pnpm-store-cache
                with:
                    path: /home/runner/.local/share/pnpm/store
                    key: ${{ runner.os }}-pnpm-store-${{ hashFiles('pnpm-lock.yaml') }}
                    restore-keys: |
                        ${{ runner.os }}-pnpm-store-

            - name: Install Dependencies
                run: pnpm install --no-frozen-lockfile

            - name: Build project
                run: pnpm build

yarn-ci.yml:

name: Yarn CI with Cache

on:
    push:
        branches:
            - main
    workflow_dispatch:

jobs:
    build:
        runs-on: ubuntu-latest
        steps:
            - name: Checkout code
                uses: actions/checkout@v4

            - name: Set up Node.js
                uses: actions/setup-node@v4
                with:
                    node-version: "20"

            - name: Restore Yarn cache
                uses: actions/cache@v4
                id: yarn-cache
                with:
                    path: ~/.cache/yarn
                    key: yarn-${{ hashFiles('**/yarn.lock') }}
                    restore-keys: |
                        yarn-

            - name: Install dependencies (Yarn install)
                run: yarn install --frozen-lockfile

            - name: Build project
                run: yarn build

三者依赖安装耗时比较

无缓存有缓存
npm4s-
yarn11s7s
pnpm5s3s

七、未来趋势

Node.js 的成功让 JavaScript 拥有了 npm,但反过来,npm 也把 Node 绑死在了 10 年前的设计上。

而今年来新起的工具 Bun / Deno / Biome 做的第一件事就是把包管理器“收编进运行时”:

Bun:把 npm client 内置进 runtime

  • 内置 bun install,速度比 pnpm 还快
  • 默认不创建 node_modules
  • 完全不依赖 npm CLI,不需要独立安装包管理器
  • 能同时管理执行环境、打包器、测试框架

Deno

  • 不使用 npm registry(默认依赖 URL import)
  • 没有 node_modules,也没有 package.json
  • 编译时自动缓存依赖
  • 支持 npm 包只是兼容层,不是核心机制

当前所有包生态都依赖 npmjs.org,这带来多个问题:

  • 单一中心化 registry 风险极高(宕机、攻击、污染)
  • 国内访问速度慢、容易被劫持
  • 企业无法对 registry 内容进行审计和控制

未来的趋势可能会是:

  • 分布式镜像成为主流
  • 带完整校验的内容寻址被更多工具采用
  • 零安装可能是包管理器的最终形态
    • Yarn Plug'n'Play
    • pnpm fetch + store
    • 运行时级别的 snapshot(Bun、Deno)
  • 更像 Rust/Cargo 的“项目级工具链管理”成为标准

参考资料