Monorepo工程化模板搭建:pnpm workspace + TypeScript最佳实践
2026/9/19 4:18:07 网站建设 项目流程

我一直在想一个问题:Monorepo 这一套东西,到底是被大厂带火的“政治正确”,还是真能解决工程效率问题?去年我把自己维护了三年、横跨 6 个仓库的前端项目体系,全部重构成基于 pnpm workspace + TypeScript 的 Monorepo 模板,跑完一轮迭代之后才确信——只要组织得当,Monorepo 确实能省掉大量重复劳动。

这篇文章把我从零搭建企业级 Monorepo 工程化模板的完整过程记录下来,包括初始化、应用创建、共享 TypeScript 配置、lint/format 基线、构建缓存和 CI 落地,以及这中间踩过的若干坑。适合准备把多个项目收归到一个仓库的团队,也适合刚接触 Monorepo、想从脚手架角度理解工程化的小团队参考。

1. 为什么我把团队项目从多仓库迁到 Monorepo:问题与收益

1.1 多仓库时代我们每天在重复踩的坑

前两年团队用的是典型的多仓库(Multi-Repo)结构,一个 UI 组件库一个仓库,两个业务系统各自一个仓库,再加上一个公共工具库仓库,总共 6 个仓库。表面看边界清晰,实际上每天都在“跨仓库搬砖”。

最痛的是依赖本地发布。业务系统 A 用到组件库的某个新组件,我必须先把组件库 build 一遍,改版本号,推到私有 npm 仓库,再去业务系统 A 的 package.json 里改依赖版本,最后手滑把版本号写错,又得重新走一遍发布流程。一套操作下来写代码只花了 20 分钟,发布迭代却耗掉半天。

其次是升级公共逻辑时的“同步地狱”。工具库里修了一个 API 的 bug,得在 6 个仓库里分别跑npm update,还要祈祷每个仓库都没有因为版本不一致产生行为差异。实际上每次升级都会有那么一两个仓库,依赖的还是几个月前的旧版本,bug 照旧。

此外,多仓库之间的代码共享方式五花八门:有人直接npm link,有人把公共代码 copy 一份进项目,有人用 git submodule。每个方案都有副作用,代码根本没办法形成统一的抽象层。

1.2 Monorepo 到底解决了什么,又带来了什么新问题

迁到 Monorepo 之后,最大的变化是**“本地包即源码”**:业务系统可以直接依赖仓库内packages/xxx的源码,改完组件库的代码,业务系统下个热更新就能看到效果,不再需要本地发布。

第二个变化是所有仓库的依赖和脚本可以在根上统一治理。根目录一个package.json、一份pnpm-lock.yaml,锁文件只有一份,依赖版本冲突问题大幅减少。

但 Monorepo 不是银弹,它会引入新问题:

  • 仓库体积膨胀,pnpm install和 CI 拉取时间变长。
  • 如果构建工具不区分变更范围,每次改动所有子包都会重新构建,CI 时间爆炸。
  • TypeScript 在多个子包之间的引用关系处理不好,会出现各种tsconfig继承混乱、产物找不到类型声明的问题。

这些问题大多可以靠工程化手段解决,但前提是你从一开始就把模板搭对。下面我直接给出我最终落地的方案。

2. 初始化企业级 Monorepo 骨架:pnpm workspace 与目录设计

2.1 为什么选 pnpm workspace 而不是 npm/yarn

当前主流的 workspace 方案有 pnpm、npm、yarn 和 Bun,外加 Turborepo/Nx 这类构建编排器。我的选择是pnpm workspace + Turborepo,理由有几点:

  • pnpm 使用内容寻址存储,所有子包共享同一个依赖存储,能硬链接到仓库内,磁盘占用比 npm/yarn 低很多。实测一个 30+ 子包的 Monorepo,node_modules体积比 npm workspace 小约 40%。
  • pnpm 对幽灵依赖(Phantom Dependency)处理严格。子包只能访问自己在package.json里声明的依赖,不会像 npm/yarn 那样把根上的依赖“透传”给子包。这能逼着每个子包把依赖声明清楚。
  • pnpm 原生支持workspace:协议,可以在dependencies里写"@scope/ui": "workspace:*",本地包引用非常直观。

npm workspace 可以做到基本等价的效果,但依赖扁平化策略不如 pnpm 干净。yarn 的 PnP 模式虽然也很强,但兼容性和团队心智成本偏高。最终我选择 pnpm 作为包管理器,Turborepo 负责任务编排。

2.2 从空目录到可运行的 workspace 骨架

先确认本机环境:Node.js 版本建议 18.18+,我用的 Node 20 LTS。pnpm 安装方式就不赘述了,直接用官方脚本装就可以。

初始化一个空目录并创建基础的 workspace 配置文件:

mkdir enterprise-monorepo && cd enterprise-monorepo pnpm init

根目录的package.json要设置成“私有”,避免误发布到 npm:

{ "name": "enterprise-monorepo", "private": true, "type": "module", "scripts": { "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", "format": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"" }, "packageManager": "pnpm@8.15.0", "devDependencies": { "turbo": "^1.13.0" } }

注意几个细节:

  • "type": "module"会影响所有子包的模块系统判断,如果你的某些子包还想用 CommonJS,需要在子包自己的package.json里显式声明"type": "commonjs"
  • "packageManager"字段可以引导团队成员统一 pnpm 版本,配合 Corepack 使用效果更好。

接着创建pnpm-workspace.yaml,声明子包的匹配模式:

packages: - "apps/*" - "packages/*"

这个模式表示apps目录下放应用子包,packages目录下放共享库子包。这两层目录是整个 Monorepo 的核心地盘,不建议在代码库里混入其他业务目录。

然后创建.npmrc,这一步很关键但很容易被忽略:

shamefully-hoist=false strict-peer-dependencies=true auto-install-peers=true
  • shamefully-hoist=false是 pnpm 默认值,显式写出来是为了防止有人“手贱”把它改成 true。改成 true 之后幽灵依赖会卷土重来。
  • strict-peer-dependencies=true让 peerDependencies 不满足时报错而不是警告,这在企业级项目中很有必要,能提前把版本冲突暴露出来。

创建完这些基础文件之后,先别急着装依赖,我们还需要目录。用命令行创建目录结构:

mkdir -p apps packages mkdir -p apps/web apps/admin mkdir -p packages/shared packages/ui packages/tsconfig

packages/tsconfig是我用来统一存放共享 TypeScript 配置的子包,这在后面的章节会详细展开。

2.3 根目录 package.json、pnpm-workspace.yaml 与 .npmrc 的关键配置

上面已经给出了三件套,但这几个文件在企业级使用中还有一些细节需要注意。

package.json的 scripts 设计:原则是“根上只放编排命令,不放业务命令”。团队成员不需要关心构建顺序,只需要在根上执行命令,比如:

pnpm build # 等价于 turbo run build pnpm --filter @apps/web dev # 只启动 web 应用

.gitignore:必须在初始化时就写好,避免把依赖目录和构建产物提交进仓库。除了常规的node_modulesdist,还要加 pnpm 特有的.pnpm-store,以及.turbo缓存目录:

node_modules/ .pnpm-store/ .turbo/ dist/ coverage/ *.log .env

编辑器统一配置:在根目录放一个.editorconfig,强制换行符、缩进和尾部空格规范,这一步能避免团队协作时出现 diff 噪音:

root = true [*] charset = utf-8 indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true

3. 共享 TypeScript 配置:一份 tsconfig 如何被所有子包复用

3.1 TypeScript 工程化中的配置痛点

多仓库时代,每个仓库都有自己的一套tsconfig.json。有些是从老项目复制过来的,有些是网上抄的,各不相同。最离谱的一次,我对比了两个项目的 tsconfig,除了compilerOptions里的target不一样,其他几乎全一样。

Monorepo 的好处是:一份共享配置可以被所有子包继承,避免“每个包维护一份且谁也不听谁的”的情况。TypeScript 自身的extends机制完全可以实现配置继承,不需要额外工具。

你完全可以把公共配置抽象成一个包,然后让业务子包通过extends引用它。TypeScript 解析extends时会遵循 Node 模块解析规则,所以可以直接写包名。

3.2 构建共享 tsconfig.base.json

我在packages/tsconfig里放了三层配置:

  1. tsconfig.base.json:最底层的公共编译选项。
  2. tsconfig.node.json:适用于 Node.js 环境的子包。
  3. tsconfig.web.json:适用于浏览器环境的子包(React/Vue 等)。

packages/tsconfig/tsconfig.base.json的内容如下:

{ "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "Bundler", "strict": true, "skipLibCheck": true, "esModuleInterop": true, "isolatedModules": true, "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, "sourceMap": true, "noEmit": false, "outDir": "dist" }, "include": ["src"] }

