|
| 1 | +--- |
| 2 | +title: pnpm 的 catalogs 功能 |
| 3 | +slug: pnpm-catalogs |
| 4 | +authors: ["oxygen"] |
| 5 | +tags: ["pnpm", "pnpm workspace", "monorepo", "pnpm catalogs", "node"] |
| 6 | +description: 介绍 pnpm 的 catalogs 功能以及使用 monorepo 的工具、好处等内容 |
| 7 | +--- |
| 8 | + |
| 9 | +pnpm 在 v9.5 加入了 catalogs 功能,用于对 monorepo 依赖进行分类管理。这将大大降低 monorepo 内部对于多个项目之间依赖版本统一管理的难度。为了清楚地认识到 catalogs 功能对于 monorepo 依赖管理的便捷性,下面我们从 monorepo 的介绍以及 catalogs 的使用两方面来探讨。 |
| 10 | + |
| 11 | +<!--truncate--> |
| 12 | + |
| 13 | +## 什么是 monorepo |
| 14 | + |
| 15 | +monorepo 中 mono 表示单个,repo 表示 repository,monorepo 也就是单一仓库的意思。monorepo 最初跟随着**分布式版本控制系统** Git 兴起(2005年)而被 Google 使用。后来随着前端开源社区的发展,Lerna 在 2015 年发布,成为了**第一个在 JavaScript 社区普及 Monorepo 概念的标志性工具**。Lerna 解决了两个核心问题: |
| 16 | + |
| 17 | +1. **引导 (Bootstrap)**: 自动将 monorepo 内的包相互链接 (symlink),让你可以在本地像使用已发布的包一样引用它们。 |
| 18 | +2. **发布 (Publish)**: 自动化管理所有包的版本号、生成 changelog,并将更新过的包一键发布到 npm。 |
| 19 | + |
| 20 | +后来又有了 Meta 开源的 yarn workspace(2017),以及后来更完善的 pnpm Workspace(2020)。 |
| 21 | + |
| 22 | +## 为什么用 monorepo |
| 23 | + |
| 24 | +首先我个人认为,对于业务领域内**子项目 < 5 个**,**维护人数 < 5** 的前端项目应该使用 monorepo,可以获得以下好处: |
| 25 | + |
| 26 | +1. 统一工具链和代码规范。前端各种构建工具,Lint 等配置相当繁琐,使用 monorepo 可以在创建项目初期就完成这些约束,后续项目只需要专注自身的构建和业务逻辑即可。 |
| 27 | +2. 统一依赖管理,减少项目整合和迁移的成本。如果你用 React18,其他组用 React19,那么在遇到业务整合场景时就可能遇到很高的迁移成本。 |
| 28 | +3. 代码共享和复用方便快捷。前端开发必会有通用的 `components` 和 `utils`,如果走传统的多仓库发包更新,流程冗长,非常不利于团队内部共享使用;并且如果大家各自都开发一份,又会增加时间和人力成本。 |
| 29 | + |
| 30 | +使用 monorepo 的优势明显,但是缺陷也存在: |
| 31 | + |
| 32 | +1. monorepo 架构复杂,需要开发者对 Lint,Turborepo 等相关管理工具,以及CI、CD 等非常熟悉才能减少踩坑。 |
| 33 | +2. monorepo 依赖管理复杂。monorepo 不光需要统一多个项目之间的第三方库依赖版本,同时还需要为后期升级做准备。举个例子,如果某个项目因为第三方库的兼容性需要升级 React 版本,如果早期用 `overrides` 强行锁定 monorepo 内 React 的版本就会出问题。所以,monorepo 内部的项目应该定期对稳定的基础库做升级,大型稳定库例如 React 都是向下兼容,这种升级成本不会太高但是远期收益很高。 |
| 34 | +3. monorepo 随着多个项目业务复杂性的增强,Lint 速度,Git 操作,依赖安装会变得越来越慢。不过现在有很多第三方工具在努力解决这些问题,例如 Turborepo 通过智能缓存和任务编排解决项目依赖安装问题,Oxlint 等解决 Eslint 速度慢的问题。最麻烦的其实还是 Git 操作,比如多个需求并行时,需要切换 Git 分支,如果不同分支的依赖存在不同,每次切换都需要重新安装依赖,非常繁琐。 |
| 35 | + |
| 36 | +## npm vs yarn vs pnpm |
| 37 | + |
| 38 | +### npm |
| 39 | + |
| 40 | +首先 npm v7(Nodejs v15)开始支持 workspaces 功能,使用步骤如下: |
| 41 | + |
| 42 | +1. 在**项目根目录的 package.json** 中添加一个 workspaces 字段 |
| 43 | + |
| 44 | +```json |
| 45 | +{ |
| 46 | + "name": "my-monorepo", |
| 47 | + "workspaces": [ |
| 48 | + "packages/*", // 匹配 packages 文件夹下的所有子项目 |
| 49 | + "apps/*" // 匹配 apps 文件夹下的所有子项目 |
| 50 | + ] |
| 51 | +} |
| 52 | +``` |
| 53 | + |
| 54 | +2. 使用 npm 提供的 `-w` (或 --workspace) 和 `--ws ` (或 --workspaces) 标志来筛选执行脚本的目录,参考[npm-workspace-demo](https://github.com/wood3n/npm-workspace-demo) 仓库示例 |
| 55 | + |
| 56 | +```shell |
| 57 | +# 在 my-app 子项目中运行 dev 脚本 |
| 58 | +npm run dev -w my-app |
| 59 | + |
| 60 | +# 在所有子项目中运行 test 脚本 |
| 61 | +npm run test --ws |
| 62 | +``` |
| 63 | + |
| 64 | +npm 会将多个项目的依赖统一安装到项目根目录的 node_modules 目录下,即使是单个项目自己用到的依赖也是如此,而针对项目间相同依赖不同版本则安装到项目内部 node_modules。所以会 npm workspace 会带来幽灵依赖以及占用磁盘空间的问题(现在的存储价格可是寸土寸金)。 |
| 65 | + |
| 66 | +### lerna |
| 67 | + |
| 68 | +lerna 的缺陷在于它只是一个项目管理工具,没有和 npm、yarn 结合。 |
| 69 | + |
| 70 | +使用 lerna 需要自己管理依赖关系,也就是使用 npm 或者 yarn 安装依赖,然后使用 `lerna bootstrap` 来在 monorepo 内部的包之间创建符号链接。由于 lerna 依赖 npm,所以会暴露和 npm 一样的幽灵依赖等问题。 |
| 71 | + |
| 72 | +### yarn |
| 73 | + |
| 74 | +yarn v1 虽然将 monorepo 的项目管理和 yarn cli 命令结合在了一起,但是依然存在依赖提升,性能和磁盘空间占用的问题。 |
| 75 | + |
| 76 | +而 yarn v2 以后增加了 pnp 模式,不创建 node_modules,所有包直接从一个 `.pnp.cjs` 索引文件中获取,优点是极速解析、无重复依赖。但是生态兼容性问题严重,IDE、ESLint、ts-node、Webpack 等工具需特殊配置。参考 [yarn-workspace-demo](https://github.com/wood3n/yarn-workspace-demo) |
| 77 | + |
| 78 | +### pnpm |
| 79 | + |
| 80 | +pnpm workspace 主要解决了以下问题: |
| 81 | + |
| 82 | +1. 在磁盘内存在 `.pnpm-store` 全局缓存,所有本地使用的包只会安装一次,后续使用硬链接绑定。不仅减少后续安装时间,而且大大减少 monorepo 仓库体积; |
| 83 | +1. pnpm 使用独特的**符号链接 (Symlinks) node_modules 结构**,在任何一个子项目的 node_modules 目录里,你只能直接看到**你在该项目 package.json 中明确声明的依赖**,避免了幽灵依赖的问题。 |
| 84 | + |
| 85 | +## 为什么要统一依赖管理 |
| 86 | + |
| 87 | +为什么 monorepo 内部要保证依赖版本的统一性呢? |
| 88 | + |
| 89 | +1. 避免单例实例问题,例如 React。举个例子,你的 components 库是用 React17 开发的,而你的主应用使用的是 React18。当主应用使用一个components 的组件时,这个组件内部的 React Hooks 是由 React17 的实例调用的,但它所在的环境却是 React18 的,这就会导致抛出**"Invalid hook call"** 错误。 |
| 90 | +2. 避免重复构建打包。如上所说,如果存在两个版本的 React,那么 components 和主应用内部在构建后会存在两个版本的 React,明显增大项目体积。 |
| 91 | +3. 保证类型提示统一。不同版本的第三方库的 api 可能存在属性等类型的不同,保证版本统一可以在多个项目间使用时无缝切换,不需要感知这些 api 的变化。 |
| 92 | + |
| 93 | +## 使用 pnpm catalogs |
| 94 | + |
| 95 | +### catalog |
| 96 | + |
| 97 | +pnpm 支持在 `pnpm-workspace.yaml` 中使用 `catalog` 定义一个名为 `default` 的目录。这些版本范围可以通过 `catalog:default` 引用。仅有默认目录时,也可以使用特殊的 `catalog:` 简写。 将 `catalog:` 视为可扩展为 `catalog:default` 的简写。 |
| 98 | + |
| 99 | +```yaml |
| 100 | +packages: |
| 101 | + - packages/* |
| 102 | + |
| 103 | +catalog: |
| 104 | + react: ^18.2.0 |
| 105 | + react-dom: ^18.2.0 |
| 106 | +``` |
| 107 | +
|
| 108 | +在 monorepo 内的项目可以在 `package.json` 中通过 `catalog:default` 引用默认依赖。仅有默认目录时,也可以使用特殊的 `catalog:` 简写。 |
| 109 | + |
| 110 | +```json |
| 111 | +{ |
| 112 | + "name": "@example/app", |
| 113 | + "dependencies": { |
| 114 | + "react": "catalog:", |
| 115 | + "react-dom": "catalog:" |
| 116 | + } |
| 117 | +} |
| 118 | +``` |
| 119 | + |
| 120 | +### catalogs |
| 121 | + |
| 122 | +也可以使用 `catalogs` 自定义多个名称的 catalog: |
| 123 | + |
| 124 | +```yaml |
| 125 | +catalogs: |
| 126 | + # 可以通过 "catalog:react17" 引用 |
| 127 | + react17: |
| 128 | + react: ^17.0.2 |
| 129 | + react-dom: ^17.0.2 |
| 130 | +
|
| 131 | + # 可以通过 "catalog:react18" 引用 |
| 132 | + react18: |
| 133 | + react: ^18.2.0 |
| 134 | + react-dom: ^18.2.0 |
| 135 | +``` |
| 136 | + |
| 137 | +然后项目内部的 `package.json` 使用 `catalog:<name>` 来表示依赖版本。 |
| 138 | + |
| 139 | +```json |
| 140 | +{ |
| 141 | + "name": "@example/components", |
| 142 | + "dependencies": { |
| 143 | + "react": "catalog:react18", |
| 144 | + } |
| 145 | +} |
| 146 | +``` |
| 147 | + |
| 148 | +对于需要发布的包,pnpm 也会在发布的时候替换到 `catalog:` |
| 149 | + |
| 150 | +### CLI 支持 |
| 151 | + |
| 152 | +pnpm 在 v10.12.1 版本对 `pnpm add` 命令支持了 `--save-catalog` 和 `--save-catalog-name` 两个选项。这两个选项主要有两个作用: |
| 153 | + |
| 154 | +1. 将安装的依赖写入 `catalog` 或者指定名称的 `catalogs`,并且使用 `catalog:[name]` 作为目标项目依赖的版本写入 `package.json`; |
| 155 | + |
| 156 | +```shell |
| 157 | +# 将 lodash 添加到 utils 并添加到默认 catalog |
| 158 | +pnpm add lodash --filter utils --save-catalog |
| 159 | +
|
| 160 | +# 将 lodash 添加到 components 并添加到目录 app-utils |
| 161 | +pnpm add lodash --filter components --save-catalog-name app-utils |
| 162 | +``` |
| 163 | + |
| 164 | +2. 对于已经在默认目录 `catalog` 中定义的依赖,如果没有指定依赖的版本,使用 `pnpm add` 命令时会直接使用 `catalog:` 作为版本写入项目的 `package.json`;如果带上 `--save-catalog-name` 选项,则会从指定 catalog 读取并作为要安装的依赖版本,没有则添加到指定名称的 catalog 下。 |
| 165 | + |
| 166 | +## catalog 的便捷性 |
| 167 | + |
| 168 | +在没有 catalog 的时候,如果要保证多个项目安装同一依赖的版本唯一性,大概有以下几种方式: |
| 169 | + |
| 170 | +1. 使用 `overrides` 强制约定**整个依赖树** (包括直接和**间接/传递**依赖)的版本唯一。这会导致限制的依赖版本低时无法使用一些使用高版本 `peerDependencies` 的库,比如很多 React 库只会兼容最新的 React 版本。 |
| 171 | +2. 对每个项目的依赖锁版本,同时在安装第三方依赖时自行查看其他项目的依赖并固定安装版本,比较繁琐,一不小心漏查了就容易导致多个项目之间相同的库版本不统一; |
| 172 | +3. 使用 `pnpmfile.cjs` 的 hook,自行编写脚本,在安装依赖的时候扫描所有其他项目依赖版本,保证安装版本的统一; |
| 173 | +4. 将第三方库安装到单一项目内做进一步封装使用,这在 utils 或者 components 子项目中很实用,既能保证多个项目版本的统一,也能为不同项目自定义第三方库中相同的业务逻辑。 |
| 174 | + |
| 175 | +在有了 catalogs 之后,monorepo 内部统一版本依赖只需要在使用 `pnpm add` 时带上 `--save-catalog` 和 `--save-catalog-name` 两个选项即可,很轻松地就共享了项目依赖的版本,同时在 `pnpm-workspace.yaml` 也能很明显地查看到项目依赖的情况。 |
| 176 | + |
| 177 | +对于 catalog 分类,我个人倾向于不是那么实用,只要项目能够共享的依赖版本,大可以直接使用 `--save-catalog` 保存到默认目录下即可,后续使用 `pnpm add` 也能快速在其他项目内部安装。 |
| 178 | + |
| 179 | +## catalog 的缺陷 |
| 180 | + |
| 181 | +catalog 的唯一缺点就是在项目的 `package.json` 中定义时无法直接显示依赖版本,这里就推荐一个 antfu 的 vscode 插件 —— [Catalog Lens](https://marketplace.visualstudio.com/items?itemName=antfu.pnpm-catalog-lens). 能够在 vscode 中将 `package.json` 中使用 `catalog:` 的版本替换成具体对应的版本号。 |
| 182 | + |
| 183 | + |
| 184 | + |
0 commit comments