ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Monorepo子包依赖安装全指南:从提升机制到幽灵依赖排查

Monorepo子包依赖安装全指南:从提升机制到幽灵依赖排查 如果你维护过一个稍大的 Monorepo 仓库大概率会遇到这类问题在 packages/web 子包里执行 npm install xxx装完却发现依赖跑到了根目录明明其他子包都用了同一个版本的库到了自己这边就行为不一致更麻烦的是CI 上拉下代码重新安装依赖直接报错说缺少某些依赖项。这些问题的根源大多集中在一个地方——子包安装依赖。这篇文章我打算把 Monorepo 里子包依赖安装这件事彻底讲透包括目录结构怎么搭、依赖该装在根还是装在子包、本地包之间怎么互相引用、报错时从哪里开始排查以及一些我踩过坑之后沉淀下来的习惯。适合刚开始用 Monorepo 的团队也适合已经用了 npm/yarn workspace 却总是被依赖问题拖累的同学。1. 先搞清楚Monorepo 里的“安装依赖”到底是个什么场景1.1 一个仓库里塞了一堆子包依赖关系立刻变复杂Monorepo 本质上就是把多个原本独立管理的项目合并到一个 Git 仓库里统一维护。这些项目通常叫子包package放在 packages/ 或者 apps/ 目录下面。每个子包都有自己的 package.json声明自己的名字、版本、脚本和依赖。在传统多仓库模式下每个项目独立安装依赖在项目根目录执行 npm install 就能生成 node_modules 和 lockfile流程简单新人也能搞定。但到了 Monorepo 场景依赖关系彻底变了子包 A 依赖子包 BB 又依赖 C根目录要有所有子包共享的开发依赖比如 TypeScript、ESLint子包之间还可能有版本范围交叉A 要求 lodash ^4.17.20B 要求 ^4.17.21两边能不能统一用一个版本这些问题已经在挑战传统的安装方式了。正因为这种复杂性Monorepo 的依赖安装不能再用“进入某个子包目录执行 npm install”这种原始方式你需要一套统一的工作区机制来协调。这就是 npm workspaces、yarn workspaces、pnpm workspace 这些概念出现的原因。1.2 npm、yarn、pnpm 三种工作区机制对比目前主流的就是这三种方案底层行为差异很大。维度npm workspacesyarn workspacespnpm workspace需要版本npm 7workspace 协议建议 8.5Yarn 1.x / Berrypnpm 3符号链接支持支持支持配合硬链接默认依赖提升是是否严格访问未声明依赖否否是workspace 协议部分版本支持支持支持磁盘占用优化一般一般明显多子包集成体验一般好好npm 从 7 开始把 workspaces 作为原生能力配置方式是在根 package.json 里加 workspaces 字段。yarn 1.x 也是类似思路yarn classic 的 workspace 命令是 yarn workspace 包名 add 依赖。pnpm 则在 pnpm-workspace.yaml 里声明目录使用 --filter 来指定子包。这里最本质的差异在于是否提升依赖hoisting以及是否容忍幽灵依赖phantom dependencies。npm 和 yarn 默认会把所有子包的依赖提升到根 node_modules方便是方便但很容易出现“我的代码里明明没有声明 lodash却能用 lodash”的情况。pnpm 默认不这么做它会把依赖安装到全局 store 中再通过符号链接放到每个子包自己的 node_modules 下并且只暴露该子包 package.json 里声明过的依赖。1.3 为什么我更推荐 pnpm 作为 Monorepo 的默认选择不是说什么场景都必须上 pnpm但如果你接手一个中大型 Monorepo尤其是前端项目Vue、React 等框架混合我一般建议优先考虑 pnpm。三个原因第一严格的依赖访问控制。pnpm 会强制你只能使用子包 package.json 里声明的依赖其他间接依赖即便已经存在于 node_modules 中也无法直接访问。这初看有点烦但能提前暴露大量隐患比等到上线前才发现某个包在全仓库都找不到要好。第二磁盘空间优化。多个子包使用同一版本的依赖时pnpm 只会在 store 里存一份并通过硬链接复用。我在一个包含 12 个前端子包的仓库里实测过用 npm 安装后 node_modules 约 4.8GB改用 pnpm 后降到了 1.9GB 左右。第三对 workspace 协议的支持非常成熟filter 参数能精确控制对子包的操作适合快速定位单个子包的依赖问题。顺带说一句有的团队因为历史原因必须留在 npm workspaces也不用太担心后面几节介绍的思路大多通用只是命令换成 npm 对应形式。2. 动手实操在子包中安装依赖的完整流程2.1 最小 Monorepo 目录与 package.json 准备为了把概念落到地上我们建一个最小 Monorepo。目录结构大致如下my-monorepo/ ├── package.json ├── pnpm-workspace.yaml ├── packages/ │ ├── utils/ │ │ └── package.json │ └── ui/ │ └── package.json根 package.json 可以很薄只保留脚本和公共开发依赖{ name: my-monorepo, private: true, scripts: { build: pnpm -r build }, devDependencies: { typescript: ^5.4.0 } }根 package.json 里不建议放业务依赖只放那些跟整体构建、CI 相关的工具。业务依赖应该尽量放到对应子包里否则容易出现“这个库到底是谁在用”的歧义。pnpm-workspace.yaml 声明哪些目录算子包packages: - packages/*子包 utils 的 package.json 可以这样写{ name: my/utils, version: 1.0.0, main: src/index.ts, dependencies: { lodash: ^4.17.21 } }然后在根目录执行pnpm install完成后你会看到根目录出现 pnpm-lock.yaml以及一个 node_modules 文件夹。pnpm 默认会把符号链接粒度做到子包级别所以在 packages/utils/node_modules 下能看到 lodash 的符号链接但它的物理文件只存在于全局 store 里。2.2 在子包内安装单个依赖的正确姿势最常见的需求是给某一个子包单独安装依赖。pnpm 推荐在根目录使用 filter 指定pnpm add --filter my/ui react这条命令会修改 packages/ui/package.json并同步更新根 lockfile。npm 对应的是npm install react -w packages/uiyarn classic 对应的是yarn workspace my/ui add react有的人习惯先 cd 到子包目录再执行 install比如cd packages/ui pnpm add react其实也可以但我不推荐原因后面第 3.2 节会讲。在 Monorepo 根目录统一操作能保证所有子包之间的依赖变化都在同一份 lockfile 里被记录不会出现某个子包目录下自己生成了一个独立 lockfile 的情况。如果你要安装的依赖只是本地开发用的不进入发布产物可以加-Dpnpm add --filter my/ui -D vitest这样会写入 devDependencies。2.3 子包之间互相引用的依赖声明workspace 协议Monorepo 里最常用到的功能是子包 A 依赖另一个子包 B。比如 my/ui 要用 my/utils 提供的工具函数。第一反应可能是这样写{ dependencies: { my/utils: ^1.0.0 } }这样写有一个问题pnpm 会先检查 my/utils 是否存在于本地 workspace 中如果版本匹配会直接链接本地包。理论上可行但不够明确。更好的方式是使用 workspace 协议{ dependencies: { my/utils: workspace:* } }workspace:* 的意思是不管版本号是多少一律使用本地 workspace 中的这个包。这样写的好处是本地开发时几乎不会出现“明明改过 utils 源码但 ui 里看到的是 registry 上的旧版本”这种诡异问题。如果希望发布时能自动转换changesets 可以将 workspace:* 改写为实际版本号再发布到 npm registry。这一点后面 4.2 节详细说。pnpm 和 yarn 原生支持 workspace 协议。npm 的 workspaces 对这种协议的兼容涉及版本差异所以如果你在用 npm workspaces建议直接用本地版本号或者提前确认 npm 版本对 workspace 协议的支持情况。2.4 依赖提升机制为什么 npm 会偷偷改变你的安装结构这一节要解释依赖提升hoisting它是理解 Monorepo 子包安装依赖的关键。npm 和 yarn 在安装时有一个默认策略能提升到根 node_modules 的尽量提升。比如 packages/ui 依赖 lodashpackages/utils 也依赖 lodash而且版本范围兼容那 lodash 只会在根 node_modules 里装一份两个子包通过 Node.js 的模块查找一路向上找到根目录都能用。这个策略的初衷是节省磁盘和安装时间同时也带来一个副产品幽灵依赖。假设 packages/web 的代码里 import 了 lodash但 package.json 里并没有声明 lodash因为 lodash 恰好被根 node_modules 下的某个其他依赖带过来了所以本地运行一切正常。这种暗藏的不合法依赖一旦根 node_modules 结构变化比如其他依赖升级后不再依赖 lodash你的代码会瞬间在 CI 或同事电脑上崩掉。pnpm 的选择不同它把依赖提升关掉了默认采用严格模式。每个子包的 node_modules 下面只有它声明过的依赖符号链接未声明的依赖在 Node.js 模块解析时根本找不到。这个特性强制开发者写清每个子包的真实依赖长期来看省下的排查时间远比当初省掉的几步命令要多。3. 子包安装依赖最容易踩的坑与排查方法3.1 幽灵依赖A 子包能用 B 子包没声明的库第一个必须说透的就是幽灵依赖。我在前端 Monorepo 里见过太多次这种问题。举一个真实场景。仓库里有 apps/admin 和 apps/site 两个应用子包还有一个 packages/hooks 子包。hooks 依赖了 dayjsadmin 的代码里也顺手用了 dayjs但 admin 的 package.json 没有声明。npm 安装时由于 hoistingdayjs 被提升到根 node_modulesadmin 自然也能引用一切正常。过了两个月hooks 升级时移除了 dayjs 依赖或者安装顺序变化导致 dayjs 不再出现在根 node_modulesadmin 立刻崩了报错通常是 Cannot find module dayjs。排查时先别着急猜用几个命令定位pnpm why dayjs这条命令会列出 dayjs 依赖关系链告诉你哪些子包声明了它、哪些是间接依赖。如果发现某个子包在代码里用了它但 dependencies 里没写那就是幽灵依赖。如果是 npm 环境可以用npm ls dayjs它会给出依赖树但因为提升机制可能显示在根 node_modules 下你还要人工判断谁真正声明了它。更隐蔽的是版本间不一致A 子包依赖 dayjs 1.xB 子包依赖 dayjs 2.x如果两个子包互相依赖锁文件就会同时锁定两个版本。解决思路要么升级统一到一个大版本要么用别名。3.2 安装到根还是安装到子包位置不对引发的怪问题很多人容易犯的一个错误是在子包目录里执行新的依赖安装。假设你当前在 packages/ui 目录下直接跑pnpm add axiospnpm 会检测这里的 workspace 根节点把 axios 安装到 packages/ui/package.json这通常没问题。但 npm 的行为就复杂一些如果你在 packages/ui 目录下跑 npm install axios有的 npm 版本会把依赖写进子包有的则会在子包目录生成一个独立的 package-lock.json从而破坏根目录的 lockfile 一致性。我建议的统一习惯是所有依赖安装都在仓库根目录执行并用 filter 或 workspace 参数指定目标。这样不管哪个团队成员执行命令结果都是可预测的。还有一类情况想把某个只用于构建的工具装到根。比如你想统一装一个 prettier 给所有子包用可以用pnpm add -w -D prettier-w 是 --workspace-root 的简写表示安装到根目录。写在根目录的依赖所有子包运行时都能通过 Node 模块解析找到这本身没问题但要注意的是这会在子包之间形成隐性依赖。比如 ui 子包里的 prettier 相关脚本其实依赖的是根目录的 prettier一旦团队有人把 root 当成杂货间后面很容易乱。3.3 “缺少依赖项”类报错的通用排查思路很多人在安装依赖时会碰到类似“由于缺少一些依赖项无法安装产品”这样比较模糊的报错。出现这种报错时第一反应不应该是怀疑 Monorepo而是排查环境。这类问题在 Monorepo 环境中高发原因在于子包数量多原生模块、编译工具链穿插其中。比如某个子包依赖了 node-sass 或 sqlite3 这类需要原生编译的包安装时就需要系统里有 Python、C 编译工具链、对应操作系统版本的 SDK。我在 Windows 和 Linux 上都遇到过类似问题报错信息里甚至会有“请确保已安装这些驱动程序”之类的提示虽然字面上说的是驱动实际上往往是系统缺了某个运行库或编译环境。如果你在 Monorepo 里遇到这类安装失败按顺序排查先看报错全文重点看 Failed 或 Error 后面的完整路径确认是哪一层子包、哪个依赖导致的中断。检查系统环境比如 Python 是否在 PATH 中node-gyp 要求的版本是否满足编译器是否具备。确认网络环境registry 是否能访问到对应依赖的下载地址。如果是 Windows还需要注意符号链接相关权限pnpm 要求开启开发者模式或使用管理员运行的终端。有些项目还要求在指定 Python 路径下做环境配置。比如某些量化平台 QMT、AI 工具链 ComfyUI 安装依赖时都会要求你先把环境切到特定 Python 解释器再安装否则会装到别的环境里去。Monorepo 里也有类似场景你可能会同时维护多个运行环境不同的子包有的是 Node 应用有的是 Python 脚本。这种跨语言 Monorepo 的依赖安装方案一般不建议只靠 npm/pnpm 一把梭而是让不同语言各自管理依赖再用根目录脚本统一串联。3.4 lockfile 冲突与版本漂移的实战处理多人协作的 Monorepo最让人头疼的就是根目录 lockfile 的冲突以及由此引发的版本漂移。假设仓库用的是 pnpm。根目录 pnpm-lock.yaml 记录所有子包的完整依赖树任何子包改动依赖都会导致 lockfile 变化。当两个分支同时新增依赖合并时 lockfile 大概率冲突。处理方式有几种养成“依赖变更独立提交”的习惯尽量少把依赖升级和功能开发混在同一个 PR 里。出现冲突时不要急着手工改 lockfile先在其中一个分支上重新生成pnpm install --lockfile-only再提交。CI 上强制使用pnpm install --frozen-lockfile发现 lockfile 与 package.json 不一致时直接失败这样能防止有人把失败状态的 lockfile 合进主分支。版本漂移是另一种坑。子包 A 用lodash: ^4.17.20子包 B 用lodash: ^4.17.21正常情况下 pnpm 会尝试解析到一个符合所有范围的版本并安装在 store 和对应位置。但如果两个范围互不兼容pnpm 会各自安装导致同一份代码在不同子包中的行为不同。这种问题在开发时很难察觉直到你发现一个子包修好的 bug 在另一个子包复现。要想统一跨子包依赖版本可以用 syncpack。它能够扫描所有子包的 package.json检查相同的依赖是否用了不同的版本范围还能一键把范围内的版本统一成指定值。3.5 网络与缓存问题换个环境就装不上依赖Monorepo 的子包数量一多依赖下载量会非常大网络和缓存问题也随之放大。最常见的现象是本地装得很好推到 CI 或者换一台机器就失败。这种问题一般有三个方向registry 不稳定。国内环境经常需要配置镜像pnpm 可以通过 .npmrc 配置 registry例如registryhttps://registry.npmmirror.com。注意 .npmrc 的位置根目录配置会影响所有子包。store 不一致。pnpm 的全局 store 如果在不同机器上内容不同会导致安装时重新下载。可以在 CI 中缓存 store 目录减少网络请求。离线包问题。有时你把依赖下载到本地希望用离线文件安装。pnpm 有对应命令比如通过pnpm store add先把某个包放入 store再离线安装。这与你在 ComfyUI 这类工具中手动指定本地依赖文件是相似的思路——关键是要搞清工具读取的目录和解析方式避免“我明明下载了但没生效”的错觉。如果遇到“明明换了一台机器就成功这台机器总是失败”的情况优先测试网络和全局缓存目录权限不要一上来就改代码。4. 把子包依赖装稳的进阶实践4.1 用同一份 lockfile 约束所有子包的依赖Monorepo 的优势之一是根目录只有一份 lockfile可以让所有子包在同一个依赖快照下运行。这样在 CI 上构建时无论构建哪个子包使用的依赖版本都一致可复现性会好很多。但前提是你得保证不会产生多余 lockfile。你需要检查仓库里是否存在 packages/*/package-lock.json 这类文件。一旦出现说明有人在子包目录单独执行过 install。发现后应删除并在根目录重新执行安装。为了防止再次发生可以在 CI 里加一道检查扫描多余 lockfile 文件。在依赖锁定的基础上使用--frozen-lockfile是必要的。比如 pnpm 中pnpm install --frozen-lockfile如果 package.json 与 lockfile 不完全匹配命令会直接报错而不是帮你悄悄更新 lockfile。这样能确保 CI 环境与提交的 lockfile 完全一致。npm 对应参数是npm ciyarn classic 是yarn install --frozen-lockfile。4.2 结合 changesets 管理发布让依赖变更可追溯子包安装依赖只是第一步最终是要让子包发布上线。在 Monorepo 里发布一个包时它依赖的本地 workspace 包如果也改了就必须一起发布新版本并更新依赖范围。changesets 是目前最成熟的方案之一。它不直接管理依赖版本而是在仓库中记录 changeset 文件描述哪些子包做了哪些改动、版本是 minor 还是 major最后通过 CI 合并成 changelog 和版本变更。使用流程大概是这样pnpm add -w -D changesets/cli pnpm changeset init pnpm changeset每次改动后在根目录执行 pnpm changeset 会交互式问你哪个子包改了、版本级别是什么是否作为某个依赖的配套变更。提交后之后在 CI 执行pnpm changeset version pnpm changeset publishchangesets 会自动把子包里的workspace:*依赖改写成实际发布的版本号然后发布到 registry。这样做的好处是依赖变更可以追溯到一次 PR而不是散落在多个提交里。4.3 在 CI 里安装子包依赖的高效姿势CI 环境与本地有很大差异最大的问题是每次都是全新环境依赖安装时间可能长达十几分钟。优化思路有几个第一合理缓存 store。pnpm 的全局 store 可以做持久化缓存比如在 GitHub Actions 中缓存~/.local/share/pnpm/storeWindows 路径会不同可以大幅减少二次安装时间。第二使用 frozen lockfile。前面提过这里再次强调CI 里绝不能允许依赖悄悄变更。第三按需安装。虽然 Monorepo 统一安装所有子包比较省心但如果你只需要构建其中一个子包可以用 pnpm 的 filter 缩小范围pnpm install --filter my/ui...注意后面是三个点表示同时安装该子包及其依赖的 workspace 子包。第四不要把日志污染到失控。子包多时安装日志会非常长建议开启--reporterappend-only或类似配置让 CI 日志只显示关键步骤。4.4 一些值得长期坚持的目录设计原则最后聊一聊长期维护 Monorepo 时的几个原则它们在源头减少依赖安装问题。一是尽量使用 scope 规范子包命名形如 your-org/package-name。scope 能精准指定子包避免出现 ui、utils 这样容易冲突的名字。二是控制子包规模。子包越多依赖关系越复杂安装和编排成本也越高。如果一个子包只有几行代码不如考虑合并到其他包中。三是依赖关系图尽量保持有向无环。子包之间的依赖不要形成环否则发布和安装都会变得很难受。可以用 madge 之类的工具在 CI 中检查依赖关系防止循环依赖。四是谨慎使用 peerDependencies。Monorepo 中跨子包传递 peer 依赖经常引发版本匹配问题尤其在 Vue/React 组件库场景中peer 依赖范围写得太紧会导致安装失败写得太松又可能让使用者拿到不兼容版本。在能明确统一的情况下直接使用 dependencies 反而更省心。5. 我的几个依赖管理习惯说到底Monorepo 子包安装依赖的核心不是在命令层面记住一堆参数而是理解依赖的本质节点之间如何解析、提升和链接以及如何用统一锁文件约束版本。单仓库时代我们只需要关心 npm install 做了什么到了 Monorepo你需要额外关心的是“哪一个包”和“在哪一层”。我这里给出一个最实用的建议无论你最终选 npm、yarn 还是 pnpm都请在根目录统一操作用 filter 或 workspace 命令指定子包如果遇到依赖安装报错先看环境再改代码尽量把 lockfile 当成代码来维护该 review 就 review。我在实际维护 Monorepo 的过程中深有体会依赖装错位置的坑远远少于依赖清理不及时的坑。现在我维护新仓库时会把“能否用 pnpm 严格链接”作为默认验收项如果团队不想换工具那至少也要保证根 lockfile 始终存在于版本库且 CI 里开启 frozen 校验。一套流程顺下来再回头看那些“莫名其妙装不上”的问题大概率都是环境或约定层面可以提前规避的。最后再分享一个小技巧如果你发现自己经常在 Monorepo 里依赖某个包时来回踩坑先在根目录跑一次pnpm why和pnpm outdated看清楚依赖来源和版本状态再决定是升级还是固定版本。比起盲目删 node_modules 重装这种排查方式要精准得多。
返回列表