文本核心内容转自 https://medium.com/@kaushalsinh73/10-typescript-monorepo-conventions-that-age-well-c1a6841226f5 原文作者 Neurobyte 个人有整理
前言 ✨
相信很多同学都有过这样的经历:刚搭建好的 Monorepo 项目用起来行云流水,代码共享、依赖管理都很顺滑 (๑•̀ㅂ•́)و✧ 但半年之后再回头看,整个代码库已经变成了一团乱麻——循环依赖、构建缓慢、发布出错……团队群里每天都在上演"谁又把什么弄坏了"的戏码 😅
其实,Monorepo 的成败关键并不在于使用了多么炫酷的工具链,而在于是否建立了一套朴素但持久的工程化约定。下面这十条实战经验,是我在多个大型项目中总结出来的"防腐剂",能够帮助团队在代码库规模增长时依然保持高效交付。
说实话,未来的你一定会感谢现在认真阅读这篇文章的自己 (。・ω・。)
1️⃣ 按业务域命名,而不是按技术层命名
核心原则
使用业务语言(如 auth、billing、search)来组织包结构,而不是技术层级(如 utils、helpers、common)。这种命名方式能够促使团队建立更清晰的边界划分,也更容易明确代码归属权。
推荐的目录结构
apps/
web/ # Web 应用
worker/ # 后台任务处理
packages/
auth/ # 认证授权模块
billing/ # 计费结算模块
search/ # 搜索功能模块
ui/ # UI 组件库为什么能长期有效 💡
业务域是稳定的,即使底层技术栈重构,业务边界也不会轻易改变。而技术层命名(如 utils)往往会成为"垃圾桶",什么都往里塞,最终导致职责不清、难以维护。
当新成员加入团队时,他们能够快速理解 @acme/billing 的职责,但很难搞清楚 @acme/helpers 到底包含了什么功能。
2️⃣ 统一使用 Workspaces + workspace: 协议
选择合适的包管理工具
选择一个包管理工具(推荐 pnpm,因为速度快且依赖管理更严格),并使用 workspace:* 协议来明确声明本地依赖,避免版本耦合问题。
📚 参考资源:
根目录配置示例
// package.json (root)
{
"name": "@acme/monorepo",
"private": true,
"packageManager": "pnpm@9.0.0",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "pnpm -r build", // 递归构建所有包
"test": "pnpm -r test" // 递归测试所有包
}
}子包依赖声明
// apps/web/package.json
{
"name": "@acme/web",
"dependencies": {
"@acme/auth": "workspace:*", // 使用 workspace 协议
"@acme/ui": "workspace:*"
}
}为什么能长期有效 💡
使用 workspace:* 协议能够确保:
- 不会意外发布半成品版本到 npm registry
- 避免同级包之间的 semver 版本漂移,所有本地包始终使用最新代码
- 在发布时,
workspace:*会自动被替换为实际的版本号
3️⃣ 使用严格的 tsconfig.base.json 作为基础配置
核心策略
在仓库根目录创建一个严格的 TypeScript 基础配置,所有子包通过继承该配置来保持一致性。只有在确实需要时,才在子包中添加特定的配置覆盖。
📚 参考资源:
根目录基础配置
// tsconfig.base.json at the repo root
{
"compilerOptions": {
"target": "ES2022", // 目标 ECMAScript 版本
"module": "ESNext", // 模块系统
"moduleResolution": "Bundler", // 模块解析策略
"lib": ["ES2022", "DOM"], // 包含的类型库
"strict": true, // 启用所有严格类型检查选项
"noUncheckedIndexedAccess": true, // 索引访问时添加 undefined 检查
"exactOptionalPropertyTypes": true, // 可选属性类型严格匹配
"skipLibCheck": true, // 跳过声明文件检查(提升性能)
"declaration": true, // 生成 .d.ts 声明文件
"declarationMap": true, // 生成声明文件的 source map
"verbatimModuleSyntax": true, // 保持模块语法原样输出
"isolatedModules": true // 确保每个文件都能独立编译
}
}子包配置示例
// packages/auth/tsconfig.json
{
"extends": "../../tsconfig.base.json", // 继承基础配置
"compilerOptions": {
"outDir": "dist", // 输出目录
"rootDir": "src", // 源码根目录
"composite": true // 启用项目引用
},
"include": ["src"]
}为什么能长期有效 💡
统一的基础配置能够:
- 避免风格漂移:所有包使用相同的类型检查规则
- 防止类型安全退化:新包默认继承严格模式,不会因为疏忽而降低类型安全标准
- 简化维护成本:需要调整配置时,只需修改一个文件
4️⃣ 使用 TypeScript Project References + Build Mode
核心价值
这个配置决定了你的 Monorepo 究竟是"任何改变都触发全量构建",还是"只构建变动部分"。对于大型项目来说,增量构建能够节省数十倍的构建时间。
📚 参考资源:
配置示例
// packages/ui/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true, // 必须启用 composite
"outDir": "dist",
"rootDir": "src"
},
"references": [
{ "path": "../auth" } // 声明对 auth 包的依赖
]
}根目录构建脚本
# 以依赖图顺序增量构建所有包
tsc -b packages/*
# watch 模式下使用 references 实现增量编译
tsc -b -w为什么能长期有效 💡
随着依赖图规模扩大,构建依然保持增量而不是变慢:
- TypeScript 会自动分析依赖关系,只重新构建受影响的包
- 生成的
.tsbuildinfo文件记录了构建状态,支持跨机器的增量构建 - 在 CI/CD 环境中,可以利用缓存进一步加速构建
5️⃣ 统一库构建工具:库用 tsup,开发用 tsx
核心理念
不要同时操控多个 bundler。保持工具链简单、统一、可预期。
📚 参考资源:
配置示例
// packages/auth/package.json
{
"name": "@acme/auth",
"type": "module", // 使用 ES Module
"main": "./dist/index.cjs", // CommonJS 入口
"module": "./dist/index.mjs", // ES Module 入口
"types": "./dist/index.d.ts", // TypeScript 类型声明
"scripts": {
"dev": "tsx watch src/index.ts", // 开发模式:使用 tsx watch
"build": "tsup src/index.ts --dts --format esm,cjs --clean" // 构建:同时输出 ESM 和 CJS
}
}为什么能长期有效 💡
- tsup 基于 esbuild,构建速度极快,配置简单,支持 TypeScript 开箱即用
- tsx 可以直接运行 TypeScript 文件,无需预编译,非常适合开发调试
- 团队可能每年都会想换 bundler,但你不需要 —— 这两个工具足够快且可预期
6️⃣ 使用干净的 exports,不允许 deep imports
核心原则
只暴露你希望暴露的内容。应用层不应该通过 packages/ui/src/button 这种路径直接导入内部实现。
📚 参考资源:
配置示例
// packages/ui/package.json
{
"name": "@acme/ui",
"type": "module",
"sideEffects": false, // 声明无副作用,支持 tree-shaking
"exports": {
".": { // 主入口
"types": "./dist/index.d.ts", // TypeScript 类型
"import": "./dist/index.mjs", // ES Module 格式
"require": "./dist/index.cjs" // CommonJS 格式
}
},
"files": ["dist"] // 只发布 dist 目录
}正确的导入方式
// ✅ 正确:通过公开的入口导入
import { Button, Input } from '@acme/ui'
// ❌ 错误:直接导入内部实现
import { Button } from '@acme/ui/src/components/button'为什么能长期有效 💡
- 包内部的重命名不会影响整个 Monorepo:只要公开 API 不变,内部结构可以随意调整
- 强制 API 设计思考:必须明确哪些是公开接口,哪些是内部实现
- 支持更好的 tree-shaking:打包工具能够准确分析依赖关系
7️⃣ 使用 Changesets 管理版本发布
核心价值
人类可读的变更说明现在写好;自动化 semver 稍后执行。这种方式能够确保每次发布都有清晰的变更记录。
📚 参考资源:
配置示例
// .changeset/config.json
{
"changelog": "@changesets/cli/changelog", // 变更日志生成器
"commit": false, // 不自动提交
"linked": [], // 关联发布的包组
"access": "public", // npm 发布权限
"baseBranch": "main" // 基准分支
}根目录脚本配置
// root package.json
{
"scripts": {
"changeset": "changeset", // 创建 changeset
"version-packages": "changeset version", // 更新版本号和 CHANGELOG
"release": "pnpm -r build && changeset publish" // 构建并发布
}
}工作流程
- 开发阶段:完成功能后运行
pnpm changeset,描述变更内容 - 版本管理:合并 PR 前运行
pnpm version-packages,自动更新版本号 - 发布阶段:运行
pnpm release,自动构建并发布到 npm
为什么能长期有效 💡
- 改动意图清晰:每个 changeset 都包含了变更描述和影响范围
- 版本管理自动化:根据 changeset 自动计算版本号(major/minor/patch)
- 不再有"到底发布了啥?"的疑问:CHANGELOG 自动生成,清晰明了
8️⃣ 用 ESLint 强化边界,而不是靠团队默契
核心理念
明确规定**"谁可以 import 谁"**,通过工具强制执行,而不是依赖口头约定。这样能够减少争议,防止循环依赖。
📚 参考资源:
配置示例
// .eslintrc.cjs (root)
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "import"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
rules: {
// 限制跨包导入路径
"import/no-restricted-paths": ["error", {
"zones": [
// ui 包不能导入 auth 包(UI 组件不应依赖业务逻辑)
{
"target": "./packages/auth",
"from": "./packages/ui"
},
// auth 包不能导入 billing 包(避免业务模块循环依赖)
{
"target": "./packages/billing",
"from": "./packages/auth"
}
]
}],
// 禁止循环依赖
"import/no-cycle": "error"
}
}实际效果
当开发者尝试违反规则时,ESLint 会立即报错:
// packages/ui/src/Button.tsx
import { login } from '@acme/auth' // ❌ ESLint 错误:ui 不能导入 auth为什么能长期有效 💡
- 边界设定存活在工具里,而不是口口相传的默契
- 新成员加入时不会破坏架构:违反规则时 CI 会自动失败
- 重构时更有信心:知道哪些依赖是被允许的,哪些需要重新设计
9️⃣ 统一测试运行器:Vitest Workspace
核心价值
保持测试快速且一致。测试文件与代码邻近;从根目录一次性运行所有测试。
📚 参考资源:
配置示例
// vitest.workspace.ts at the root
import { defineWorkspace } from 'vitest/config'
export default defineWorkspace([
// 认证模块测试
{
test: {
include: ['packages/auth/src/**/*.test.ts']
}
},
// UI 组件测试
{
test: {
include: ['packages/ui/src/**/*.test.tsx']
}
},
// Web 应用测试
{
test: {
include: ['apps/web/src/**/*.test.tsx']
}
},
])测试文件组织
packages/auth/src/
├── login.ts
├── login.test.ts # 测试文件与源码邻近
├── register.ts
└── register.test.ts运行测试
# 运行所有包的测试
pnpm test
# 运行特定包的测试
pnpm --filter @acme/auth test
# watch 模式
pnpm test --watch为什么能长期有效 💡
- 共用 reporters、快照与覆盖率配置:不需要为每个包单独配置
- 测试速度快:Vitest 基于 Vite,支持并行测试和智能缓存
- 开发体验好:HMR 支持,修改测试后立即看到结果
🔟 集中管理环境变量类型:用 Zod 校验
核心理念
不要把 process.env.FOO 散落在代码各处。在一个专门的包中验证环境变量,确保类型安全,到处复用。
📚 参考资源:
实现示例
// packages/env/src/index.ts
import { z } from "zod"
// 定义环境变量 schema
const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(), // 必须是合法的 URL
PORT: z.coerce.number().int().default(3000) // 自动转换为数字,默认 3000
})
// 在应用启动时立即验证
export const env = schema.parse(process.env)
// 导出类型供 TypeScript 使用
export type Env = z.infer<typeof schema>在应用中使用
// apps/web/src/server.ts
import { env } from "@acme/env"
// ✅ 类型安全:env.PORT 是 number 类型
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`)
})
// ✅ 如果环境变量不合法,应用启动时就会抛出错误
// 而不是在运行时某个不确定的时刻崩溃为什么能长期有效 💡
- 环境配置不正确会尽早失败:应用启动时就会检查,而不是凌晨两点崩在生产环境
- 类型提示完整:TypeScript 能够推断出每个环境变量的类型
- 文档即代码:schema 本身就是环境变量的文档
💎 一些微小但长期有效的习惯
除了上述十条核心约定,以下这些小习惯也能显著提升 Monorepo 的可维护性:
1. 库中优先使用 named exports
// ✅ 推荐:named exports 更易重构
export { Button } from './Button'
export { Input } from './Input'
// ❌ 避免:default export 重命名时容易出错
export default Button2. 每个包保留 README.md
为每个包编写简洁的 README,说明其职责和使用示例:
# @acme/auth
认证授权模块,提供登录、注册、权限验证等功能。
## 安装
\`\`\`bash
pnpm add @acme/auth
\`\`\`
## 使用示例
\`\`\`typescript
import { login } from '@acme/auth'
const user = await login({ email, password })
\`\`\`3. 在 CODEOWNERS 中标注模块负责人
# .github/CODEOWNERS
/packages/auth/ @team-security
/packages/billing/ @team-payments
/packages/ui/ @team-design-system这样可以自动分流 PR 评审,提高响应速度。
4. 添加 prepack 脚本
确保发布前构建正确:
{
"scripts": {
"prepack": "pnpm build" // npm publish 前自动执行
}
}结语 🎯
Monorepo 并不会因为某个"大问题"而失败,而是因为无数个小问题不断累积。以上十条约定能够显著减少团队规模、包数量和需求增加所带来的摩擦。
这些实践都是在真实项目中经过验证的,它们的共同特点是:
- ✅ 简单朴素:不依赖复杂的工具链
- ✅ 持久有效:不会因为技术栈变化而失效
- ✅ 易于执行:可以通过工具自动化强制执行
如果你也有经历战火、价值连城的 Monorepo 经验技巧,欢迎在评论区分享 —— 我一定会认真学习(当然也会注明出处)(。・ω・。)ノ♡
希望这篇文章能帮助你构建一个健康、可持续的 Monorepo 项目。记住:好的工程化约定,是送给未来自己最好的礼物 🎁
相关资源推荐:
- 本文链接:https://fridolph.top/posts/2026-01-19__monorepo
- 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 许可协议。