Monorepo 这三个词,这几年在前端圈子里基本属于"每个团队早晚都会碰上的问题"。我见过太多的项目组,一开始老老实实拆仓库,每个前端项目一套 TypeScript 配置,版本漂移严重到离谱,后来被迫再折腾回单仓库。也有团队一上来就跟风上了 nx,结果后端的同事根本不用 Node,整个工具链白瞎。真正让我下定决心整理一份企业级 Monorepo 模板的,是去年一次跨项目重构:三个前端应用要共用一套类型定义和请求层,每个仓库各改各的,发布顺序错一个就雪崩。那段时间我翻遍了各类实践,最后沉淀出一个相对标准的工程化模板,目标是让初始化、应用创建、共享 TypeScript 配置这三件最基础也最关键的事,不再依赖某个人"记住了"来做。
这篇文章就把这套模板的搭建全过程拆开讲,包括工具链为什么选 pnpm + Turborepo + TypeScript Project References、目录怎么规划、应用怎么接进来、共享 tsconfig 怎么做到三层递进设计、以及我在升级 TypeScript 版本时踩到的一个大坑。适合想从零搭 Monorepo 的团队,也适合那些已经搭了但是总觉得"哪里不得劲"的人。
1. 为什么企业级前端团队最终都会走向 Monorepo
1.1 从多仓库到单仓库:那些年我们复制粘贴的配置文件
先聊一个反直觉的事:很多团队不敢上 Monorepo,不是因为技术难,而是因为"一个仓库放所有代码"这个想法让他们本能地觉得危险。但实际上,多仓库带来的问题远比想象的更烦。最典型的就是配置漂移。拿 TypeScript 配置来说,我在不同项目的 tsconfig.json 里见过 stimulus 的 strict 开关状态都不一致,有的开了 noUncheckedIndexedAccess,有的没开,有的甚至用了好几种不同的 target。每次改公共规范,都得一个一个仓库发 PR,漏一个就等着上线出问题。
这种"每个仓库一套配置"的模式还有一个隐藏代价:没法平滑地跨项目复用代码。你有一个工具函数放在 utils 仓库里,得先 npm publish 一个版本,然后再在业务项目里升级依赖。但如果你正在本地同时改这两个仓库,就得不停地在两个终端里切来切去、publish 临时版本、再进业务项目装回来。这种事我前前后后折腾了无数遍,每回都在心里骂自己为什么不早点上 Monorepo。
单仓库的好处不在于"代码放一起",而在于依赖关系在同一份代码库内可见、可追踪、可原子化修改。你在 utils 里改一个函数签名,可以连同所有使用方的改动一起提交在一个 commit 里,code review 的人一眼就能看到这个改动的完整影响范围。这种能力在跨项目改类型、改协议、升级基础库时极其有用。
1.2 Monorepo 的"鱼与熊掌"问题:如何平衡耦合与边界
当然,Monorepo 不是万能药。很多人的担忧也真实存在:所有代码放一起,会不会变成一个大泥球?各业务线的权限怎么隔离?CI 是不是会变得很慢?
这些担忧我在实际落地时都遇到了,最终得到的结论是:Monorepo 本身不制造混乱,混乱来自缺少边界。边界靠两样东西保证,一个是目录结构,一个是脚本级隔离。目录结构上,我习惯把应用和库分开,apps/放可独立部署的前端应用(管理后台、客户端站点、运营端),packages/放库代码(UI 组件库、工具函数库、类型定义、请求层、共享配置)。这样种子层的"谁是消费者、谁是被依赖方"非常清楚,也方便后续按目录做 CI 路径过滤。
权限隔离则靠另一个层面解决:比如所有核心库的发布走专门的发布流程和 code owner 审核,而不是每个人都去 npm 上手动 publish。只要这套边界设计出来,Monorepo 的协作体验会明显优于一堆互相低效引用的独立仓库。
2. 工具链三件套:pnpm Workspace + Turborepo + TypeScript Project References
2.1 pnpm 的依赖隔离:它是怎么干掉"幽灵依赖"的
说到 Monorepo 的工程化,第一个要定下来的就是包管理器。npm 和 yarn 的 workspace 也能用,但要说企业级的可靠性,我会直接选 pnpm。原因只有一个核心词:依赖隔离。
pnpm 的 node_modules 结构跟 npm 的最大区别在于它用的是符号链接加全局内容寻址存储。每个项目里能 require 到的依赖,都是它在pnpm-workspace.yaml里声明过的,而那些"间接依赖"不会天然地暴露给你的应用代码。这就从根源上解决了"幽灵依赖"问题。我见过不少用 npm workspace 的项目,代码里直接import了某个 package.json 里根本没写的包,因为它是另一个依赖传递进来的,升级一个版本就全线崩溃。这种问题在 pnpm 下会直接报解析失败,倒逼你显式声明依赖,从源头保护了项目的健康度。
此外 pnpm 还提供了--filter命令体系,可以精确地操作某个包或者某一组包。比如pnpm --filter @myrepo/web add axios是只给 web 应用装依赖,pnpm -r run lint是对所有子包跑 lint。这套心智模型在大型 monorepo 里非常重要,命令行就能表达"我想对谁做什么"。
2.2 Turborepo 任务编排:为什么不用 Nx 也不手写一堆脚本
决定用 pnpm workspace 之后,接下来就是任务编排层。pnpm 自带的-r能按依赖拓扑顺序执行所有子包的命令,但企业级场景下不够,因为我们需要增量构建、结果缓存、以及"依赖关系变化后只重跑受影响部分"的能力。
Turborepo 就是干这个的。它做的事情简单说就是:记录每个 script 的输入文件内容哈希,如果这个包和它依赖的包对应的输入没有变化,就直接复用上一次的缓存产物,而不去真的重跑。在多个应用、多个库的 monorepo 里,这套机制能把 CI 构建时间从十几分钟压到几十秒。
有人会问,为什么不直接上 Nx?Nx 的能力是很强大,但它默认带一套比较重的插件体系和自己的生成器生态,对于只是想要"前端应用加几个共享库"这种中大型项目,Turborepo 的心智负担明显更小。Turborepo 本身非常克制,它不强加你用什么框架、什么目录结构,只提供"任务编排 + 缓存"这两个核心能力。配合 pnpm workspace 和普通的 package.json scripts,已经能达到企业级要求的稳定和速度。
2.3 TypeScript Project References:这是共享配置能够真正落地的底层机制
第三件套是 TypeScript 的 Project References(项目引用)。很多人对它的理解只停留在"能够在 tsconfig 里用references引用另一个包",但它在 monorepo 里真正的威力是构建时的依赖顺序控制。
当一个项目引用了另一个项目,TypeScript 在tsc --build模式下会先确保被引用项目已经构建过,然后通过.d.ts文件来理解它的导出类型,而不是去读源码。这在类型层面给 monorepo 划出了一条清晰的依赖边界:你改了一个库的源码,构建时它会先产出新的声明文件,然后再让依赖方基于新声明做类型检查。这样类型信息和运行时信息、构建顺序彻底对齐,不会再出现"明明源码改了,另一个项目却还在用旧类型"的诡异情况。
Project References 还带来一个额外好处:类型检查可以被增量化了。传统的单 tsconfig 在大型 monorepo 里会越来越慢,因为每回 VSCode 或者 tsc 都要加载所有源码。拆成多个 reference 之后,理论上每个项目只需要看它自己的源码和依赖方产出的 d.ts,感知范围大幅缩小。这个性能收益在高版本 TypeScript 的独立声明文件模式下更加明显。
3. 从零初始化:目录规划、根配置与第一个 package
3.1 目录结构怎么定:apps 和 packages 的边界划分
我推荐一个项目模板目录,结构如下:
my-monorepo/ ├── apps/ │ ├── web/ # 主站应用(React + Vite) │ └── admin/ # 管理后台应用 ├── packages/ │ ├── ui/ # 基础 UI 组件库 │ ├── utils/ # 纯工具函数库 │ ├── types/ # 全局类型定义 │ ├── config/ # 共享配置文件包 │ │ ├── tsconfig.base.json │ │ ├── tsconfig.react.json │ │ ├── eslint-preset.js │ │ └── prettier.config.js │ └── shared/ # 业务共享模块(登录态、请求层等) ├── .github/ │ └── workflows/ ├── .npmrc ├── package.json ├── pnpm-workspace.yaml ├── turbo.json ├── tsconfig.json └── README.mdapps/目录下的每个项目都有自己的依赖闭环,packages/目录下每个包高度内聚、独立发布。关键点在于包名的粒度,我建议遵循"包越小越好、依赖层级越浅越好"。不要把业务逻辑堆进一个大而全的shared包,否则它又变成了一个微型 monorepo,所有应用都依赖它、改一次全量重建,和单仓库的初衷背道而驰。
3.2 根 package.json 与 pnpm-workspace.yaml 的核心写法
先初始化 Git 仓库,然后写根package.json。它不承载任何业务依赖,只放一些全局脚本:
{ "name": "my-monorepo", "private": true, "packageManager": "pnpm@9.12.0", "engines": { "node": ">=20.0.0", "pnpm": ">=9.0.0" }, "scripts": { "dev": "turbo run dev", "build": "turbo run build", "lint": "turbo run lint", "typecheck": "turbo run typecheck", "test": "turbo run test", "clean": "turbo run clean", "changeset": "changeset", "version-packages": "changeset version", "release": "pnpm build && changeset publish" }, "devDependencies": { "@changesets/cli": "^2.27.8", "turbo": "^2.1.2", "typescript": "^5.6.3" } }注意packageManager字段,它配合 corepack 使用,能保证团队所有人在本地装依赖时都启用同一版本的 pnpm,避免"我本地好好的你为什么不行"这类问题。
然后是pnpm-workspace.yaml,这是所有 workspace 包的"户口本":
packages: - "apps/*" - "packages/*"这个文件指定了哪些目录属于 workspace 包。如果以后想加一个放 e2e 测试的目录,也得把这个目录加进去。
.npmrc里建议加几行,防止一些隐性问题:
shamefully-hoist=false strict-peer-dependencies=true auto-install-peers=trueshamefully-hoist=false可以理解为"不要把所有依赖都提升到顶层 node_modules",这是 pnpm 隔离依赖语义的基础。
3.3 版本管理与变更集:给企业级模板加一个发布控制阀
作为企业级模板,发布控制必须从第一天就设计进来,这里我选择 Changesets。它的核心机制是开发者改完代码后,跑一句pnpm changeset,回答三个问题(这个包是什么类型变更?影响哪些包?一句话描述?),然后会自动生成一个 markdown 变更集文件。到发版本的日子统一跑pnpm version-packages和pnpm release,它会自动更新所有受影响包的版本号,并生成 CHANGELOG。
这套机制最大的价值在于:它强迫你为每次变更记录影响范围,并且让发布流程变成可审计的。在小团队里你可以说"我改完直接 publish 就行了",但一旦有多个团队十几个人同时在一个仓库里提交代码,如果没有这种控制阀,发布基本靠喊。
4. 创建第一个应用:React + Vite 的完整接入过程
4.1 手写配置 vs 脚手架工具:我为什么建议先跑一次脚手架再对照改
标题里说要创建应用,有人会问:直接pnpm create vite生成一个应用姿势不是更标准吗?我的建议是:可以用官方的 create-vite 脚手架生成基础骨架当参考,但最终要把它改造成符合 monorepo 规范的形态。因为脚手架默认生成的 vite.config.ts 和 tsconfig 针对的是"独立应用"场景,它不知道 monorepo 里还有 workspace 依赖这回事,也不理解 Project References。
我更推荐的做法是这样的:先在apps/web下自己手工写一版配置。这个过程不用很久,但能让所有模块的职责一目了然。等以后团队里来了新人,他打开模板看到的不是一堆黑魔法,而是每一行都能说清"为什么要存在"的配置,这就是企业级模板该有的样子。
4.2 应用入口、Workspace 引用与本地开发的打通
先创建应用目录,初始化包信息:
// apps/web/package.json { "name": "@myrepo/web", "version": "0.1.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint . --max-warnings=0", "typecheck": "tsc -b --noEmit" }, "dependencies": { "@myrepo/shared": "workspace:*", "@myrepo/ui": "workspace:*", "@myrepo/utils": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1" }, "devDependencies": { "@myrepo/config": "workspace:*", "@types/react": "^18.3.10", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.2", "typescript": "^5.6.3", "vite": "^5.4.8" } }这里最关键的是"workspace:*"这个版本号写法。它表示"这个依赖不指向 npm registry,而是直接链接到当前工作区里的包"。*代表始终使用当前工作区版本,不需要手写具体版本号。这样本地开发时,你改了packages/shared的代码,应用里的效果是即时生效的,不会再出现"本地 npm link 半天,最后发现根本没链上"的惨剧。
Vite 配置里不需要太多特殊处理,因为 Vite 本身就能正确处理 pnpm workspace 下的源码依赖。但要注意一个坑:如果你在 monorepo 里用了 npm 的 workspace 或有复杂的 alias 需求,就得手动维护 resolve.alias。pnpm + Vite 的组合在默认状态下反而很省心。
应用级 tsconfig 是接入 Project References 的关键位置:
// apps/web/tsconfig.json { "extends": "@myrepo/config/tsconfig.react.json", "compilerOptions": { "baseUrl": "." }, "references": [ { "path": "../../packages/shared" }, { "path": "../../packages/ui" }, { "path": "../../packages/utils" } ], "include": ["src", "vite.config.ts"] }references字段告诉 TypeScript:本项目依赖这几个包,在tsc -b构建模式时,先构建那些依赖包,再执行本项目的检查和产物生成。这是整个 monorepo 能正确连通的"大脑指挥层"。
5. 共享 TypeScript 配置的三层设计:不再 Ctrl+C 配置
5.1 第一层:基础 tsconfig.base.json 的字段设计
共享配置是标题里的核心,也是最能体现工程化水平的地方。在多个应用的团队里,如果每个项目都从零写一遍 tsconfig,迟早会漂移成各种版本。所以先设计一个基础配置packages/config/tsconfig.base.json,它负责定义所有项目通用的编译基线和代码质量底线。
我提供一个可以直接参考的版本:
{ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "useDefineForClassFields": true, "noEmit": true, "declaration": false } }这套配置里值得解释几个容易被忽略的字段。moduleResolution: "Bundler"是给 Vite 这类打包工具准备的,它允许你使用package.json的exports字段做子路径导入,同时不需要强制加文件扩展名,跟 ESM 的 Node 原生解析保持了一个折中的平衡。noUncheckedIndexedAccess是一个折磨人但极有价值的开关,它会让数组下标访问返回T | undefined,强迫你处理边界情况。这在业务代码里一开始会觉得烦,但一旦上线,它会帮你挡掉不知道多少潜在的运行时错误。isolatedModules则是为了让 TypeScript 和 Vite 的按需编译机制保持一致,避免出现单文件编译时的导出问题。
5.2 第二层:继承与覆盖的拆分策略
基础配置之上,需要拆分几个场景化配置,因为应用和库的构建需求完全不一样。应用不需要产出.d.ts和 JS,代码最终由 Vite 打包;库则必须输出声明文件,否则没法被其他包引用。如果一锅烩,要么应用配置太"重"、要么库配置太"虚"。
我通常拆成三份:
tsconfig.base.json:公共基础,所有包继承。tsconfig.react.json:React 应用的场景配置,通常用于apps/*。tsconfig.lib.json:库的场景配置,通常用于packages/*。
tsconfig.react.json的大致样子:
{ "extends": "./tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": ["vite/client"] } }tsconfig.lib.json则是另一个方向:
{ "extends": "./tsconfig.base.json", "compilerOptions": { "composite": true, "declaration": true, "declarationMap": true, "emitDeclarationOnly": true, "outDir": "dist", "rootDir": "src" } }注意composite这个字段:被 Project References 引用的包必须开启它,这要求所有源码文件必须被include覆盖,同时会强制开启declaration。配合emitDeclarationOnly,我们不需要真的编译出 JS 代码,因为使用方的构建工具(Vite)会直接消费packages/*的 TS 源码,类型检查只依赖这个库构建出来的.d.ts。这种"运行时靠 Vite 处理源码、类型检查靠 d.ts"的设计,是 Monorepo 里性能和正确性兼顾的经典做法。
5.3 第三层:Project References 联动与构建顺序
第三层设计就是让 Project References 真正工作起来。每个被引用的packages/*/tsconfig.json都要继承tsconfig.lib.json并配置好自己的references和include。以一个工具库包为例:
// packages/utils/tsconfig.json { "extends": "@myrepo/config/tsconfig.lib.json", "compilerOptions": { "rootDir": "src", "outDir": "dist" }, "include": ["src"], "references": [ { "path": "../types" } ] }当apps/web的 build 命令执行tsc -b时,TypeScript 会先从最底层的packages/types开始构建,然后是packages/utils,最后才是应用本身。整个依赖图是 DAG(有向无环图),方向清晰、顺序稳定。这也是为什么我在第 2 节说 Project References 是"共享配置真正落地的底层机制",因为它把 tsconfig 从一个简单的"编译参数集合"升级成为"依赖编排系统"。
5.4 容易漏的一个细节:composite 与 noEmit 的冲突
在实际落地时,很多人会在这里卡住:库配置里开了composite,而基础的tsconfig.base.json里又写了"noEmit": true,结果tsc -b直接报错,提示 composite 项目不允许开 noEmit。这是因为 composite 项目必须能产出.d.ts文件,noEmit 等于把产出去掉了,两者天然矛盾。
解决方案有两种。第一种是把noEmit从 base 配置里拿掉,在应用配置里单独开;第二种(我用的是这个)是在库配置里写"emitDeclarationOnly": true,这样虽然没有 JS 产物,但声明文件会正常产出,满足 composite 的要求。应用配置则继续保留noEmit: true,因为应用只需要类型检查,真正的打包交给 Vite。这种"分场景配置"的灵活性,正是共享配置拆成多层的价值所在。
6. 从开发到构建:Turborepo 的任务编排与缓存命中优化
6.1 turbo.json 的 pipeline 配置解析
有了 workspace 里那些包之后,根目录的turbo.json就是所有脚本的"调度中心"。先看一个完整的例子:
{ "$schema": "https://turbo.build/schema.json", "tasks": { "dev": { "cache": false, "persistent": true }, "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"] }, "lint": { "dependsOn": ["^lint"] }, "typecheck": { "dependsOn": ["^typecheck"] }, "test": { "dependsOn": ["^build"] }, "clean": { "cache": false } } }dependsOn里的^build表示"先跑依赖项的 build,再跑本任务的 build"。这个^前缀是 Turborepo 对依赖拓扑的语法糖,它会自动从 workspace 依赖关系里推断顺序。outputs用来声明缓存的产物目录,cache: false用来告诉 Turborepo 哪些任务不适合缓存,比如持续运行的 dev server 或者每次都要从干净状态跑的 clean。
6.2 缓存命中的条件和常见失效原因
Turborepo 的缓存是基于文件内容哈希的,只要任务的输入文件哈希没变化,就直接取缓存结果,跳过重跑。这里的"输入"包括什么?包括该包内的源码文件、它所依赖的包的产物(d.ts)、环境变量(通过 env 字段声明)、以及传给任务的参数。我把缓存命中率从低到高的关键操作列一下:
第一,确保应用的源码目录没有垃圾文件。比如有些工具会在 src 下生成临时的 graphql schema 文件,这些文件一变动会让整个包的哈希变化,导致缓存失效。最好通过.gitignore和工具的忽略规则把这类文件排除在 Turborepo 的扫描范围之外。
第二,给环境变量显式注册。很多人写的构建脚本里直接读process.env.API_BASE_URL,但没告诉 Turbo 这个变量参与缓存计算,结果不同环境下命中了同一个缓存,线上 API 地址就不对。在 turbo.json 里应该这样声明:
"build": { "dependsOn": ["^build"], "env": ["API_BASE_URL", "NODE_ENV"], "outputs": ["dist/**"] }第三,注意 outputs 不能包含绝对路径和随机的产物目录。如果 Vite 的 outDir 是dist --mode拼接出来的,每次路径不一样,Turbo 找不到之前缓存的产物,也会失效。
在实际项目里,做到以上三点后,本地开发增量构建的体验会有质的提升。几十个包的仓库,改动一个工具函数,通常只有受影响的应用会重跑,其余直接命中缓存,完成时间从分钟级降到秒级。
7. 踩坑实录:TS 版本升级与 baseUrl 弃用带来的配置迁移
7.1 现象:升级 TypeScript 后编译终端持续告警
我在准备这套模板时,正好碰到 TypeScript 版本从 5.4 升到最新的 5.6。升完的时候,一切看起来正常,直到有一次在 CI 里跑类型检查,发现终端里刷出了一条告警:
Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption 'paths' instead.翻译过来就是:你配置里的baseUrl被弃用了,等到 TypeScript 7.0 会直接失效,请改用paths来表达同样的语义。当时心里一紧,因为这通常是大量旧配置里都会踩到的点。很多人在 tsconfig 里写"baseUrl": "."的本意,只是一种"让 imports 更简洁"的习惯,但很少有人研究它真正的运行时语义。
7.2 排查链路:从报错信息反推配置根因
我当时的排查链路大概是这样:先在项目里搜baseUrl这个字段,逐个文件确认它的使用场景。结果发现,大多数情况下大家只是为了能写import xxx from '@/utils/xxx'这种 alias 导入,而@的解析依赖paths配置;在 5.x 以前,paths又必须配合baseUrl来解析相对路径。所以大家就习惯了"两个字段一起写"。
到了 TypeScript 5.x,官方已经支持在paths里直接使用相对路径(相对于 tsconfig.json 所在目录),所以baseUrl已经不再是必需项。官方为了迁移平滑,才先给出 deprecation 告警,留到 7.0 再移除。顺着这个思路,修复方案就很明确了:
- 删除
"baseUrl": "."; - 把
paths里的"@/*": ["src/*"]改成"@/*": ["./src/*"]。
对于有别名需求的包,统一改成./开头,用相对 tsconfig 文件所在目录的写法。这样既保留了 alias 的便利性,又完全绕开了baseUrl。
我特意在packages/config里写了一个校验脚本,用tsc -p的方式在 CI 里检查所有子项目,一旦发现baseUrl就会 fail。这算是一个"防御性工程"思路:既然 TypeScript 7.0 会移除它,那不如现在就从模板层面把它剔除干净。
7.3 顺便讲清楚:paths 与 exports 的关系
这个问题顺带引出一个更底层的知识:paths是给 TypeScript 做类型解析时用的,它只存在于开发期和类型检查期;真正到运行时,模块能不能被正确解析,靠的是package.json的exports字段和打包器的 alias 配置。所以你的 vite.config.ts 里如果没有配 alias,而 tsconfig 里只是用paths把@指过去了,类型检查和 Vite 实际打包的结果可能不一致。这也是 Monorepo 里大家容易踩的"类型检查过了、运行时报错"的一类典型问题。
在 Monorepo 里,如果包与包之间都用包名直接引用(比如@myrepo/utils),同时又严格遵守 exports 字段,那么日常业务代码里其实不太需要配置一堆 paths 别名。尽可能用 workspace 包依赖,而不是用 paths 跳来跳去,这样依赖关系能被 Turborepo 和 TypeScript Project References 完整识别到,缓存和增量构建才能发挥最大价值。
8. 依赖管理与规范落地:让企业级模板"活"起来
8.1 workspace:* 协议的语义与使用禁忌
前面提到了"workspace:*"这个版本写法,这里是它更完整的语义说明。pnpm 安装依赖时,遇到workspace:*,它不会去 registry 拉包,而是直接把现有 workspace 里的包做一个符号链接。workspace:*里的*表示"使用工作区里的那个版本,不管它叫什么版本号"。
但要注意一个反直觉的点:workspace:*不能直接用于发布到 npm 的包。如果你有一个库包@myrepo/ui要发布,而它的 dependencies 里引用了@myrepo/utils且写的是workspace:*,npm publish 的时候会报错,因为 npm registry 上根本没有workspace:*这个版本。正确的做法是:在发布流程里,用 pnpm 的 publish 命令自动把workspace:*替换成具体的版本号(比如0.1.0)。Changesets 的 publish 流程默认做了这个替换,这也是为什么我在根规范里加了 changeset 的原因。
8.2 依赖去重与上提:pnpm 的 hoist 策略
企业级 Monorepo 里依赖数量通常很大,装包速度和 node_modules 体积都是需要关注的。pnpm 默认的隔离策略本身已经比 npm 节省了大量磁盘空间,因为它采用全局 store 的内容寻址,同一个版本的依赖只存一份物理内容。
但在某些情况下还是会出现"同一个包在多个 workspace 子包里各装一份不同版本"的问题,比如react在一处是 18.3.1,在另一处是 18.2.0。这不仅是磁盘浪费,还会引发"同一份 hook 状态在两个 React 实例间互相不认"这种硬 bug。要解决这个,需要定期用pnpm why react查看统一性,更彻底的办法是配置pnpm.overrides强制统一版本。
.npmrc里的shamefully-hoist=false是一个很关键的设置。默认 pnpm 不会把依赖提升到所有包都能看见的顶层,这会逼你显式声明每个包自己的依赖。刚开始团队可能会不习惯,觉得"删个 node_modules 再装就报 module not found",但坚持两周,代码的依赖声明会变得非常规范,互相之间"隐性依赖"几乎绝迹。
8.3 CI 环境下的缓存复用与增量校验
最后聊一下 CI。企业级工程化模板落地的最后一公里就是流水线,不然本地一切顺利、CI 一切重跑,等于白搭。基于 Turborepo,我会在 CI 里做这么几件事:
第一步,配置远程缓存。Turborepo 支持 remote cache,可以用自建的 Turborepo Remote Cache Server,也可以用 Vercel 的官方服务。原理是把缓存产物按内容哈希上传到远端,这样每个 PR 都能复用上次成功构建的缓存。团队越多、子包越多,这个收益越明显。
第二步,实现路径过滤。我一般会把 CI 拆成两条几乎并行的分支:只改了packages/*就跑所有相关应用的构建;只改了apps/web就只跑 web 的构建。Turborepo 2.x 的--filter配合--affected(或者手动算 changed list)可以直接实现"只构建受影响项目"。
第三步,把tsc -b && turbo lint && turbo test挂进 pre-merge 的 pipeline。注意这里不要重复跑构建,避免浪费。在turbo.json里,test 任务通常依赖^build,这样顺序上会先构建被依赖的库包,再执行测试。
这里贴一份简化的 CI workflow 片段,可以直接参考:
name: CI on: pull_request: types: [opened, synchronize] jobs: ci: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup pnpm uses: pnpm/action-setup@v4 with: version: 9.12.0 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 cache: "pnpm" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Lint and typecheck run: | pnpm lint pnpm typecheck - name: Build affected packages run: pnpm turbo run build --affectedpnpm install --frozen-lockfile会严格按照pnpm-lock.yaml安装,避免因为 lockfile 漂移导致的不可复现问题。缓存 key 由锁文件内容和 Turbo 哈希共同决定,这样既能让 pnpm 层面对 node_modules 做缓存,又能复用 Turborepo 层的构建缓存,CI 整体速度会有明显改善。
最后再分享一个经验
从我的实际情况来看,Monorepo 工程化模板最容易被低估的不是技术选型,而是"配置收敛"。那些看起来不起眼的共享 tsconfig、统一的工具链版本、显式声明的依赖关系,才是真正让团队在跨项目协作时不打架的根本。如果你也想搭一套,我的体会是:不要一上来就塞进来一堆全家桶插件,先把 pnpm workspace、Turborepo、共享 tsconfig 和 Project References 这四件事彻底跑通,让一个真实应用加两个库的指挥链路顺顺当当走完,再考虑扩展 E2E、Storybook 这些周边能力。这套模板我目前维护了半年多,团队在新增项目时基本上只做两件事:复制目录结构、改 package.json 名称。这也正是我最初希望达到的效果——让工程化变成基础设施,而不是某个人的经验。