十条经过实战检验的 TypeScript Monorepo 最佳实践

3412 字
17 分钟
十条经过实战检验的 TypeScript Monorepo 最佳实践

文本核心内容转自 https://medium.com/@kaushalsinh73/10-typescript-monorepo-conventions-that-age-well-c1a6841226f5 原文作者 Neurobyte 个人有整理

前言 ✨#

相信很多同学都有过这样的经历:刚搭建好的 Monorepo 项目用起来行云流水,代码共享、依赖管理都很顺滑 (๑•̀ㅂ•́)و✧ 但半年之后再回头看,整个代码库已经变成了一团乱麻——循环依赖、构建缓慢、发布出错……团队群里每天都在上演”谁又把什么弄坏了”的戏码 😅

其实,Monorepo 的成败关键并不在于使用了多么炫酷的工具链,而在于是否建立了一套朴素但持久的工程化约定。下面这十条实战经验,是我在多个大型项目中总结出来的”防腐剂”,能够帮助团队在代码库规模增长时依然保持高效交付。

说实话,未来的你一定会感谢现在认真阅读这篇文章的自己 (。・ω・。)


1️⃣ 按业务域命名,而不是按技术层命名#

核心原则#

使用业务语言(如 authbillingsearch)来组织包结构,而不是技术层级(如 utilshelperscommon)。这种命名方式能够促使团队建立更清晰的边界划分,也更容易明确代码归属权。

推荐的目录结构#

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 包的依赖
]
}

根目录构建脚本#

Terminal window
# 以依赖图顺序增量构建所有包
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" // 构建并发布
}
}

工作流程#

  1. 开发阶段:完成功能后运行 pnpm changeset,描述变更内容
  2. 版本管理:合并 PR 前运行 pnpm version-packages,自动更新版本号
  3. 发布阶段:运行 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

运行测试#

Terminal window
# 运行所有包的测试
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 Button

2. 每个包保留 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 项目。记住:好的工程化约定,是送给未来自己最好的礼物 🎁


相关资源推荐

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

十条经过实战检验的 TypeScript Monorepo 最佳实践
https://blog.fridolph.top/posts/2026-01-19__monorepo/
作者
Fridolph
发布于
2026-01-19
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录