TypeScript Monorepo 依赖边界治理:路径别名、包导出与循环依赖怎么控

HTMLPAGE 团队
17 分钟阅读

Monorepo 真正难的不是把代码放进 packages 目录,而是防止边界继续假装存在。本文从路径别名、包导出、循环依赖和层级约束出发,讲清 TypeScript Monorepo 的依赖边界治理方法。

#TypeScript #Monorepo #Dependency Boundaries #Path Alias #Circular Dependency

很多团队以为把仓库改成 Monorepo,就等于自动获得了更清晰的架构边界。现实通常相反:目录拆开以后,如果导入规则和包导出没有跟上,边界反而更容易被误判。看起来大家都在 packages/ 里按模块开发,实际上应用层照样可以直连别的包内部文件,公共包也可能反过来依赖业务包,最后只是在更漂亮的目录结构里继续写大泥球。

TypeScript 在 Monorepo 里的边界治理价值,重点不在“写路径别名更优雅”,而在于把依赖方向、可访问层级和循环依赖问题显式化。只要这些问题仍然靠团队自觉,仓库规模一上来,约定就会被现实一点点磨穿。

路径别名可以改善可读性,但它不是边界本身

路径别名最直接的好处当然是避免这类导入:

import { formatMoney } from '../../../../shared/src/utils/formatMoney'

改成统一别名后,代码看起来确实更干净:

import { formatMoney } from '@shared/utils/formatMoney'

问题在于,这样只是把“深层穿透”写得更像正常行为。如果别名直接指向另一个包的内部源码目录,你只是把问题包装得更好看了。真正该问的是:这个导入是否应该存在?它是不是应该只能通过包的公共入口访问?

包导出决定了“别人能看到什么”,它比 alias 更接近真实边界

在 Monorepo 里,package.jsonexports 才是更接近边界事实的工具。因为它不是在改善写法,而是在定义允许被消费的表面。

{
  "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,只有稳定、通用、不会携带业务特例的能力才值得放进去。

可执行的边界治理动作

  1. 先明确每个包属于哪一层,而不是只看目录名字。
  2. exports 收窄公共包的可见入口。
  3. 把路径别名指向公共入口,而不是内部源码目录。
  4. 用 lint、图分析或 CI 检查循环依赖和越层依赖。
  5. 定期审视 shared 包是否开始吸收业务特例。

边界治理最怕的不是规则少,而是规则只停在文档里。只要这些动作没进入自动化流程,仓库规模一大,例外就会持续积累。

总结

TypeScript Monorepo 的真正挑战,不是把 import 写短,而是把依赖方向做实。路径别名改善阅读体验,包导出定义可见表面,层级约束阻止向上反依赖,循环依赖检查负责尽早暴露结构问题。四件事缺一不可,否则 Monorepo 只是把大泥球切成了多个文件夹而已。

本系列导航:

延伸阅读: