很多团队以为把仓库改成 Monorepo,就等于自动获得了更清晰的架构边界。现实通常相反:目录拆开以后,如果导入规则和包导出没有跟上,边界反而更容易被误判。看起来大家都在 packages/ 里按模块开发,实际上应用层照样可以直连别的包内部文件,公共包也可能反过来依赖业务包,最后只是在更漂亮的目录结构里继续写大泥球。
TypeScript 在 Monorepo 里的边界治理价值,重点不在“写路径别名更优雅”,而在于把依赖方向、可访问层级和循环依赖问题显式化。只要这些问题仍然靠团队自觉,仓库规模一上来,约定就会被现实一点点磨穿。
路径别名可以改善可读性,但它不是边界本身
路径别名最直接的好处当然是避免这类导入:
import { formatMoney } from '../../../../shared/src/utils/formatMoney'
改成统一别名后,代码看起来确实更干净:
import { formatMoney } from '@shared/utils/formatMoney'
问题在于,这样只是把“深层穿透”写得更像正常行为。如果别名直接指向另一个包的内部源码目录,你只是把问题包装得更好看了。真正该问的是:这个导入是否应该存在?它是不是应该只能通过包的公共入口访问?
包导出决定了“别人能看到什么”,它比 alias 更接近真实边界
在 Monorepo 里,package.json 的 exports 才是更接近边界事实的工具。因为它不是在改善写法,而是在定义允许被消费的表面。
{
"name": "@acme/shared",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
一旦公共包只导出入口文件,下游再想去 import src/internal,就不会只是“代码风格不佳”,而会直接碰到机制约束。这正是边界治理需要的效果:让错误路径变难,而不是继续靠 code review 提醒。
循环依赖不是“等构建报错再说”,而是结构已经开始反咬你
循环依赖在 Monorepo 里尤其危险,因为它不一定马上导致构建失败。更常见的情况是:
- 类型推导突然变慢
- 某些运行时导入顺序出现诡异问题
- 公共包越来越难抽离或单独测试
典型路径是这样的:
- 业务包 A 依赖公共包 B 的工具函数。
- 公共包 B 后来又顺手引用了 A 里的某个业务常量或类型。
- 仓库一开始还能跑,时间久了依赖方向就彻底乱掉。
只要公共层开始反向引用业务层,说明架构层次已经被打穿。循环依赖真正可怕的地方,不是 import 图不好看,而是所有“高层依赖低层”的假设都失效了。
更稳的做法:先定义层,再定义允许的依赖方向
很多 Monorepo 治理失败,是因为团队先有 packages,再慢慢猜这些包应该处在哪一层。更稳的方式通常是先抽象层级,例如:
- 基础层:纯工具、类型、schema
- 平台层:UI 组件、SDK、公共服务
- 业务层:领域逻辑、页面、应用
然后再决定依赖方向:业务层可以向下依赖平台层和基础层,平台层可以依赖基础层,基础层不能向上反依赖。只要这个方向没有先讲清,后面 alias、引用图和 lint 规则都只能算补救。
一个常见失败案例:共享包看起来最公共,实际塞满业务特例
某团队的 shared 包一开始只有日期、字符串和请求工具,后来为了图方便,逐渐塞进了营销活动常量、某个页面特有的表单字段映射,甚至某个业务模块的枚举。这样做短期很快,长期却让 shared 变成“谁都能往里塞,但没人敢拆”的黑洞。
最后仓库里出现两个后果:
- 几乎所有包都依赖
shared,任何改动都有大面积影响。 shared内部其实又反向依赖某些业务包,形成隐性环。
这类问题表面是依赖图混乱,根因其实是没有明确公共层的准入标准。不是所有“多个地方都能用到”的东西都该进 shared,只有稳定、通用、不会携带业务特例的能力才值得放进去。
可执行的边界治理动作
- 先明确每个包属于哪一层,而不是只看目录名字。
- 用
exports收窄公共包的可见入口。 - 把路径别名指向公共入口,而不是内部源码目录。
- 用 lint、图分析或 CI 检查循环依赖和越层依赖。
- 定期审视 shared 包是否开始吸收业务特例。
边界治理最怕的不是规则少,而是规则只停在文档里。只要这些动作没进入自动化流程,仓库规模一大,例外就会持续积累。
总结
TypeScript Monorepo 的真正挑战,不是把 import 写短,而是把依赖方向做实。路径别名改善阅读体验,包导出定义可见表面,层级约束阻止向上反依赖,循环依赖检查负责尽早暴露结构问题。四件事缺一不可,否则 Monorepo 只是把大泥球切成了多个文件夹而已。
本系列导航:
- 如果你还没把编译边界拆开,先回头读 TypeScript 项目引用与 tsconfig 分层
- 若你已经在治理依赖图,再接着看 TypeScript 类型检查性能优化
- 如果你还要继续收口对外契约边界,可读 TypeScript 公共 API 设计
延伸阅读:


