十条经过实战检验的 TypeScript Monorepo 最佳实践
文本核心内容转自 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" // 递归测试所有包 }}子包依赖声明
{ "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 // 确保每个文件都能独立编译 }}子包配置示例
{ "extends": "../../tsconfig.base.json", // 继承基础配置 "compilerOptions": { "outDir": "dist", // 输出目录 "rootDir": "src", // 源码根目录 "composite": true // 启用项目引用 }, "include": ["src"]}为什么能长期有效 💡
统一的基础配置能够:
- 避免风格漂移:所有包使用相同的类型检查规则
- 防止类型安全退化:新包默认继承严格模式,不会因为疏忽而降低类型安全标准
- 简化维护成本:需要调整配置时,只需修改一个文件
4️⃣ 使用 TypeScript Project References + Build Mode
核心价值
这个配置决定了你的 Monorepo 究竟是”任何改变都触发全量构建”,还是”只构建变动部分”。对于大型项目来说,增量构建能够节省数十倍的构建时间。
📚 参考资源:
配置示例
{ "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。保持工具链简单、统一、可预期。
📚 参考资源:
配置示例
{ "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 这种路径直接导入内部实现。
📚 参考资源:
配置示例
{ "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 稍后执行。这种方式能够确保每次发布都有清晰的变更记录。
📚 参考资源:
配置示例
{ "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 会立即报错:
import { login } from '@acme/auth' // ❌ ESLint 错误:ui 不能导入 auth为什么能长期有效 💡
- 边界设定存活在工具里,而不是口口相传的默契
- 新成员加入时不会破坏架构:违反规则时 CI 会自动失败
- 重构时更有信心:知道哪些依赖是被允许的,哪些需要重新设计
9️⃣ 统一测试运行器:Vitest Workspace
核心价值
保持测试快速且一致。测试文件与代码邻近;从根目录一次性运行所有测试。
📚 参考资源:
配置示例
// vitest.workspace.ts at the rootimport { 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 散落在代码各处。在一个专门的包中验证环境变量,确保类型安全,到处复用。
📚 参考资源:
实现示例
import { z } from "zod"
// 定义环境变量 schemaconst 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>在应用中使用
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
认证授权模块,提供登录、注册、权限验证等功能。
## 安装
\`\`\`bashpnpm add @acme/auth\`\`\`
## 使用示例
\`\`\`typescriptimport { 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 项目。记住:好的工程化约定,是送给未来自己最好的礼物 🎁
相关资源推荐:
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!