几个选项我要重点解释一下,因为它们直接决定了 Monorepo 里其他包能不能正确引用当前包的类型:

  • moduleResolution: "Bundler":这是 TS 5.x 之后比较推荐的方案,对现代打包器(Vite、Webpack)友好,也是 Vite 生态的默认选择之一。但是要注意,这个选项要求module不是CommonJS
  • declaration: true+declarationMap: true生成.d.ts.d.ts.map,否则其他子包在引用当前包时只能看到源码但拿不到类型声明。
  • isolatedModules: true:使用 Vite/esbuild 转译时的安全要求,强制每个文件必须能独立编译。

packages/tsconfig/tsconfig.node.json用于 Node 侧服务子包:

{ "extends": "./tsconfig.base.json", "compilerOptions": { "lib": ["ES2023"], "module": "NodeNext", "moduleResolution": "NodeNext" } }

这里改用了NodeNext,因为 Node.js 原生 ESM 解析规则和 bundler 不一样,TypeScript 源码如果要在 Node 环境直接用tsxts-node跑,这个配置更贴合运行时行为。

packages/tsconfig/tsconfig.web.json用于前端应用子包:

{ "extends": "./tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx" } }

如果你的前端不是 React,而是 Vue 或 Svelte,这个jsx选项要根据框架调整。

3.3 子包如何继承与覆盖配置

子包里的tsconfig.json写法根上是一致的:

