
最近团队一直在做微前端拆分和公共组件库的治理代码仓库越来越多依赖关系越来越乱我下定决心把整个前端基座迁到 Monorepo 架构上。折腾完初始化、应用接入、共享配置抽取这一整套流程后我最大的感受是Monorepo 本身不复杂复杂的是“工程化”这三个字——如果你只是把几个包塞进一个仓库那不叫 Monorepo那叫给自己埋雷。这篇内容就是我当时从零搭建企业级 Monorepo 工程化模板的完整复盘包含项目初始化、应用创建、共享 TypeScript 配置抽取以及一堆实践下来才会遇到的坑和取舍。不管你是准备把现有项目迁移过来还是从零开一个新项目这套流程和配置都可以直接抄作业再按自己团队的情况微调就好。在开始之前先明确这套模板的目标一套代码仓库多个应用和多个共享包共存开发者可以在根目录统一安装依赖、统一执行构建和测试TypeScript 配置做到“一处维护、处处继承”同时让新增一个子应用的成本降到最低。所有配置和脚本都是为了这几个目标服务的越往后你越能感受到这种设计的好处。1. 为什么需要一套 Monorepo 工程化模板1.1 从多仓库到单一仓库到底解决了什么先聊聊背景。大多数团队一开始都是多仓库Multi-repo模式一个后台管理系统一个仓库一个移动端 H5 一个仓库公共组件库再单独一个仓库。这种模式在项目少、人少的时候问题不大但当仓库数量上来之后痛点会越来越明显第一是跨仓库改代码极其痛苦。公共组件库改了一个类型定义或者工具函数你得先在组件库仓库发版再到各个业务仓库里升级依赖如果涉及到破坏性变更所有业务方都得跟着改一轮。发布链路长不说中间任何一步出错都可能导致线上问题。第二是版本对齐难。多个业务仓库依赖同一个组件库的不同版本时间一长你会发现线上跑着的组件库版本五花八门出了问题都很难复现。第三是本地开发体验割裂。你想同时调试业务代码和组件库代码就得使用npm link或者 yalc 这种工具配置麻烦不说偶尔还会遇到链接失效、依赖重复实例化之类的问题。Monorepo 用“一个仓库装下所有代码”的方式把跨仓库改代码变成了同仓库改代码。公共包不需要发版直接在workspace里被业务应用引用改完立即可见。版本天然统一因为大家都从同一个源码树获取依赖。这就是 Monorepo 架构最核心的价值——它不是银弹但能解决多仓库模式下最让人头疼的协作和版本问题。1.2 模板应该包含哪些核心能力既然决定搭 Monorepo那就要想清楚模板里必须包含什么。我个人认为一个企业级 Monorepo 工程化模板至少要具备以下五项核心能力多包管理能力能够在一个仓库里管理多个 npm 包/应用支持批量安装依赖、批量执行脚本、本地包之间的软链接。任务编排能力能够按依赖关系自动排序执行构建、测试、类型检查等任务并且只对变更过的项目做增量执行节省 CI 时间。共享配置能力TypeScript、ESLint、Prettier、Jest 等配置能够抽成共享包各子项目继承后再按需覆盖。统一的工程规范包括目录结构规范、命名规范、代码提交规范、版本管理规范。低成本接入能力新增一个子应用或子包时不需要复制粘贴一大堆配置而是通过脚手架命令自动生成符合规范的代码结构。这五项能力并不是一开始就要全部做齐但模板的设计必须为这些能力预留空间。否则你搭出来的东西用不了几个月就得推翻重来这是企业级和玩具级 Monorepo 最本质的区别。2. 技术选型与整体设计Monorepo 的技术选型看起来眼花缭乱但核心组合其实就那么几个。我最终的选型是pnpm workspace Turborepo TypeScript Project References的组合下面说说为什么这么选以及每个组件在模板里扮演什么角色。2.1 包管理为什么是 pnpm workspace包管理器的选择是 Monorepo 的第一道分水岭。目前主流的方案有 npm workspace、yarn workspace包括 yarn 2/3 的 Berry 版本和 pnpm workspace。我直接说结论新项目无脑选 pnpm。为什么因为 pnpm 从设计之初就考虑了 Monorepo 的场景。它采用全局内容寻址存储 硬链接的依赖管理策略多个项目共享同一个依赖版本时磁盘上只有一份真实文件其他都是硬链接引用。这意味着你在一个包含十个子包的仓库里安装依赖用的磁盘空间和安装速度都远优于 npm/yarn 的方案。pnpm 的另一个杀器是严格的依赖隔离。在 npm/yarn 的扁平化 node_modules 里你即使没有在 package.json 中声明某个依赖代码里也能import到它因为依赖被提升到了顶层 node_modules这就是臭名昭著的“幽灵依赖”。pnpm 默认不允许这种情况你只能用 package.json 中显式声明的依赖。刚开始会有点不习惯但长期来看这能逼着每个包把依赖声明清楚可维护性提升一个档次。在 pnpm workspace 里你只需要在根目录放一个pnpm-workspace.yaml文件指定哪些目录是 workspace 包然后在任意子包中用pnpm add安装依赖时pnpm 会自动识别本地 workspace 包并创建软链接。比如你在apps/admin目录下执行pnpm add repo/uipnpm 会直接链接到本地的packages/ui而不是去 npm registry 拉取。2.2 任务编排Turborepo 的价值如果你只有两三个包用 pnpm 的--filter参数配合--parallel就够了。但到了企业级规模——比如我这边是 5 个应用 12 个共享包——任务编排就必须要交给专门工具。Turborepo 是目前生态最成熟的选择。Turborepo 的核心价值有三个任务依赖感知它知道build任务之间的依赖关系。比如apps/admin依赖packages/ui那么构建admin之前一定先构建ui不管你在命令行里怎么指定顺序。增量构建Turborepo 会根据文件内容哈希判断哪些包没有变化跳过这部分构建任务直接复用缓存结果。我第一次用的时候全量构建 6 分多钟第二次构建没有代码变更只花了几秒钟。远程缓存可以把构建缓存上传到云端Vercel 或者自建服务团队成员和 CI 共享缓存别人构建过的东西你不必重新构建。Turborepo 本身不重复实现包管理它只是在你已有的 pnpm/npm/yarn workspace 之上做一个任务编排层。这么设计的好处是职责清晰——包管理器管依赖树Turborepo 管任务执行两者各司其职互不干扰。2.3 目录结构与约束Monorepo 的目录结构看似是个小问题其实影响深远。我采用的目录结构如下这也是目前社区比较主流的分层方式my-monorepo/ ├── apps/ # 可部署的应用 │ ├── admin/ # 后台管理系统 │ ├── web/ # 官网/前台应用 │ └── docs/ # 文档站 ├── packages/ # 共享库发布到 npm 或仅内部使用 │ ├── ui/ # 组件库 │ ├── utils/ # 工具函数库 │ ├── config-eslint/ # ESLint 共享配置 │ ├── config-typescript/ # TypeScript 共享配置 │ └── config-tailwind/ # Tailwind 共享配置 ├── package.json ├── pnpm-workspace.yaml ├── turbo.json └── tsconfig.base.jsonapps目录放最终会部署运行的应用packages目录放可复用的代码和配置。这里有一个很重要的边界apps 之间的依赖应当极少甚至没有apps 只依赖 packages 里的代码。如果你发现两个应用之间有大量共享逻辑那应该把这部分逻辑下沉到 packages而不是在应用之间直接相互引用。这个约束能让仓库保持可维护不至于变成一团乱麻。为了让这个约束落地我还有一个原则packages/下的包尽量保持“通用”不要绑定某个具体业务。比如utils里面放的是formatDate、debounce这类纯函数真正跟业务相关的逻辑放在各个 app 内部的src里。这样既能复用又不会因为业务耦合导致一个包改一处、处处爆炸。3. 从零初始化仓库3.1 安装基础工具与初始化 pnpm workspace现在开始动手。首先得确保本机装了 Node.js推荐 18.17.0 及以上版本我这边用的是 20 LTS和 pnpm我用的是 9.x。安装 pnpm 的方式有多种推荐通过 corepack 启用corepack enable corepack prepare pnpmlatest --activate如果你不想用 corepack也可以直接通过 npm 全局安装npm install -g pnpm两条路都能走通。然后初始化整个仓库。先创建一个根目录比如my-monorepo然后执行mkdir my-monorepo cd my-monorepo pnpm init根目录的package.json需要做几件重要的事。首先是声明private: true防止根包被意外发布。其次是添加packageManager字段锁定包管理器版本保证团队和 CI 环境一致{ name: my-monorepo, private: true, packageManager: pnpm9.12.0 }接着创建pnpm-workspace.yaml告诉 pnpm 哪些目录是 workspace 包packages: - apps/* - packages/*这一个文件就完成了 workspace 的核心配置。pnpm 会把apps和packages下的每个子目录都识别为一个独立的 workspace 包。执行pnpm install后这些子包之间如果需要互相引用直接按包名安装即可pnpm 会自动匹配 workspace 内的包并创建软链接。3.2 配置 Turborepo 与根目录脚本初始化完成后安装 Turborepo 作为开发依赖pnpm add -D -w turbo-w表示安装到 workspace 根目录这是 pnpm 的特殊参数没有它安装会失败。然后在根目录创建turbo.json{ $schema: https://turbo.build/schema.json, tasks: { build: { dependsOn: [^build], inputs: [$TURBO_DEFAULT$, .env.*], outputs: [dist/**, !dist/**/__tests__/**] }, dev: { cache: false, persistent: true }, lint: { dependsOn: [^lint] }, typecheck: { dependsOn: [^typecheck] }, test: { dependsOn: [^test], inputs: [$TURBO_DEFAULT$, src/**, tests/**] } } }这里解释几个关键的配置dependsOn[^build]表示当前包的 build 任务依赖其依赖包^前缀的 build 任务。Turborepo 会自动推导依赖图保证先构建底层包再构建应用。cache是否开启缓存。dev任务必须关掉缓存因为开发服务是常驻进程没有“完成”的概念。persistent标记常驻任务防止 Turborepo 误判为执行完毕。inputs/outputs决定缓存键和缓存产物范围。dist/**声明构建产物目录Turborepo 会缓存这些文件。根目录的package.json中添加统一的命令入口{ scripts: { build: turbo run build, dev: turbo run dev, lint: turbo run lint, typecheck: turbo run typecheck, test: turbo run test, clean: turbo run clean rm -rf node_modules } }这样团队所有成员都通过根目录的这些命令来执行任务不需要进到某个子包里单独操作。统一的命令入口是工程化模板体验提升的关键一步别省。4. 创建应用与共享配置落地4.1 用 Vite 创建一个 React 应用示例基础架构准备好后我们来创建第一个应用。以 React Vite 为例进入apps目录并执行cd apps pnpm create vite admin --template react-tsVite 脚手架会生成一个标准的 React TypeScript 应用。然后你要注意这个应用现在还不是 workspace 包需要检查它是否被 pnpm 正确识别。只要它在apps/*目录下pnpm 就已经把它当作 workspace 包了但我们需要调整一下包名让它遵循我们的命名规范{ name: apps/admin, private: true, type: module }在 Monorepo 中包名一般建议使用组织名/包名的 scoped 形式比如company/admin、repo/ui这样在依赖引用中能够一眼看出包的归属关系避免命名冲突。接着给应用安装必要依赖。注意这里有个细节如果应用需要依赖 workspace 内的共享包比如repo/ui你不需要先发布这个包直接安装本地路径即可cd apps/admin pnpm add repo/ui前提是packages/ui包已经存在且package.json中的name为repo/ui。这样 pnpm 会自动在node_modules中创建软链接指向packages/ui目录。Vite 在 Monorepo 中有一个经典问题默认配置下它只会对当前应用目录下的源码做转译如果你引用的 workspace 包是src源码而不是构建后的dist产物Vite 可能不会对它做 JSX/TS 转译导致运行报错。解决办法是给 Vite 配置server.fs.allow和optimizeDeps// vite.config.ts import { defineConfig } from vite; import react from vitejs/plugin-react; import { resolve } from path; export default defineConfig({ plugins: [react()], server: { fs: { allow: [resolve(__dirname, ../../)] } }, optimizeDeps: { include: [repo/ui] } });fs.allow让 Vite 可以读取工作区根目录下的文件optimizeDeps.include让 Vite 预构建 workspace 内部依赖避免开发时出现“依赖扫描不到”的问题。4.2 共享 TypeScript 配置的抽取重点TypeScript 配置是 Monorepo 工程化的重头戏也是最容易翻车的地方。很多人直接在每个包里复制粘贴一份tsconfig.json这会导致配置漂移——改动一个公共配置时要同步 N 个文件漏掉任何一个都会造成行为不一致。正确的做法是把公共配置抽成一个共享包。我习惯在packages/config-typescript目录下维护多个 tsconfig 片段packages/config-typescript/ ├── package.json ├── base.json ├── nextjs.json ├── react-app.json └── node.json其中base.json是所有人共享的底层配置包括目标语法版本、模块解析策略、严格模式等{ $schema: https://json.schemastore.org/tsconfig, compilerOptions: { target: ES2022, lib: [ES2022, DOM, DOM.Iterable], module: ESNext, moduleResolution: bundler, strict: true, noUnusedLocals: true, noUnusedParameters: true, esModuleInterop: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, resolveJsonModule: true, isolatedModules: true, declaration: true, declarationMap: true, sourceMap: true } }这里重点说moduleResolution: bundler。这个配置是 TypeScript 5.0 引入的专门为 Vite、webpack 等打包器设计。使用它之后TS 可以直接解析package.json中的exports字段避免一些使用moduleResolution: node或node16时出现的模块解析问题。另外它不再强制要求文件扩展名写import ./foo也不会报错和 Vite 的开发体验保持一致。但有一个坑需要注意moduleResolution: bundler需要搭配module: ESNext或类似值使用不能和module: CommonJS混用。如果你的某个包是 Node 服务端代码需要编译成 CommonJS那就不能用这份配置而是继承node.json{ extends: ./base.json, compilerOptions: { module: NodeNext, moduleResolution: NodeNext, declaration: false } }然后是共享包的package.json。为了让其他包能通过 npm 包名的方式引用这份配置需要在exports中映射各个配置文件{ name: repo/typescript-config, version: 0.0.0, private: true, main: index.js, files: [base.json, react-app.json, nextjs.json, node.json], exports: { ./base.json: ./base.json, ./react-app.json: ./react-app.json, ./nextjs.json: ./nextjs.json, ./node.json: ./node.json } }注意这里的exports要匹配实际文件的相对路径。这样做之后任何包只需要在自己的tsconfig.json中写{ extends: repo/typescript-config/base.json, compilerOptions: { outDir: dist } }就可以继承共享配置并且还能按需覆盖某些字段。这就是“一处维护、处处继承”的落地方式。4.3 路径别名与“baseUrl 弃用”问题路径别名是 TypeScript 配置中让人又爱又恨的部分。以前大家习惯在tsconfig.json里配置baseUrl加上paths{ compilerOptions: { baseUrl: ., paths: { /*: [src/*] } } }这在单包项目中确实好用。但在 Monorepo 中共享配置里的baseUrl会遇到一个致命问题baseUrl 是相对于 tsconfig.json 所在目录解析的。如果子包继承根目录的共享配置路径解析会错乱。而且还有一个更紧迫的背景TypeScript 官方已经在 6.0 中弃用baseUrl计划在 7.0 中彻底移除。这意味着如果你还习惯写baseUrl新项目最好一开始就不要用。替代方案是直接在paths中写相对路径{ compilerOptions: { paths: { /*: [./src/*] } } }这样paths的路径是相对于当前tsconfig.json解析的baseUrl不再参与解析。这个写法和moduleResolution: bundler配合是现代 TypeScript Monorepo 的标准做法。Vite 侧还需要同步配置别名解析否则运行时会找不到路径// vite.config.ts import { defineConfig } from vite; import react from vitejs/plugin-react; import { fileURLToPath, URL } from node:url; export default defineConfig({ plugins: [react()], resolve: { alias: { : fileURLToPath(new URL(./src, import.meta.url)) } } });两边配置保持一致开发时就不会碰到“TS 编译通过、Vite 运行报模块找不到”的怪问题。5. 常见问题与排查实录搭建 Monorepo 模板的过程中我踩过不少坑这里挑几个有代表性的记录一下大家遇到类似问题可以少走弯路。5.1 TS 7.0 废弃 baseUrl如何迁移现有代码前面提到baseUrl废弃问题这里展开说一下。如果你是从老项目迁移到 Monorepo很有可能会在tsconfig.json中看到类似的配置{ compilerOptions: { baseUrl: ./src, paths: { components/*: [components/*], utils/*: [utils/*] } } }这种配置在 TS 6.0 中会报 deprecation warning在 7.0 中直接报错所以建议尽早迁移。迁移方法很简单第一步去掉baseUrl字段。第二步将paths中的相对路径改为基于 tsconfig.json 所在目录的相对路径。比如上面这个例子里baseUrl是./src那么paths中components/*实际上指向的是tsconfig/src/components/*。去掉baseUrl后直接写成{ compilerOptions: { paths: { components/*: [./src/components/*], utils/*: [./src/utils/*] } } }第三步检查 import 语句是否需要改动。如果原来的路径是以components/xxx这种别名开头TS 6.0 之后解析方式不变代码不需要改。但如果你用了baseUrl的非 paths 解析特性比如直接写相对src的路径而没加别名那就要把相对路径补齐。最后如果项目里配置很多可以用 codemod 工具辅助迁移TS 官方 deprecation warning 会在编译器输出中明确指出哪个文件需要改。手动改也不麻烦无非是细心一点。5.2 幽灵依赖与 pnpm 严格模式的摩擦pnpm 对幽灵依赖零容忍这在 Monorepo 中体现得淋漓尽致。我遇到过最典型的场景是某个包packages/utils内部使用了lodash但它的package.json里漏写了lodash依赖。在 npm/yarn 下因为在根 node_modules 里能顺藤摸瓜找到 lodash代码能正常运行。切到 pnpm 后直接报ERR_PNPM_NO_IMPORTANT_MISSING_DEPENDENCY或者运行时提示模块找不到。解决方式就是回到packages/utils目录下执行pnpm add lodash就这么简单但背后透露的是 pnpm 的设计哲学每个包必须显式声明自己的依赖依赖关系必须可追溯。刚开始团队成员会抱怨“怎么我加了依赖还要跑一次 install”但习惯之后就会发现这种严格约束让依赖关系清晰可见仓库的可维护性大大提升。5.3 构建顺序问题为什么我的应用先构建了Turborepo 自动处理依赖顺序后绝大多数情况下任务执行顺序是对的。但有一种场景需要特别注意如果你的 app 依赖的共享包是构建产物dist而不是源码那构建 app 时 Turborepo 需要确保共享包已经完成构建。dependsOn: [^build]解决了这个问题。但如果你在开发阶段直接 import 共享包的源码路径比如repo/ui/src那 Turborepo 的依赖感知就无法覆盖到这种情况。你可能会遇到改了packages/ui的源码但apps/admin跑的还是旧版本——因为apps/admin的node_modules/repo/ui指向的是旧构建产物。我的建议是企业级 Monorepo 模板中建议共享包默认导出构建产物开发时通过 watch 模式实时构建。具体做法是在packages/ui的package.json中配置dev: tsc -b --watch然后在根目录执行pnpm dev时Turborepo 会根据依赖关系启动ui的 watch 任务和admin的 dev 任务。改源码、自动构建、应用热更新一条链跑通。另一种方案是让 Vite 直接消费源码配合resolve.conditions和exports中的development条件。这个方案更灵活但对构建工具配置的要求更高团队基础比较扎实的话可以试试。如果只是想要稳定的使用体验构建产物方案更稳妥。6. 关于模板再往后想一步本来想在这里收尾但有几个扩展建议还是想提一嘴因为我在搭完核心模板后很快就发现还有一些问题值得提前规划第一是共享 ESLint 配置。TypeScript 配置只是工程化的一部分ESLint 同样值得抽成共享包。目前比较稳定的是扁平化配置eslint.config.js通过repo/eslint-config包导出import js from eslint/js; import tseslint from typescript-eslint; import react from eslint-plugin-react; import reactHooks from eslint-plugin-react-hooks; export default tseslint.config( { ignores: [dist, node_modules] }, js.configs.recommended, ...tseslint.configs.recommended, { files: [**/*.ts, **/*.tsx], languageOptions: { parserOptions: { project: [./tsconfig.json], tsconfigRootDir: import.meta.dirname } }, rules: { // 团队自定义规则 } } );然后在每个应用的eslint.config.js中直接继承import config from repo/eslint-config; export default config;这样 lint 规则也能做到“一处维护、处处继承”而且不用每个包各自安装一整套 ESLint 插件。第二是 CI 缓存。远程缓存是 Turborepo 的隐藏大招。我在 CI 中配了 Turborepo 的 remote cache跑一次基础镜像构建后后续的 typecheck 和 build 任务在无代码变更时都是秒过开发体验提升非常明显。第三是版本管理和发布策略。如果packages目录下有需要发布到 npm 的公共包建议引入 changesets。它能自动生成 changelog、辅助语义化版本号管理并且天然适配 Monorepo 多个包独立发版的场景。最后说一下我个人的体会。Monorepo 模板这件事最大的价值不是把文件拷贝来拷贝去而是把团队的共识沉淀成了代码和配置。任何新成员加入clone 下来一条命令跑通开发环境这就是最直观的生产力提升。模板不是一蹴而就的它会随着团队实践的深入不断演进比如新增规范、调整配置、补充脚手架能力。这套方案经过我们实际项目的验证已经在支撑 5 个应用和 12 个共享包的日常开发和发布。如果你的团队也在考虑引入 Monorepo希望这篇内容能帮你少踩几个坑。