pnpm workspace Monorepo 依赖隔离与工程化实战
发布时间:2026/10/1 23:55:12 作者:尧图编辑部 阅读量:1,286

前几年我手上同时维护着七八个互相依赖的包改一个公共组件要在三个仓库里分别发版本、分别升级一个下午就这么没了。后来整体迁到 Monorepo包管理器从 npm 换到 yarn 又换到 pnpm最后停在 pnpm workspace 上就没再动过。这套组合真正吸引我的地方不是快而是它把依赖这件事说清楚了每个包能看见什么、不能看见什么由磁盘上的链接结构决定而不是靠扁平化提升碰运气。如果你正准备把散落的仓库合并成一个或者接手了一个已经跑起来但没人讲得清依赖关系的 Monorepo下面这些内容基本能覆盖从初始化到上线打包会遇到的大部分问题。我会先讲清楚 pnpm 和 npm/yarn 在磁盘上到底差在哪再给出一套能直接抄的 workspace 配置然后是完整的实操流程、几个高频故障的排查路径以及长期维护时要盯住的几件事。1. 为什么我最终把 Monorepo 的包管理器换成了 pnpm1.1 从 npm 和 yarn 的目录结构说起npm 3 之后为了解决 Windows 上路径过长的问题把依赖树做成了扁平化提升你装了 AA 依赖 lodash那么 lodash 会被提升到项目根目录的node_modules/lodash而不是放在node_modules/A/node_modules/lodash。这个设计在单包项目里没什么感觉一旦进入 Monorepo 就开始出问题。最典型的是幽灵依赖——你的代码里写了require(lodash)但package.json里根本没声明它它能跑通完全是因为 A 恰好装了 lodash 并且被提升到了顶层。等到某天 A 换了一个不依赖 lodash 的版本或者你换了个包管理器代码原地爆炸而且报错位置和真正的原因离得很远查起来非常难受。yarn v1 沿用了类似的提升策略同时引入了 workspaces 概念把各个子包的依赖统一提升到根目录的node_modules。这在当时是个不小的进步但也意味着子包之间的依赖边界彻底模糊了任何包都能随手引用到别的包装的东西package.json逐渐变成一份写给人看的文档而不是机器严格执行的契约。我在一个二十多个包的项目里亲眼见过这种失控——没人敢删任何一个依赖因为谁也不知道删了会不会影响别人最后根目录的package.json里躺着一百多个依赖其中一大半已经没人用了。pnpm 的做法是换了一套底层机制。它在磁盘上维护一个全局的内容寻址仓库store所有包的真实文件只存一份然后通过硬链接把它们投放到每个项目的node_modules/.pnpm目录里。项目顶层node_modules下放的只是指向.pnpm里真实目录的软链接而且只放当前 package.json 里明确声明的依赖。你的包里没声明 lodash顶层就没有 lodash 的软链接代码里 require 它就会直接报错。这种提前暴露问题的设计在单包项目里可能显得有点严苛但在多包协作的 Monorepo 里它把依赖边界重新变成了硬约束。1.2 硬链接和软链接到底省了什么先解释硬链接。一个文件在磁盘上由 inode 和若干目录项组成硬链接就是让多个目录项指向同一个 inode。也就是说同一个 lodash 4.17.21无论有多少个项目引用它物理磁盘上只有一份内容。这跟 npm 的每个项目复制一份完全不同。这里有个前提容易被忽略硬链接不能跨文件系统。如果你的项目放在 D 盘而 pnpm 的 store 默认在 C 盘的用户目录下硬链接就创建不了pnpm 会退化成文件复制你会看到类似cross-device link的警告安装速度也明显变慢。解决办法是把 store 挪到同一个盘pnpm config set store-dir D:\.pnpm-store pnpm store path第二条命令用来确认当前生效的 store 路径改完配置后一定要跑一次不然你以为改了其实没改。软链接负责另一件事把.pnpm里的真实目录暴露到顶层node_modules。一个典型的项目目录长这样node_modules/ ├── .pnpm/ │ ├── lodash4.17.21/ │ │ └── node_modules/ │ │ └── lodash/ - 真实文件硬链接自 store │ ├── axios1.7.7/ │ │ └── node_modules/ │ │ ├── axios/ │ │ └── follow-redirects - ../../follow-redirects1.15.9/node_modules/follow-redirects ├── lodash - .pnpm/lodash4.17.21/node_modules/lodash └── axios - .pnpm/axios1.7.7/node_modules/axios关键点在.pnpm/axios1.7.7/node_modules/这一层axios 自己的依赖 follow-redirects 放在它旁边的软链接而不是被提升到项目顶层。这样 axios 能找到 follow-redirects你的业务代码找不到除非你自己在 package.json 里声明。这正是依赖隔离的实现方式。在 Windows 上创建符号链接需要额外权限所以 pnpm 会用目录联接junction来替代硬链接则依赖 NTFS 原生支持一般不需要特殊处理。如果你在 Windows 上遇到EPERM: operation not permitted, symlink之类的报错八成是权限问题而不是 pnpm 本身的 bug。1.3 pnpm 与 npm、yarn 的关键差异对照对比维度npmyarn v1pnpmnode_modules 结构扁平提升扁平提升.pnpm嵌套 顶层软链接磁盘占用每个项目一份副本每个项目一份副本全局 store 硬链接多项目共享幽灵依赖常见常见默认禁止同一依赖多版本嵌套在子目录嵌套在子目录每个版本独立目录互不干扰Monorepo 支持workspacesworkspacesworkspaces过滤语法更强peer 依赖处理易冲突易警告自动按版本分组警告更精确安装速度冷装较慢较慢明显更快内置任务编排无无有-r、--filter需要说明的是yarn 的 Berry 版本v2也改成了类似 pnpm 的 PnP 或 node_modules 链接方案思路有重合。但在实际项目里用下来pnpm 的迁移成本更低——它没有放弃 node_modules绝大多数工具链不需要额外适配而 PnP 那一套在某些老项目上折腾起来相当费劲。2. workspace 配置的核心pnpm-workspace.yaml 怎么写才不出错2.1 最小可用配置与目录约定pnpm 识别 Monorepo 的唯一标志是仓库根目录存在pnpm-workspace.yaml。文件名不能改大小写也不能变。最小配置只有三行packages: - packages/* - apps/*packages字段是一个 glob 数组用来告诉 pnpm 哪些目录算工作区成员。几个容易踩的点packages/*只会匹配一层子目录如果你的包放在packages/group-a/foo这种两级结构里需要写成packages/**或者packages/*/*想排除某个目录用!前缀比如- !**/__tests__/**。目录结构上我一般这么分apps/放可独立部署的应用Web 前端、Node 服务、桌面端packages/放被复用的库组件库、工具函数、类型定义、配置包。这个划分不是强制的但它让谁会发布、谁只是消费者一目了然后面配 CI 和发布流程时会省很多事。根目录的package.json必须加上private: true这是硬性要求。没加的话 pnpm 在安装时会给出警告而且你会面临误发布根包的风险——根包里面通常只有一个脚本集合发到仓库里毫无意义。同时建议把 node 版本和包管理器版本钉死{ name: my-monorepo, private: true, packageManager: pnpm9.12.3, engines: { node: 18.18.0 } }packageManager字段配合 corepack 使用能让团队里所有人的 pnpm 版本严格一致。这一点在多包项目里比我预想的更重要——pnpm 大版本之间 lockfile 格式不同如果有人用 8 有人用 9CI 上会因为锁文件不一致反复失败。2.2 workspace 协议的用法与取舍Monorepo 里子包互相引用最忌讳写死版本号。你在apps/web的 package.json 里写scope/ui: 1.2.0本地开发时 pnpm 可能会去远端仓库拉这个版本而不是用你刚改完还没发版的本地代码。正确做法是用 workspace 协议{ dependencies: { scope/ui: workspace:*, scope/utils: workspace:^, scope/types: workspace:~ } }四种写法的区别值得说清楚workspace:*本地永远链接到工作区里的那个包发布时替换成精确版本1.2.0workspace:^发布时替换成^1.2.0允许消费方装到兼容的新版本workspace:~发布时替换成~1.2.0只允许补丁级更新workspace:1.2.0本地仍然链接发布时替换成指定的1.2.0我自己的习惯是内部工具包用workspace:*因为它们跟主应用是同一个发布节奏没必要留升级空间对外提供的 SDK 包用workspace:^给使用者一点弹性。这个地方不要图省事全用*等到要对外发布时再回头改成本比一开始想清楚高得多。还有一个细节workspace:协议的替换只发生在pnpm publish的时候pnpm pack也会触发。如果你用别的工具发布比如某些发布流水线自己调 npm publish替换不会发生包会被原样发出去安装方会直接报找不到workspace:*这个版本。这是真实发生过的线上事故。2.3 用 catalog 统一版本根治版本漂移规模一上来多包版本不一致几乎是必然的。十个包里有七个用 React 18.2两个用 18.3还有一个不知道为什么是 17.0你排查问题的时候要多花一倍时间。pnpm 9.5 之后引入了 catalog目录机制专门治这个病packages: - packages/* - apps/* catalog: react: ^18.3.1 react-dom: ^18.3.1 typescript: ~5.6.3 vitest: ^2.1.4然后各个子包这样引用{ devDependencies: { typescript: catalog:, vitest: catalog: }, dependencies: { react: catalog:, react-dom: catalog: } }想升级 React只改pnpm-workspace.yaml里的一行然后全仓pnpm install就完了。这比逐个包去改 package.json 可靠得多也不会漏。catalog 还支持具名分组比如把测试相关的放一组catalogs: testing: vitest: ^2.1.4 testing-library/react: ^16.0.1引用时写vitest: catalog:testing。这个功能在我们把 e2e 测试框架从 Cypress 换成 Playwright 的时候帮了大忙改动集中在一个文件里。需要注意的坑catalog 目前只在 pnpm 9.5 支持而且它不会自动改写已有的硬编码版本你是要手动把^18.2.0换成catalog:。建议一次性做完别新旧混用否则会出现同一个仓库里两套版本来源的情况更难维护。3. 从零搭一套可跑的 Monorepo完整实操流程3.1 初始化骨架与基础配置从空目录开始我一般这么走。先确认 pnpm 可用用 corepack 是最省事的路径它能跟着 node 版本走不用每个 node 版本单独装一遍corepack enable pnpm pnpm --version如果 corepack 在你的 node 发行版里没有捆绑近两年的版本确实有把它拆出去单独安装的趋势退回全局安装也可以npm install -g pnpm pnpm --version接着建目录mkdir my-monorepo cd my-monorepo pnpm init mkdir -p apps/web packages/ui packages/utils packages/tsconfig根pnpm-workspace.yamlpackages: - apps/* - packages/* catalog: typescript: ~5.6.3 react: ^18.3.1 react-dom: ^18.3.1根package.json里除了private和packageManager我会加一组统一脚本{ scripts: { build: pnpm -r --filter ./packages/** run build, typecheck: pnpm -r run typecheck, test: pnpm -r run test, dev: pnpm --filter scope/web run dev, clean: pnpm -r exec rm -rf dist node_modules/.cache } }这里的-r是 recursive表示递归到所有工作区包执行--filter ./packages/**把范围限死在库包上跳过应用。pnpm -r exec跟pnpm -r run的区别是前者直接执行命令不需要目标包在 package.json 里定义同名 script清理场景用它更合适。每个子包用pnpm init生成 package.json然后手动改name为scope/xxx形式。强烈建议所有内部包统一加 scope 前缀一是发布到私有仓库时不会跟公共包重名二是--filter按名字筛选时不会误伤三是看依赖树的时候一眼能分出哪些是自己人。3.2 依赖安装的三种作用域pnpm 在 workspace 里装依赖区分三种作用域用错会浪费不少时间# 装到根目录只适合 lint、prettier、husky 这类全仓工具 pnpm add -w -D eslint prettier # 装到指定包 pnpm add react --filter scope/ui pnpm add -D vitest --filter scope/utils # 装到所有包 pnpm add -D typescript -r第一个坑在根目录直接执行pnpm add lodash不加-wpnpm 会直接拒绝并报ERR_PNPM_ADDING_TO_ROOT。这是保护机制防止你的子包依赖被意外装到根上。看到这个报错不要以为命令坏了加上-w就行。第二个坑--filter匹配不到包时pnpm 默认静默跳过你还以为装成功了。加上--filter ... --filter-prod之类的组合前先用pnpm ls -r --depth -1把所有包名列出来确认一遍。我吃过一次亏scope/ui-kit写成了scope/uikit命令返回成功但什么都没装后面构建报错时才反应过来。第三个坑-w装的东西在 CI 里也可能被跳过。如果你配了pnpm install --prod根目录的 devDependencies 全都不装构建脚本就找不到了。CI 里安装依赖阶段一律不加--prod生产依赖的裁剪交给pnpm deploy或打包工具去处理。装完之后可以用这几个命令验证依赖树pnpm why lodash # 谁引入了 lodash pnpm why lodash --filter scope/ui # 只看某个包的引用链 pnpm ls -r --depth 0 # 列出所有包的直接依赖 pnpm list --depth Infinity # 完整树3.3 过滤语法与任务编排--filter是 pnpm 在 Monorepo 场景里最有价值的功能值得单独花时间记。它的语法比表面看起来丰富写法含义--filter scope/ui按包名精确匹配--filter ./packages/**按目录路径匹配--filter scope/*通配符匹配包名--filter ...scope/ui该包及其全部依赖--filter scope/ui...该包及所有依赖它的包--filter [origin/main]相比 origin/main 有改动的包--filter {packages/**}花括号表示匹配结果的并集最后那个基于 git 的过滤在 CI 里做增量构建非常好用。比如只构建改动过的包及其下游pnpm --filter ...[origin/main] run build默认情况下pnpm -r run build会按依赖拓扑排序utils一定在ui之前跑完ui又一定在web之前。这是自动推断的你不需要手动指定顺序。但如果某个脚本想并行跑比如所有包同时跑 typecheck互不依赖加--parallelpnpm -r --parallel run typecheck有个细节--parallel会忽略拓扑排序纯粹并发执行。用在构建上很可能出问题依赖还没产出就去读取了只适合无副作用的检查类任务。还有个--stream参数作用是把各子进程的输出实时透传而不是等全部跑完再一起打印。跑-r dev的时候基本必加否则你根本看不到哪个包的日志pnpm -r --parallel --stream run dev3.4 版本管理与发布Monorepo 发布最成熟的方案是 changesets。它的思路是每个改动者提交代码时一并写一份变更说明标明这次改动影响哪些包、是补丁还是小版本还是大版本。发布时把所有变更说明收集起来统一升版本、生成 CHANGELOG、推送仓库。pnpm add -w -D changesets/cli pnpm changeset init它会生成.changeset/config.json其中baseBranch要改成你的主分支名access按你的仓库类型设成public或restricted。日常流程pnpm changeset # 交互式选择受影响的包和变更等级生成 md 文件 pnpm changeset version # 消费这些 md改版本号、写 CHANGELOG pnpm changeset publish # 发布到仓库并打 tagchangeset version会自动处理workspace:协议的替代同时更新包之间的相互依赖版本。这里有个容易忽略的点如果scope/ui升了小版本而scope/web依赖workspace:^changesets 会把 web 的依赖范围也更新成新的版本号。但workspace:*这种精确匹配的情况下不会写进 web 的 package.json因为它本身就是链接关系。这两种行为都不是 bug是你选择协议写法时应该预期的结果。想跳过交互式流程也可以用pnpm publish -r直接发布所有版本号有变化的包但我不推荐在团队里这么干因为没有 CHANGELOG、没有变更说明出了问题很难回溯。4. 踩坑实录安装、命令识别、打包这些问题怎么排查4.1 pnpm 命令识别不了先看这四层pnpm 不是内部或外部命令也不是可运行的程序这个报错我见过太多次了原因基本逃不出四层按顺序查效率最高。第一层是不是真的没装。开个新终端跑npm ls -g --depth 0看列表里有没有 pnpm。没有就装。第二层装了但 PATH 没生效。npm 的全局依赖目录在 Windows 上通常是%APPDATA%\npmmacOS 和 Linux 上是$(npm config get prefix)/bin。这个目录必须在 PATH 里否则命令名认识不了。判断方法很简单命令行直接跑$(npm config get prefix)/bin/pnpm -v能出版本号就说明是 PATH 的问题修 PATH 就行。改完必须重开终端已经打开的终端不会自动读取新的环境变量。第三层nvm 切换 node 版本后 pnpm 消失了。这是最迷惑人的一种情况昨天还好好的今天nvm use 20之后 pnpm 就没了。原因是 nvm 的全局包目录按 node 版本分开存放你在 node 18 下装的 pnpm 在 node 20 下看不见。三个解决办法我按推荐度排# 方案一用 corepack跟着 node 走一次启用长期有效 corepack enable pnpm # 方案二每个 node 版本下都装一次 nvm use 20 npm i -g pnpm # 方案三用 volta 之类能跨 node 版本管理全局工具的方案 volta install pnpm方案一最省事。不过要注意某些较新的 node 发行版已经把 corepack 拆出去要单独安装如果corepack本身都提示找不到就先npm i -g corepack再执行corepack enable pnpm。第四层IDE 内置终端没继承系统环境。VS Code 和 JetBrains 系的终端偶尔会出现系统终端能用、IDE 里不能用的情况重启 IDE 基本能解决。如果还不行检查 IDE 的终端设置里有没有覆盖 shell 的环境变量。顺便说下删除pnpm的操作如果你确实要卸载重装先npm rm -g pnpm然后手动清掉缓存目录pnpm store path给出路径直接删掉那个目录最后删项目里的node_modules重新pnpm install。不清 store 的话重装后可能仍然读到旧的包元数据出现一些解释不了的状态。4.2 安装下载失败与镜像配置pnpm下载失败绝大多数是网络层面的事。最直接的应对是切镜像源pnpm config set registry https://registry.npmmirror.com pnpm config get registry # 确认生效改的是用户级配置~/.npmrc。如果你只想让当前项目用镜像在项目根目录建.npmrc写一行registryhttps://registry.npmmirror.com这样不会影响其他项目。团队协作时我倾向于用项目级配置因为能跟代码一起提交新人拉下来开箱即用。.npmrc里还能配几个对 Monorepo 很关键的项registryhttps://registry.npmmirror.com strict-peer-dependenciesfalse auto-install-peerstrue link-workspace-packagestrue prefer-workspace-packagestrueauto-install-peerstrue在 pnpm 8 之后已经默认开启显式写出来是为了兼容旧版本不至于因为某个人的 pnpm 版本旧而行为不一致。strict-peer-dependenciesfalse则是在引入某些对 peer 依赖声明不严谨的第三方包时避免安装直接中断。另一类下载失败跟网络无关是锁文件冲突ERR_PNPM_OUTDATED_LOCKFILE Cannot install with frozen-lockfile because pnpm-lock.yaml is not up to date出现这个说明package.json改过但pnpm-lock.yaml没同步本地跑一次pnpm install让它更新然后把锁文件一起提交。CI 上必须加--frozen-lockfile否则锁文件失效了也不报错构建出来的东西跟你本地不一样问题会更难查。还有一种报错是ERR_PNPM_UNEXPECTED_STORE或者 store 版本不匹配通常出现在 store 目录被别的 pnpm 大版本污染过之后。处理方式是清 storepnpm store prune rm -rf node_modules pnpm installpnpm store prune只删除没有被任何项目引用的包是安全的不会影响正在用的项目。4.3 electron 打包在 Monorepo 里的三个特殊处理electron 是 Monorepo 里最容易出问题的依赖因为它的安装流程和打包流程都跟普通库不一样。第一pnpm 10 之后默认不执行依赖的postinstall脚本而 electron 正是靠这个脚本下载二进制文件。表现是安装看起来成功了但node_modules/.pnpm/electronxx/node_modules/electron/dist目录不存在一跑就报找不到 electron。你需要显式批准pnpm approve-builds或者在pnpm-workspace.yaml里声明白名单onlyBuiltDependencies: - electron - esbuild - sharpsharp和esbuild也建议加进去它们同样依赖安装时的原生构建。加完之后重新pnpm install看到Ignoring build scripts之类的提示消失才算成功。第二二进制下载慢或者失败。electron 的二进制不走 npm registry是单独的下载地址所以要单独配镜像electron_mirrorhttps://npmmirror.com/mirrors/electron/ electron_builder_binaries_mirrorhttps://npmmirror.com/mirrors/electron-builder-binaries/第二行是给 electron-builder 用的不配的话打包阶段还是会去原地址拉文件然后卡住。第三electron-builder 扫描node_modules复制文件时遇到 pnpm 的软链接结构经常处理不干净出现打包出来的应用缺少某个依赖这类问题。我试过三种方案最后固定用第三种全局加shamefully-hoisttrue让node_modules变回扁平结构。有效但等于放弃了 pnpm 的隔离优势全仓都受影响不推荐。在.npmrc里配node-linkerhoisted。效果类似同样是全仓级别的妥协。用pnpm deploy把应用及其生产依赖抽到一个独立目录再对这个目录打包。这样主仓库保持严格隔离只有打包产物是扁平的。pnpm --filter scope/desktop deploy --prod ./dist-app抽出来的目录结构是标准的扁平node_moduleselectron-builder 处理起来毫无障碍。代价是多一步构建但在 CI 里也就几秒钟。4.4 常见问题速查表现象或报错大概率原因处理方式pnpm 不是内部或外部命令PATH 未生效 / nvm 切换后丢失检查 npm 全局 bin 目录或用 corepackERR_PNPM_ADDING_TO_ROOT根目录装依赖没加-w命令补上-wERR_PNPM_NO_IMPORTER_MANIFEST_FOUND当前目录没有 package.json确认执行位置或先pnpm initERR_PNPM_OUTDATED_LOCKFILE锁文件与 package.json 不同步本地pnpm install并提交锁文件ERR_PNPM_UNEXPECTED_STOREstore 与现有 node_modules 不匹配pnpm store prune后重装Ignoring build scriptspnpm 10 默认禁止依赖构建脚本配onlyBuiltDependencies或pnpm approve-buildselectron 安装后没有 dist 目录postinstall 未执行同上把 electron 加进白名单Cannot find module xxx幽灵依赖未在 package.json 声明显式声明或用public-hoist-pattern临时兜底创建链接报EPERMWindows 权限不足开启开发者模式或调整 store 所在盘安装日志出现cross-device linkstore 与项目不在同一文件系统用pnpm config set store-dir挪到同一盘5. 长期维护要盯住的几件事5.1 CI 缓存与锁文件策略CI 上 pnpm 的缓存策略很简单缓存pnpm store path输出的那个目录key 用pnpm-lock.yaml的哈希值。锁文件没变就命中缓存安装速度能从几十秒压到几秒。配套的安装命令我一般这么写pnpm install --frozen-lockfile --prefer-offline--frozen-lockfile保证锁文件不会被意外修改--prefer-offline让 pnpm 优先用缓存而不是去请求网络两个参数配合起来在 CI 环境里非常稳。还有一件事必须做团队里所有人用同一个 pnpm 大版本。根package.json里的packageManager字段只是声明真正强制生效需要 corepack 参与。如果 CI 环境不允许启用 corepack退而求其次的做法是在流水线第一步加个版本检查pnpm --version | grep -q ^9\. || (echo pnpm 版本不符 exit 1)看着有点土但确实拦住过好几次因为版本不一致导致的锁文件反复重写。5.2 幽灵依赖与 hoist 的取舍从 npm 迁到 pnpm 时最常见的拦路虎是各种工具隐式引用了没声明的包。典型代表是某些年份较老的构建工具它们内部require(webpack)或者require(postcss)但 package.json 里没写。在扁平化的 node_modules 下能跑在 pnpm 下直接报错。处理顺序建议这样先看报错指向哪个包能通过自己显式声明解决的就直接加进 package.json这是最干净的做法。确实是第三方包自身的问题用public-hoist-pattern精确提升public-hoist-pattern[]*eslint* public-hoist-pattern[]*prettier* public-hoist-pattern[]babel/*这些包会被提升到顶层node_modules等于回到 npm 的行为但范围可控。最后才是shamefully-hoisttrue这个核武器它会把所有依赖都提升等于完全放弃 pnpm 的隔离机制。我见过有人在项目第一天就加上这行然后抱怨 pnpm 没什么优势——那确实是没什么优势了。还有hoist-pattern和public-hoist-pattern的区别值得分清楚前者影响的是.pnpm/node_modules内部那个提升层只有依赖自己能看见后者会提升到项目顶层node_modules你的源码也能 require 到。要解决幽灵依赖用的是后者。5.3 团队协作上的几条硬规矩第一条仓库里只能有一个锁文件。.gitignore里明确写上package-lock.json、yarn.lock然后加个提交前的检查或者 CI 步骤一旦发现这两个文件存在就报错。我见过最混乱的情况是一个仓库里同时躺着pnpm-lock.yaml和yarn.lock两个人构建出来的依赖树不一样排查了一整天才发现问题。第二条node 版本要钉住。.nvmrc写版本号根package.json的engines写范围packageManager写 pnpm 版本。这三样凑齐了新人拉下来基本不会遇到环境问题。第三条内部包统一命名规范。scope/前缀 小写短横线命名跟公共包区分开。发布到公司私有仓库时publishConfig里单独配access和registry别跟公共仓库的配置混在一起。第四条日常开发用pnpm -r --parallel --stream run dev一把起所有包的 watch 模式比每个包开一个终端省事得多。前提是每个包的 dev 脚本都是 watch 模式支持增量编译否则会白白吃 CPU。我们项目里tsc --watch和vite build --watch都是这么跑的改一行公共组件三秒内主应用就热更新了。最后分享一个我用了很久的小习惯每周清一次 store。pnpm store prune会删掉所有没被引用的包跑一次通常能回收几个 G尤其是在频繁切换分支、频繁升级依赖的仓库里效果明显。这个动作不会有副作用放心执行。