{ "extends": "@monorepo/tsconfig/tsconfig.web.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "baseUrl": ".", "paths": { "@shared/*": ["../../packages/shared/src/*"] } }, "include": ["src", "tests"] }

说完继承,就不得不提最近 TypeScript 社区比较热门的一个变动:baseUrl弃用问题。TypeScript 6.x 开始对baseUrl打出了弃用警告,计划在 7.0 中移除对它的支持。这直接影响上面这段配置的写法和迁移路径。

具体信息在 TypeScript 的 roadmap 里:baseUrl不再作为路径解析的必备选项,路径映射可以直接由paths本身驱动。也就是说:

{ "compilerOptions": { "paths": { "#shared/*": ["../../packages/shared/src/*"] } } }

而不需要再写baseUrl,路径解析会基于tsconfig.json所在目录自动计算。这里面有一个歧义风险值得注意:paths路径是相对于baseUrl的,baseUrl 被移除之后,如果 tsconfig 不在子包根目录,路径的基准点要重新确认。在 TS 7.0 之后的语义里,paths里声明的目标路径会更明确地以tsconfig.json所在目录为基准。

所以如果你是从老项目迁移过来,并且tsconfig.json里目前有baseUrl字段,升级到较新版本的 TypeScript 时候会看到类似这样的警告:

选项"baseurl"已弃用,并将停止在 typescript 7.0 中运行。指定 compileroption。

处理方法很简单:

  1. 删除baseUrl这一行。
  2. 检查paths里的目标路径,如果原来写的是相对baseUrl的相对路径,改成相对tsconfig.json文件本身的路径。
  3. 运行一次tsc --showConfig确认解析结果和迁移前一致。

我个人的建议是:新项目模板里直接不写baseUrl,从一开始就按 TS 7.0 的语义来。

4. 创建可运行的 App 子包:从 hello world 到真实场景

4.1 应用子包的目录结构与产物约定

我以apps/web为例,用 Vite + React + TypeScript 创建应用。不用从 npm 拉模板,直接手动初始化,这样更可控一些。

cd apps/web pnpm init

apps/web/package.json需要特殊处理:

{ "name": "@apps/web", "version": "0.0.0", "private": true, "type": "module", "scripts": { "dev": "vite", "build": "tsc -p tsconfig.json && vite build", "preview": "vite preview" }, "dependencies": { "@apps/shared": "workspace:*", "@apps/ui": "workspace:*", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", "typescript": "^5.5.0", "vite": "^5.0.0" } }

这里有三点值得注意:

  • 包名统一用@apps/web这种 scope 格式,避免子包之间名字冲突。
  • workspace:*表示依赖当前 workspace 内的同名包,pnpm 会把符号链接指向源码目录。
  • 根上装了typescript,子包也装了。这么做是有意的:每个子包独立声明自己的编译工具,避免子包依赖“根上的环境”。这样即使某个子包需要被单独拎出去调试,也不会因为缺少依赖而跑不起来。

4.2 依赖管理:本地包引用与workspace协议

创建共享库子包packages/shared

mkdir -p packages/shared/src cd packages/shared pnpm init

packages/shared/package.json

{ "name": "@apps/shared", "version": "0.0.0", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, "scripts": { "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch" } }

这里我把maintypes都指向构建产物,而不是直接指向src。为什么这么做?

在开发模式下,pnpm dev会用tsc --watch把源码编译到dist目录,引用方通过 workspace 链接拿到的是实时更新的产物。在 Helm 部署或者生产构建时,执行pnpm build,产物完整生成后再打包,避免依赖方的 Vite 去解析src下的 TS 源码而导致规则不一致。

当然,也有团队喜欢把exports直接指向src/index.ts,让 Vite 直接编译 TS 源码。这种做法省去了本地监听编译的负担,但代价是所有消费方打包器都必须能处理 TS 文件,而且 lint 检查范围容易失控。对企业级模板来说,产物优先更稳。

4.3 让 TypeScript 构建快速可复现

Monorepo 构建顺序是一个坑:如果apps/web依赖packages/shared,那么构建apps/web之前必须保证packages/shared已经被构建过。

最简单的方式是用 Turborepo 的 task dependency :

根目录turbo.json

{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".turbo/**"] }, "dev": { "cache": false, "persistent": true }, "lint": { "outputs": [] } } }

"dependsOn": ["^build"]的意思是:执行子包 build 前,先构建所有它依赖的其他子包。Turborepo 会分析 workspace 之间的依赖图,按拓扑顺序并行执行构建,还带缓存——只要有子包的输入文件哈希没变,就直接跳过构建并从缓存恢复产物。

"cache": false放在 dev 任务上,是因为开发服务器是长驻进程,不适合用缓存控制。

"outputs": []放在 lint 任务上,表示 lint 不产生需要缓存的产物,但 Turborepo 仍然会缓存它的执行结果(如果 lint 没有通过,就不会输出到缓存)。

5. 落地企业级的几个关键细节:lint、format、构建与CI

5.1 ESLint、Prettier 的基线配置

在 Monorepo 里配置 ESLint,最好用扁平化配置(Flat Config),因为 ESLint 9 已经把它作为默认方式,TS 相关的插件也基本完成了适配。

根目录安装:

pnpm add -Dw eslint @eslint/js typescript-eslint prettier eslint-config-prettier

eslint.config.js(ESLint 9 风格):

import js from "@eslint/js"; import ts from "typescript-eslint"; import prettier from "eslint-config-prettier"; export default ts.config( js.configs.recommended, ...ts.configs.recommended, { ignores: ["**/dist/**", "**/node_modules/**", "**/.turbo/**"] }, { files: ["**/*.{ts,tsx}"], rules: { "@typescript-eslint/no-unused-vars": [ "error", { "argsIgnorePattern": "^_" } ], "@typescript-eslint/consistent-type-imports": [ "error", { "prefer": "type-imports" } ] } }, prettier );

这里prettier放在最后,通过eslint-config-prettier关闭所有与 Prettier 冲突的规则。TypeScript 规则我推荐typescript-eslint,因为社区活跃,且对 TS 5/6 的语法支持及时。

prettier配置用一个prettier.config.js就好:

export default { semi: false, singleQuote: false, printWidth: 100, trailingComma: "all" };

这里关于“长等号”有个容易被忽略的小细节:有些团队发现在格式化之后,打印日志时为了对齐而输出的=========会被printWidth强制换行,实际上printWidth只影响代码结构,不会进入字符串字面量内部,所以不用担心。真正容易出现长等号问题的场景是 Markdown 表格、注释和模板字符串内部的对齐字符,Prettier 默认不会动这些区域的文本。

5.2 脚本编排与 turbo 缓存

有了 Turborepo,根目录的 scripts 就是通往子包的统一入口:

pnpm build # 构建所有子包,按依赖拓扑排序 pnpm --filter @apps/web dev # 只跑 web 应用 pnpm --filter @apps/shared build --force # 强制重新构建 shared

有几个推荐加入的脚本:

{ "scripts": { "changeset": "changeset", "ci:build": "turbo run build --filter=@apps/web...", "typecheck": "turbo run typecheck" } }

ci:build中的--filter=@apps/web...是 Turborepo 的“选择该包及其全部依赖”语法,非常适合 CI 里只构建变更相关的应用。如果在 CI 里觉得 turbo 的缓存不够智能,还可以配合changesets做基于变更集的自动发布,这样共享库的版本迭代也完全自动化。

5.3 把模板推进到 CI

CI 的任务拆分是 Monorepo 比较关键的优化点。

我们先写一个 GitHub Actions 的工作流,实现三个核心步骤:安装、lint + test、build。

name: CI on: push: branches: [main] pull_request: jobs: install: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 8.15.0 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile lint: needs: install runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 8.15.0 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint build: needs: install runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 8.15.0 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm ci:build

重点提一下pnpm install --frozen-lockfile:在 CI 中必须加这个参数,否则它会重新解析依赖并可能生成新的pnpm-lock.yaml,导致与本地环境不一致。如果团队里有 CI 自动升级依赖的小工具(如 Renovate),记得把pnpm-lock.yaml也纳入 PR 检查范围。

6. 我在实操中最常被问到的坑与解决方案

6.1 长等号、字符对齐与日志美化:TypeScript 里的“实用输出”

有同事问过“typescript 怎么输出长等号”之类的问题。实际场景通常是:我们要在终端打印一条分隔线,把日志模块化区分开。TypeScript 里最直接的方式:

const line = "=".repeat(80) console.log(line) console.log("订单处理模块启动") console.log(line)

String.prototype.repeat(80)就能生成 80 个等号的字符串。如果你是想输出带颜色的分隔线,推荐用picocolors

import pc from "picocolors" const divider = pc.bold(pc.cyan("=".repeat(60))) console.log(divider)

另外,在 Monorepo 的子包日志里,建议给每个子包加上独立的前缀,例如[shared][web],这样 Turborepo 交错输出日志时还能分清是哪条流水线的。

6.2 baseUrl 弃用警告的正确迁移姿势

前面已经提到了 TS 6.x 开始对baseUrl打出弃用警告。我实测迁移时遇到的典型报错就是:

Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. Specify compilerOption '???'

警告本身不影响当前编译,但它提示你要逐渐迁移到不依赖baseUrl的路径解析。我在模板里做了以下调整:

  1. tsconfig.base.json中的baseUrl: "."删掉。
  2. 所有子包里的paths路径改为相对于当前tsconfig.json的路径,不使用baseUrl作为基准。
  3. 执行tsc --showConfig对比迁移前后的模块解析结果。
  4. 清理掉代码里可能存在的“伪相对路径”——例如有些人习惯了shared/xxx,这种写法和baseUrl强绑定,迁移后必须统一改成绝对路径或者#shared/xxx之类的路径别名。

我们团队统一用#前缀做路径别名,语义上是“内部模块”,视觉上也和普通依赖区分开,避免把#开头误当成 npm 包。

6.3 其他常见错误收集

我再列一个扫坑清单,都是我或同事实际遇到并且定位过的:

报错现象根本原因推荐解法
子包互相引用时 TS 报“模块解析失败”被引用的包没有生成distexports指向不存在的文件package.jsonexports里标注构建产物路径;构建依赖用 turbodependsOn
pnpm install后子包引用了根目录的依赖却没有声明幽灵依赖打开shamefully-hoist=false,把漏声明的依赖补回子包package.json
TS 编译时“不能将类型仅用作类型”代码里把 type import 和普通 import 混在一起开启consistent-type-imports规则
tsc --build时 references 指向的工程没有composite: true用了 TypeScript Project References 但配置不完整要么补上composite: true,要么放弃 references 改用 turbo 调度
Vite dev server 修改共享包不热更新workspace 符号链接被 Vite 默认optimizeDeps忽略vite.config.ts里增加optimizeDeps.exclude,或者用server.watch.ignored排除**/packages/**/node_modules

每个问题单独展开都能写一篇文章,但核心思想只有一个:Monorepo 的依赖关系必须显式化,任何依赖“巧合可用”的写法都会在团队规模上来之后变成定时炸弹。

还有一个我在模板里经常被忽略但很重要的约定:所有共享包默认开启"sideEffects": false。这个字段配合打包器的 tree-shaking 可以显著减少构建产物体积。如果某个包确实有副作用(比如导入样式文件),要单独标注:

{ "sideEffects": ["**/*.css"] }

结尾:关于这套模板的后话

搭建这套 Monorepo 模板,我最大的体会是:工程化模板不是“一次配置完事”,而是“把约定变成本能”。初始化、应用创建和共享 TypeScript 配齐只是第一步,真正让它产生价值的是团队成员愿意遵守那套目录结构、依赖声明和构建约定。我自己在实操里建议新加入的同事先从一个小任务开始,比如在packages/shared加一个工具函数,然后在apps/web里引用它,这一趟走完基本就能理解 workspace 和共享配置是怎么串起来的。

后续可以扩展的方向很多:要不要引入 Changesets 做自动发包?要不要把 Storybook 放进packages/ui?CI 里要不要加“只测变更子包”的任务?这些都可以在这个模型上继续生长。希望这篇记录能让你少走几次弯路,也欢迎你在自己的项目里踩到新坑之后回来交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询