这篇文章想聊聊我最近做的一件比较“磨刀”的事——把散落在各个仓库里的前端工程统一收敛到一个 Monorepo 工程化模板里。起因其实很简单:团队里新项目越开越多,但每次初始化都要重新搭一遍 TypeScript 编译、代码规范、构建脚本,配置还经常互相打架。折腾了几次之后,我决定直接用 pnpm workspace + Turborepo 搭一套企业级的 Monorepo 模板,把初始化、应用创建和共享 TypeScript 配置一次性固化下来。
如果你也在纠结要不要上 Monorepo,或者已经决定上车但不知道第一步怎么落地,这篇文章应该能帮你省掉不少查文档的时间。我会从零开始,把整个模板的搭建过程、关键配置、踩过的坑都捋一遍。内容不追求大而全,重点集中在三条主线上:怎么初始化一个干净的 workspace、怎么创建一个可运行的应用子包、怎么把 TypeScript 配置做成真正好用的共享层。最后还会附上我实际遇到的高频问题和排查方法,算是给同样在折腾的人一份速查手册。
1. 整体设计与选型思路
1.1 为什么非得是 Monorepo:背景与痛点
先说痛点。以前团队同时维护四五个前端项目,每个项目都是独立仓库,各自管理依赖、各自配置构建。表面上互不干扰,实际上麻烦得很:公共组件库升级一个版本,要在每个项目里手动改包号、跑测试、重新发布;新员工入职光搭环境就要半天;TypeScript 版本、ESLint 规则在不同仓库里悄悄漂移,最后出现“本地好好的, CI 挂了”“A 项目能编译,B 项目报错”这种玄学问题。
Monorepo 的核心价值不是把代码放一起,而是把“版本、依赖、配置、构建流程”放进同一个约束体系。所有子包共享一套依赖策略和脚本规范,跨项目改代码变成一次 PR 的事,公共包升级再也不用逐个仓库操作。同时,得益于 pnpm 的硬链接和内容寻址存储,多个项目共用的依赖只在全局仓库里存一份,磁盘占用大幅下降,安装速度也快很多。对于企业内同时维护多个中后台应用、组件库、工具库的团队,这个收益非常明显。
1.2 工具链选型:为什么是 pnpm + Turborepo
Monorepo 的工具有很多,npm/yarn/pnpm workspace、Lerna、Nx、Turborepo、Rush 都有各自拥趸。我用过一段时间 Lerna,也试过纯 npm workspace,最终把组合定为 pnpm + Turborepo,理由有三个。
第一,pnpm 的 workspace 协议设计得非常干净。子包之间的依赖可以用workspace:^来声明版本,发布时 pnpm 会自动替换成实际版本号,既满足本地开发时的链接引用,又不会污染发布后的依赖声明。相比 npm workspace 在链接处理上的各种边缘情况,pnpm 的行为更可预期。
第二,Turborepo 的任务编排和缓存机制确实好用。它不需要像 Nx 那样引入插件化生态,自定义程度很高。我们只需要在turbo.json里声明任务的依赖关系(比如 build 之前要先 build 依赖包),Turborepo 会自动按拓扑顺序执行,并用内容哈希做缓存。改了 A 包代码,只有依赖 A 的包才会重新构建,其余全部命中缓存,CI 时间能压到原来的三分之一。
第三,学习成本适中。团队成员大多熟悉 pnpm 的基础操作,Turborepo 的配置项也很少,基本看一遍官方示例就能上手,不需要像 Nx 那样花时间理解它的插件体系。
选型对比可以直接看这张表:
| 方案 | 包管理 | 任务编排 | 缓存/远程缓存 | 学习成本 | 适合场景 |
|---|---|---|---|---|---|
| pnpm + Turborepo | pnpm workspace | Turborepo | 支持,配置简单 | 中 | 大多数前端/Node全栈团队 |
| npm + Lerna | npm workspace | Lerna | 弱,依赖自定义脚本 | 低 | 老项目迁移,已深度绑定 Lerna |
| yarn workspace + Nx | yarn | Nx | 支持,能力强 | 高 | 大型多团队仓库,需要丰富插件 |
| Rush | pnpm/npm/yarn | Rush | 支持,偏重量级 | 高 | 超大仓库,基础设施团队运维充足 |
1.3 目录结构设计与包划分逻辑
初始化之前先把目录结构定好,后面能省很多事。我采用的是一套比较常见的分层结构:
monorepo-starter/ ├── apps/ # 可独立部署的应用 │ └── web/ # 示例 React 应用 ├── packages/ # 共享库与配置 │ ├── ui/ # 组件库 │ ├── utils/ # 纯函数工具库 │ └── config/ # 共享 TypeScript / ESLint 配置 ├── .eslintrc.cjs ├── .prettierrc ├── .npmrc ├── package.json ├── pnpm-workspace.yaml ├── turbo.json ├── tsconfig.base.json └── tsconfig.web.jsonapps 放最终产物,packages 里放被多个 app 依赖的库和配置。这里有个容易犯迷糊的地方:packages/config本身不是一个会发布到业务项目的包,它只在仓库内部被引用,因此不需要src目录,只需要把各类配置文件和类型定义放进去即可。而packages/utils、packages/ui这类是真正的业务包,需要有自己的src、package.json和构建产物。
这样的分层对后续扩展也很友好。新增一个 Node 服务,只需在apps下加一个api目录;新增一个跨项目使用的请求库,只需在packages下加一个包,改动都是局部性的,不影响全局配置。
2. 从零初始化 Monorepo 工程骨架
2.1 环境准备与 workspace 声明
动手之前先确认环境:Node.js 版本建议 18 以上,pnpm 版本 8 以上。如果你用的 Node 还是 16,建议顺手升一下,因为后面很多东西(比如较新的 Vite、TS 5.x)已经不再兼容旧版本了。
初始化时我习惯先建一个空目录,然后手动创建基础文件,不用pnpm init自动生成的默认配置,因为默认配置往往带着一堆用不到的字段,还得再改一遍。
第一步是创建pnpm-workspace.yaml:
packages: - "apps/*" - "packages/*"这行配置就是 pnpm 识别 Monorepo 子包的依据。之后在apps和packages下新建的每个带package.json的目录,都会自动被 pnpm 纳入 workspace 管理。
然后创建根目录的package.json。这里有个容易踩的坑:不要把根包设置成private: false,也尽量不要让它承载实际的业务依赖。根包只负责统一工具链和各子包的编排脚本。我的根配置长这样:
{ "name": "monorepo-starter", "private": true, "scripts": { "dev": "turbo run dev", "build": "turbo run build", "typecheck": "turbo run typecheck", "lint": "turbo run lint", "format": "prettier --write .", "changeset": "changeset" }, "devDependencies": { "turbo": "^2.3.0", "prettier": "^3.3.0" }, "packageManager": "pnpm@9.12.0" }packageManager字段建议显式声明,这样能避免不同成员本地 pnpm 版本不一致导致的锁文件冲突。我在实际项目中见过好多次因为 pnpm 版本不一致,pnpm-lock.yaml反复变动的情况,加了这个字段后用 Corepack 就能自动切到指定版本。
2.2 turbo.json:从任务编排到缓存命中
Turborepo 的配置是整个工程的脉络,掌控着所有脚本的调用顺序和缓存策略。我用的是 2.x 版本的字段写法,任务统一定义在tasks下:
{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "dev": { "cache": false, "persistent": true }, "typecheck": { "dependsOn": ["^typecheck"] }, "lint": { "dependsOn": ["^lint"] } } }dependsOn里的^build表示“先构建依赖包,再构建当前包”。这是 Monorepo 里非常重要的拓扑关系,它保证了apps/web跑 build 之前,packages/ui和packages/utils一定已经构建过。如果你不配置这项,构建顺序就完全不可控,轻则报模块找不到,重则把旧产物打进包里。
outputs字段告诉 Turborepo 哪些是构建产物。它只缓存这个目录下的内容,命中缓存时直接恢复文件,不会再执行打包脚本。dev任务我设置了不缓存且持久运行,因为开发服务器一般要一直挂着,缓存没有意义。
2.3 安装依赖与锁文件管理
配置好这些文件之后,在根目录执行pnpm install。此时 pnpm 会根据 workspace 声明扫描所有子包,生成一个统一的pnpm-lock.yaml。这个锁文件一定要提交到 Git,它是整个仓库依赖唯一性的保证。
之后添加依赖时,建议养成用--filter指定子包的习惯。比如给apps/web安装 React:
pnpm --filter web add react react-dom给packages/utils安装 lodash-es:
pnpm --filter @repo/utils add lodash-es注意,这里--filter后面跟的是子包package.json里的name,不是目录名。我一开始经常写错,把目录名当包名用,结果 pnpm 直接报错。包名建议用@repo/这样的私有 scope 前缀,既清晰又能避免未来发布到公共 npm 时和现有包冲突。
3. 共享 TypeScript 配置的落地细节
3.1 拆分基础配置:base / web / node 三层
TypeScript 配置是 Monorepo 里最容易让人头大的部分,因为项目里同时存在浏览器端代码、Node 端代码、纯工具库代码,它们对模块解析、lib、target 的要求都不一样。如果所有包共用一份 tsconfig,很快就会出现各种诡异的编译错误。
我最终拆成了三层:
tsconfig.base.json:所有包共享的基础配置,包括模块解析策略、严格模式、增量编译、路径别名等。tsconfig.web.json:继承 base,面向浏览器端应用,lib包含 DOM。tsconfig.node.json:继承 base,面向 Node 端代码,module采用 CommonJS 或 NodeNext 视情况而定。
原理解释一下:TypeScript 的extends字段支持继承配置文件,子配置会覆盖父配置的同名选项。这个机制正好适合做分层设计,但有一点要注意:同名字段是整体覆盖而不是合并。比如父配置里paths定义了一堆别名,子配置想增加一个,必须把父配置的也写进来,否则会丢失。所以在设计 base 配置时,我一般只放所有子配置都需要的基础内容,路径别名这种偏业务层的东西放到对应层级的配置里。
3.2 共享配置核心:strict、paths 与模块解析
这是tsconfig.base.json的一个核心示例:
{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ES2022"], "strict": true, "declaration": true, "declarationMap": true, "sourceMap": true, "composite": true, "incremental": true, "skipLibCheck": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "paths": { "@repo/utils": ["./packages/utils/src/index.ts"], "@repo/ui": ["./packages/ui/src/index.ts"] } } }moduleResolution我选择了Bundler。这是 TypeScript 5.0 引入的新解析模式,专门配合 Vite、Webpack 等现代打包器使用。它支持package.json的exports字段,也支持require与import的条件导出。对纯 Node 环境用的配置,我会改成NodeNext,因为Bundler模式在严格的 Node 场景下可能不符合预期。
paths是共享 TypeScript 配置的重头戏。它让我们在子包内部可以直接写import { cn } from "@repo/utils",而不用写一长串相对路径../../../../packages/utils/src/index。这里有个新的坑要特别提醒:TypeScript 7.0 准备废弃baseUrl,现在很多官方示例在paths里用baseUrl配合相对路径,未来会收到弃用警告。
所以我的写法是上面那种:paths的值用相对根目录的路径直接写。实际测试下来,这样写完全可行,而且规避了将来升级 TS 的兼容问题。
另一个细节是composite: true和incremental: true。composite是启用项目引用(Project References)的前提,incremental会生成.tsbuildinfo文件,让 TS 增量编译时只检查改动过的文件。对大型 Monorepo 来说,这个收益非常明显。代价是第一次编译会稍慢,但后续的重复编译时间明显下降。
3.3 各子包如何引用共享配置
有了 base 配置之后,每个子包只需要很简短的 tsconfig 即可。以apps/web为例:
{ "extends": "../../tsconfig.web.json", "compilerOptions": { "outDir": "./dist", "rootDir": "./src", "tsBuildInfoFile": "./node_modules/.cache/tsconfig.tsbuildinfo" }, "include": ["src"] }看到extends后面的相对路径了吗?这里要从子包目录出发数到根目录。路径写错了配置不会生效,而且报错信息不太直观,我踩过一次之后就特别小心,每次新建包都会先确认层级关系。
tsBuildInfoFile统一放到node_modules/.cache下,这样不会污染源码目录。这个位置和 Turborepo 的缓存机制也配合得很好,清缓存时只需要删除对应目录。
真正的共享 TypeScript 配置文件,我一般放在packages/config包里。这样设计的好处是,其他包通过@repo/config这样明确的包名引用配置,不会因为移动目录层级而改变路径关系。这也是 Config as Package 的思想,本质上和代码包的处理逻辑一致。
4. 创建真实应用与跨包引用实战
4.1 创建 apps/web:以 React + Vite 为例
基础配置活了之后,就能创建真正的应用了。以apps/web为例,我通常先用 Vite 脚手架初始化,再改造成适合 Monorepo 的结构:
pnpm create vite apps/web --template react-ts然后修改apps/web/package.json,把里面的标准字段改成 workspace 风格:
{ "name": "@repo/web", "private": true, "scripts": { "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", "typecheck": "tsc -b --pretty", "lint": "eslint ." } }这里注意两个细节。第一,tsc -b会先执行全仓的类型检查,利用我们在 base 配置里开启的composite和项目引用关系,一次性检查多个包,比分别对每个包执行tsc --noEmit快得多。第二,Vite 的配置文件里不要遗留着base之类的硬编码路径,这在 Monorepo 里很容易导致子包之间资源引用错乱。
4.2 创建 packages/utils:让跨包引用先跑起来
接下来创建一个真正的共享工具包。packages/utils/package.json长这样:
{ "name": "@repo/utils", "version": "0.0.1", "main": "./src/index.ts", "types": "./src/index.ts", "exports": { ".": "./src/index.ts", "./format": "./src/format.ts" } }你可能会奇怪,为什么main和types都指向.ts源码?这是 Monorepo 开发模式下的一种常见做法:在本地开发时直接引用 TS 源码,让 Vite 或打包器去做转译,省去每次改代码都要重新执行一次build的等待时间。
但要注意,发布到 npm 时不能这么干,需要先用 tsup 或 tsc 构建出.js和.d.ts,再把这些产物作为入口。所以在真正的发布包里,我通常会用tsup做一次打包,exports字段分别指向构建产物。本地开发和发布用两套入口指向,是我对比了多种方案后觉得最合理的配置。
要让apps/web能引用它,需要在apps/web/package.json里加入 workspace 依赖:
pnpm --filter @repo/web add @repo/utils@workspace:*执行完之后依赖会显示为:
"dependencies": { "@repo/utils": "workspace:*" }workspace:*是 pnpm 的特殊语义,含义是“无论子包当前是什么版本,一律使用本地 workspace 里的版本”。这在联调阶段特别方便,不用每次手动改版本号。发布时 pnpm 会自动把它转换成^0.0.1这样的正式版本号(可以在publishConfig里配置registry和access后直接发)。
4.3 共享组件库与类型定义
如果项目里有多个前端应用,一个共享的 React 组件库几乎是刚需。packages/ui的结构和utils类似,但有几个组件库特有的关键点:
peerDependencies里声明react和react-dom,避免同一仓库里出现多个 React 副本导致 hooks 报错。devDependencies里仍然要安装 React,用于本地开发和组件测试。- 不要在组件库里硬编码 CSS 的全局样式,尽量用 CSS-in-JS 或携带命名空间的样式方案。
组件库的共享配置也可以做成一个独立的配置包。我在packages/config下放了 ESLint 的共享配置,通过extends的方式被各子包复用。比如eslint-config-base.js里统一配置了typescript-eslint、react-hooks等规则,各子包只需在.eslintrc.cjs里写:
module.exports = { extends: ["@repo/config/base"], };这一步看起来简单,但它是 Monorepo 工程化里常被忽略的一环。因为如果没有统一规范,不同子包很容易各自引入不同版本的 lint 插件,导致规则判断不一致。把 ESLint、Prettier、TypeScript 都做成共享配置之后,“一次配置,处处生效”的好处会很快体现出来。
5. 常见问题与排查技巧实录
5.1 高频报错速查表
踩坑是必然的,我把这段时间最常遇到的问题整理成了速查表,方便新人自查:
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
Cannot find module '@repo/utils' | 包名或路径配置不一致 | 检查package.json的name、依赖声明、tsconfigpaths是否三处一致 |
tsc -b报错Composite projects may not disable declaration emit | 子配置把declaration覆盖掉了 | 在子配置里显式设置"declaration": true |
| 编辑器报错,命令行不报错 | VS Code 使用的 TS 版本和本地不一致 | 在项目根目录tsconfig.json里指定"types": []或切换工作区 TS 版本 |
| pnpm install 后报 peer 依赖冲突 | React 版本不统一 | 在组件库的peerDependencies里标明版本,根目录用pnpm.overrides强制统一 |
| 构建出来的包内容还是旧的 | Turborepo 缓存命中 | 执行turbo build --force清掉缓存重跑 |
baseUrl被标记废弃 | 用了即将移除的特性 | 用paths的相对路径写法,去掉baseUrl |
Cannot read properties of undefined在编译时出现 | 某个包的main或exports入口指向不存在的文件 | 检查该包是否已构建,入口路径是否与实际文件一致 |
5.2 关于共享 TypeScript 配置的三个避坑心得
第一个避坑心得是:用绝对路径写paths时,尽量用./开头的相对路径,不要依赖baseUrl。热搜里大家经常搜“baseurl 已弃用”这回事,这个现象不是假新闻,TypeScript 7.0 里baseUrl会停止运行。提前改过来,以后升级就不会遇到断崖式报错。
第二个心得:共享配置不要过度拆分。有人为了“纯净”,把paths、lib、target全部拆成独立文件,结果每个子配置都要搞七八个文件继承,维护成本直线上升。我自己的原则是:两层就够用,根级tsconfig.base.json放通用规则,按平台拆出web/node两个分叉,子包配置只留差异项。
第三个心得:ESLint 的parserOptions.project在 Monorepo 里容易引起性能问题,因为 TS 项目多了之后类型感知规则会拖慢 lint 速度。我现在内部项目默认不启用类型感知规则,只在 CI 单独跑一轮typecheck来兜底。毕竟 lint 的主要任务是抓代码风格和明显错误,类型问题交给 tsc 更靠谱。
5.3 实践中的整体收益
整套模板跑通之后,我发现团队在三个维度上的改善非常明显。第一是“新项目启动速度”:原来搭一个新前端项目需要一天,现在复制模板改几个包名,半小时就能跑起来。第二是“升级成本”:公共工具库或 TS 配置要调整时,只需改动共享包,然后运行一次全量 typecheck 就能评估影响范围,不用再在多个仓库之间来回切。第三是“CI 提速”:Turborepo 的缓存让只有局部代码变更的 PR 的构建时间大幅缩短,提交越频繁,缓存收益越明显。
当然,Monorepo 也有它不适合的场景。如果团队只有一两个独立项目,且几乎没有公共代码要复用,强行上这套结构反而会多出一层维护成本。不过如果你现在已经有三五个项目,并且频繁感觉到“这个公共函数我好像在另一个项目写过”,那就可以认真考虑迁移到这套模板上了。
我个人在实际搭建过程中最深的体会是:工程化模板本质上是把团队的“隐性约定”外化成“显性配置”。只要这些配置能被人轻松理解、复制、扩展,它就会成为团队效率的加速器;反之,如果配置变成了谁都看不懂的“黑话体系”,那它只会制造新的瓶颈。所以做这套模板时,我坚持只把必要的复杂度放进去,并且每加一条配置都写下注释,确保后来的人能接得住。这也是我建议你在一开始动手时就要想清楚的:你的 Monorepo 模板不是为了炫技,而是为了让团队把精力集中在真正需要创造力的业务代码上。