monorepo项目的一些策略
在monorepo的实际落地项目中,存在一些方案策略问题,比如使用产物依赖还是源码依赖,如何管理公共的tsconfig配置和eslint配置,怎么根据依赖构建和批量发布版本等,本文整理一下这些策略点。
依赖类型
monorepo
├── package1
└── package2
└── src
└── index.ts
假设package2依赖package1,那么在package1中,需要将package2的依赖声明为
"dependencies": {
"package2": "workspace:*"
}
产物依赖
产物引用指的是当前package引用monorepo中其他子package构建后的产物,比如上面的package2是ts编写的,需要提前构建package2,生成对应的产物,同时将其package.json
中的入口文件指向产物
{
"main": "lib/index.js",
"module": "es/index.js",
"type": "lib/index.d.ts",
}
如果是package1和package2同时开发,那么package2每次更新后,都需要指向构建命令重新编译出新的dist产物(或者使用tsc --watch
等watch命令检测到文件变化后重新编译)
这种方式的优势在于
- 每个package的构建是独立的,可以拥有不同的构建配置,甚至是独立的构建工具
劣势在于
- 本地开发package1的热更新链路变长,因为要等待package2的构建完成
- 如果存在多个类似于package2的包,且在同时开发,启动多个watch命令会比较繁琐,同时对电脑的性能也有一定影响
源码依赖
源码引用指的是当前package直接引用其他子package的源码,即在package2的package.json
中,其入口文件直接声明了其源文件
{
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts"
}
这种方式的优势在于
- 子package实际上不需要构建工具,会被当前项目的构建工具编译,因此也不需要提前或者watch执行子package的的构建
- 可以享受到完整的HMR,package2的修改会触发package1的HMR,相当于package2和package1的源码是在一起的效果
劣势在于
- 当前项目的构建文件数量变多,构建效率会下降
ts多项目配置
背景问题
一个monorepo项目,如果采用了ts,大概是下面这种结构
my-repo/
├─ packages/
│ ├─ utils/
│ │ └─ tsconfig.json
│ └─ app/
│ └─ tsconfig.json
└─ tsconfig.json
对于同一个项目里面的多个package而言,希望ts的配置尽量保持统一,降低TypeScript 项目引用配置复杂度
共享tsconfig配置
tsconfig.json
本身有一个extends
的字段,这样就可以避免为每个包声明相同的配置.
创建基础配置文件tsconfig.base.json
供所有包继承:
// 根目录 tsconfig.base.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"incremental": true,
"baseUrl": ".",
"paths": {
"@my-org/utils": ["./packages/utils/src"],
"@my-org/components": ["./packages/components/src"],
"@my-org/types": ["./packages/types/src"]
}
},
"exclude": ["node_modules", "dist", "build"]
}
各个包继承基础配置:
// packages/utils/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["dist", "**/*.test.ts", "**/*.spec.ts"]
}
// packages/components/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx" // 组件包特有配置
},
"include": ["src/**/*"],
"references": [
{ "path": "../utils" },
{ "path": "../types" }
]
}
项目引用
如果项目比较庞大,类型提示较慢,需要增量类型检查的性能优化,可以使用项目引用。
TypeScript 提供了 Project References(项目引用),
{
"compilerOptions": {
"composite": true
}
}
启用 composite
后,表示当前项目是一个“可被引用的独立编译单元”,TypeScript 要求并生成一些额外的构建信息。
开启之后,会自动生成 .tsbuildinfo
文件,这是一个内部缓存文件,用于增量编译。
只有开启了 composite
的项目才能被别的项目通过 "references"
引用。
使用 TypeScript 项目引用
根目录配置项目引用references
:
// 根目录 tsconfig.json
{
"extends": "./tsconfig.base.json",
"files": [],
"references": [
{ "path": "./packages/types" },
{ "path": "./packages/utils" },
{ "path": "./packages/components" },
{ "path": "./apps/web" },
{ "path": "./apps/admin" }
]
}
应用级别的配置:
// apps/web/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx",
"noEmit": true // 应用不需要输出类型文件
},
"include": ["src/**/*", "../../packages/*/src"],
"references": [
{ "path": "../../packages/types" },
{ "path": "../../packages/utils" },
{ "path": "../../packages/components" }
]
}
内部包的类型依赖
背景问题
在monorepo项目中,有些package实际上并不会发布出去,只用于内部包拆分和管理(一般会采用源码依赖)
如果直接用 TypeScript 的 declaration
输出,公共包的 .d.ts
文件会出现类似:
import { InternalType } from '@my-org/internal-utils';
但因为 @my-org/internal-utils
不会发布到 npm仓库,消费者会直接报错:找不到模块声明,导致类型提示错误
API Extractor 的解决方案
API Extractor 是由 Microsoft 开发的一个开源工具,专门用于 TypeScript 库的 API 管理和类型定义文件(.d.ts)的处理。它最初是为了满足大型 TypeScript 项目(如 Office UI Fabric 等)的 API 管理需求而创建的。
API Extractor 的主要作用包括:
API 分析和验证:分析 TypeScript 项目的导出 API,确保 API 的一致性和稳定性,并可以根据预定义规则验证 API 是否符合规范。
类型定义文件(.d.ts)合并:将多个相关的类型定义文件合并成一个单独的 .d.ts 文件,这个功能特别适用于 Monorepo 结构中的包管理,可以将内部依赖的类型定义合并到公共包中。
API 文档生成:可以生成详细的 API 报告和文档,帮助开发者了解项目的 API 结构和变更历史。
版本兼容性检查:通过分析 API 的变更,帮助开发者判断新版本是否与旧版本兼容,从而正确地进行版本语义化管理。
当一个公共包依赖于内部包时,如果不使用 API Extractor,发布的公共包的类型定义文件会包含对内部包的引用,而这些内部包并不会发布到 npm,导致使用者在使用时会出现类型缺失的错误。
API Extractor 通过类型裁剪和合并功能,可以把内部依赖的 .d.ts
打包进公共包的 .d.ts
,避免对外暴露内部依赖。。
具体做法:
在公共包构建时,生成普通
.d.ts
文件。配置
api-extractor.json
:json{ "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", "mainEntryPointFilePath": "<projectFolder>/dist/index.d.ts", "dtsRollup": { "enabled": true, "untrimmedFilePath": "<projectFolder>/dist/<packageName>.d.ts" }, "bundledPackages": [ "@my-org/internal-utils" ] }
bundledPackages
指定了哪些内部依赖需要被“内联”进来,这里就可以将本地monorepo中的私有包配置进来- 最终输出的
.d.ts
会把内部包的类型合并到一个文件中,不再有外部 import。
可选:配合
api-documenter
生成 Markdown 或 API 文档站点.
优缺点
- 优点
- 避免外部用户感知“内部包”的存在。
- API 表面更简洁、稳定。
- 避免“类型缺失”导致的 npm 用户报错。
- 缺点
- 构建复杂度更高(需要维护
api-extractor.json
)。 - 类型合并过程有时需要手动处理冲突或命名空间问题。
- 构建复杂度更高(需要维护
在 monorepo 且存在内部私有包 的情况下,推荐公共包用 API Extractor 来合并类型定义,这样发布出去的 npm 包只需要自己,不会依赖那些不发布的子包。
eslint统一配置
从团队项目的代码风格出发,同一个项目不同package的代码风格需要保持一致,但不同包可能需要不同的 lint 规则,因此需要实现eslint配置文件的继承和覆盖策略
与tsconfig.json
类似,eslintrc.js
也提供了extends
之类的字段,可以从一份公共的eslint配置继承
因此,只需要在根目录统一配置,包级别继承并自定义特殊的校验规则即可。
// 根目录 .eslintrc.js
module.exports = {
extends: ['@my-org/eslint-config-base']
}
// 包级别 .eslintrc.js
module.exports = {
extends: ['../../.eslintrc.js'],
rules: {
// 包特定规则
}
}
公共依赖
对于同一个monorepo而言,团队希望他们的技术栈是统一的,比如希望通过pnpm统一管理第三方依赖的版本,尽量避免同一个依赖包存在多个不同版本的情况,这会影响性能和 bundle 大小
依赖重复检测
需要检查当前项目中不同版本的相同依赖被安装多次的情况
查看每个package中安装的具体包版本依赖
pnpm list -r axios
或者写个脚本分析pnpm-lock.yaml
下某个package具体安装的版本
强制限定某个依赖的版本
根目录的package.json中,通过pnpm.overrides
指定某个package的版本
{
"name": "my-repo",
"private": true,
"version": "1.0.0",
"devDependencies": {},
"pnpm": {
"overrides": {
"axios": "1.12.2",
}
}
}
然后直接重新安装每个package的对应依赖
pnpm -r add axios
构建顺序
如果采用的是产物依赖,还需要考虑构建策略的问题。
构建顺序管理
在 monorepo 中,包之间存在依赖关系时,构建顺序至关重要。
问题:
- 如果 package-a 依赖 package-b,必须先构建 package-b
- 手动管理构建顺序容易出错且效率低下
解决方案:
- 使用 pnpm 的拓扑排序
# 按依赖顺序构建所有包
pnpm -r --filter="./packages/*" run build
# 并行构建(pnpm 会自动处理依赖顺序)
pnpm -r run build
- 使用 Turborepo
Turborepo 是由 Vercel 开发的一个高性能 monorepo 构建系统,解决了 monorepo 的 构建速度、依赖管理和任务调度
// turbo.json
{
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
}
}
}
增量构建
问题:
- 每次构建所有包效率低下
- 只有部分包发生变更时,不需要重新构建所有包
解决方案:
基于 Git 变更的增量构建
bash# 只构建发生变更的包及其依赖者 pnpm -r --filter="...[HEAD~1]" run build
使用构建缓存工具
- Turborepo:提供本地和远程缓存
- Nx:智能构建缓存和依赖图分析
批量发布
在pnpm monorepo项目中,主要有以下几种批量发包的主流方案,比如使用pnpm的pnpm publish -r
命令,使用lerna,使用changesets等。
Changesets
Changesets 是目前最流行的 monorepo 版本管理和发布工具之一,特别适合与 pnpm 配合使用。
特点:
- 基于意图的版本管理:开发者在开发时声明变更的影响级别(major/minor/patch)
- 自动处理包间的依赖关系更新
- 自动生成 changelog
- 支持独立版本和固定版本模式
- 与CI/CD集成良好
基本工作流程:
- 开发者使用
pnpm changeset
命令创建变更集,描述变更内容和影响的包 - 使用
pnpm changeset version
命令更新包版本和生成 changelog - 提交变更并合并到主分支
- 使用
pnpm publish -r
发布所有更新的包
配置示例:
# 安装
pnpm add -Dw @changesets/cli
# 初始化
pnpm changeset init
# 创建变更集
pnpm changeset
# 更新版本
pnpm changeset version
# 发布包
pnpm publish -r
Lerna
Lerna 是早期流行的 monorepo 管理工具,也提供了版本管理和发布功能。
特点:
- 支持固定版本和独立版本模式
- 内置版本管理和发布命令
- 可以自动更新包间的依赖关系
- 社区成熟,文档丰富
局限性:
- 与现代工具链集成不如 Changesets 流畅
- 维护活跃度相对较低
Semantic Release
Semantic Release 是基于提交信息自动发布版本的工具。
特点:
- 根据 Git 提交信息自动确定版本号
- 全自动化发布流程
- 支持插件扩展
适用场景:
- 团队严格遵循约定式提交规范
- 希望完全自动化版本管理流程
推荐方案
对于 pnpm monorepo 项目,强烈推荐使用 Changesets 方案,原因如下:
- 专为 monorepo 设计:Changesets 在设计之初就考虑了 monorepo 场景,能很好地处理包间依赖关系
- 与 pnpm 深度集成:官方文档提供了详细的 pnpm + Changesets 集成指南
- 灵活的工作流程:支持手动审核变更内容后再发布,更适合企业级项目
- 社区活跃:被许多知名开源项目采用,如 pnpm、Astro、SvelteKit 等
- 完善的 CI/CD 支持:提供了 GitHub Actions 等 CI/CD 集成方案
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
