TypeScript 大型项目架构:从单体 tsconfig 到分层可扩展的工程

文章来源声明: 原文作者:前端阿凡; 来源站点:掘金; 原文链接:https://juejin.cn/post/7685962011589361673; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

references切编译域、分层类型化守边界,是大型TS工程的两条命脉;存量项目走渐进三车道迁移最实用。适合中后台巨型单体、Monorepo组件库与JS老系统改造参考。

1. 技术难点:项目一大,类型系统先崩的三种姿势 ------------------------

Day09 到 Day13 解决的是"单个类型怎么写才快、才正确",但真实的大型项目里,类型问题很少是单个类型写错,而是结构的腐烂。代码行数过十万之后,TypeScript 会以三种方式报复你:

  1. 编译域不分,全仓一个 tsconfig。几十个包、几万文件共用一个 tsconfig.jsontsc 每次全量检查整个依赖图。任何包的类型改动都会波及其他所有包,编译从 3 秒涨到 3 分钟,IDE 的智能提示时灵时不灵。这本质上和"一个进程里所有模块共享全局变量"是同一类问题:耦合的是类型域,不是类型本身
  2. 分层边界被类型穿透。UI 层直接依赖数据库 DTO 的类型,领域层函数签名里出现 axios 的 AxiosResponse<T>,基础设施的类型顺着 import 一路泄漏到视图层。结果是你改一个 ORM 模型,全项目几百个文件飘红。架构分层在运行时可能守得住,但类型系统会诚实地把你打破边界的每一笔账都记下来。
  3. 类型版本漂移与第三方耦合@types/react 升了 minor,某个深层依赖的类型定义和你锁的版本打架;第三方库的类型定义直接被你 import 的类型别名泄漏到全项目,你想换库的时候,改的不只是一个文件,而是一整片类型网。

还有一个隐形的难点:大型项目往往不是绿地。老代码是 JS、是 any、是十年前的模式。TypeScript 的收益(编译期安全)和成本(迁移期全项目飘红、构建变慢)在存量项目里是直接冲突的,很多人项目死在"一次性全量迁移"这一步。

  1. 完整解法:四层地基 + 三种武器

2.1 分层架构类型化:让依赖方向变成类型约束

经典的分层(UI → 应用服务 → 领域 ← 基础设施)在 TS 里要落到类型上,靠的不是文档,而是每一层只依赖下一层的抽象,不依赖实现。核心手法是"接口下沉 + 依赖注入":

<span>// domain/ports/user-repository.ts —— 领域层只认抽象,不认实现</span>
<span>export</span> <span>interface</span> <span>UserRepository</span> {
  <span>findById</span>(<span>id</span>: <span>UserId</span>): <span>Promise</span><<span>User</span> | <span>null</span>>;
  <span>save</span>(<span>user</span>: <span>User</span>): <span>Promise</span><<span>void</span>>;
}

<span>// infrastructure/repos/typeorm-user-repository.ts —— 实现细节锁在基础设施层</span>
<span>export</span> <span>class</span> <span>TypeOrmUserRepository</span> <span>implements</span> <span>UserRepository</span> {
  <span>constructor</span>(<span><span>private</span> <span>readonly</span> dataSource: DataSource</span>) {}
  <span>async</span> <span>findById</span>(<span>id: UserId</span>) {
    <span>const</span> row = <span>await</span> <span>this</span>.<span>dataSource</span>.<span>getRepository</span>(<span>UserEntity</span>).<span>findOneBy</span>({ id });
    <span>return</span> row ? <span>mapEntityToDomain</span>(row) : <span>null</span>; <span>// 防腐层:Entity -> Domain</span>
  }
}

<span>// application/use-cases/get-user.ts —— 应用层只依赖抽象</span>
<span>export</span> <span>function</span> <span>makeGetUser</span>(<span>repo: UserRepository</span>) {
  <span>return</span> <span>async</span> (<span>id</span>: <span>UserId</span>): <span>Promise</span><<span>User</span>> => repo.<span>findById</span>(id);
}

两条硬规则,配合 lint 检查强制执行:

  • 防腐层映射是唯一允许出现 Entity 的地方。领域类型和 DTO 之间用 mapEntityToDomain 这种纯函数转换,禁止把 UserEntity 直接塞进领域层参数。这样 ORM 换掉时,领域层一个字节都不用改。
  • 基础设施类型不许越界。给 ESLint 配 no-restricted-imports,禁止业务层 import axiostypeormfs 等基础设施包的路径。类型泄漏通常从 import 泄漏开始,堵住 import 就堵住了大头。

2.2 project references:把编译域切开

这是大型项目根治编译慢的官方武器。原理:把项目拆成若干 composite 子项目,每个子项目有自己的 tsconfig.json 和声明文件产物,父项目通过 references 引用的是子项目的 .d.ts 而非源码。于是类型检查变成增量 + 局部:改 UI 层代码,只会重查 UI 层;改基础设施层,只重查它自己的声明文件。

// tsconfig.base.json —— 公共编译选项
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "strict": true,
    "composite": true,
    "declaration": true,
    "skipLibCheck": true,
    "incremental": true,
    "paths": {
      "@domain/*": ["packages/domain/src/*"],
      "@app/*": ["packages/application/src/*"]
    }
  }
}

// packages/domain/tsconfig.json —— 叶子包,无 references
{
  "extends": "../../tsconfig.base.json",
  "include": ["src"]
}

// packages/application/tsconfig.json —— 只引用它真正依赖的包
{
  "extends": "../../tsconfig.base.json",
  "references": [{ "path": "../domain" }],
  "include": ["src"]
}

// packages/web/tsconfig.json —— 入口包,引用全部依赖
{
  "extends": "../../tsconfig.base.json",
  "references": [{ "path": "../domain" }, { "path": "../application" }],
  "include": ["src"]
}

根目录一个 tsconfig.json 只干一件事:引用所有子项目:

{
  "files": [],
  "references": [
    { "path": "./packages/domain" },
    { "path": "./packages/application" },
    { "path": "./packages/web" }
  ]
}

tsc -b(build 模式)替代 tsc,它懂得拓扑排序、跳过未变动的子项目、复用 .tsbuildinfo。注意 paths 别和 references 打架:composite 项目要求声明文件先构建出来,所以根目录先 tsc -b packages/domain 再引用它,IDE 里按引用关系自动处理,命令行 CI 里按拓扑序构建即可。

2.3 渐进迁移:JS 老代码的"三条车道"

存量项目不要一次性切。把代码按"类型价值"分三条车道,各走各的节奏:

// tsconfig 车道一:老 JS 代码,允许但不强制
{
  "compilerOptions": {
    "allowJs": true,
    "checkJs": false
  }
}

  1. 车道一(存量 JS)allowJs: true, checkJs: false,先让 JS 文件参与编译、享受模块解析,但不断言类型。零成本接入,不飘红。
  2. 车道二(关键路径 JS):在 // @ts-check 注释标记的文件上开 checkJs,配合 JSDoc 类型标注,把最核心的服务层先"JSDoc 化",拿到类型检查但不写 .ts 后缀,改造成本最低。
  3. 车道三(新增代码):新文件一律 .tsstrict: true。API 入口处用运行时校验(zod/io-ts)做"类型隔离带":外部数据先 safeParse,通过后的对象已经携带精确类型,下游就安全了。

配套一个逃生舱登记制度any 不能悄悄出现,统一收敛到一个 src/types/escape-hatch.ts,每个 any 必须写注释说明"为什么这里先不管 + 计划哪天回来治理"。让 any 变成可见的技术债,而不是散落的定时炸弹。

2.4 类型依赖治理:锁版本 + 供应商隔离

  • @types/* 全部锁死 minor,升级走 dependabot PR,CI 里对 @types 的 major 升级单独跑一次全量 tsc 回归。类型依赖的破坏性往往发生在"无声"处:编译过了,但推导变宽了。
  • 第三方类型用"供应商别名"隔离。不要在业务代码里直接 import { AxiosResponse },而是 import type { ApiResponse } from '@core/http',在 @core/http 内部做一次类型适配。换库时只改这一个文件,且可以用 module augmentation 对第三方类型做项目级修补(比如给 express.Request 扩展 user 字段),而不是到处 as any
<span>// src/types/express-augment.d.ts</span>
<span>import</span> <span>'express'</span>;
<span>declare</span> <span>module</span> <span>'express-serve-static-core'</span> {
  <span>interface</span> <span>Request</span> {
    user?: { <span>id</span>: <span>string</span>; <span>role</span>: <span>'admin'</span> | <span>'member'</span> };
  }
}

2.5 三件工程武器:alias、barrel 节制、CI 分层检查

  • path alias 统一为 @xxx/ 前缀,防止 ../../../../ 深坑,也让包间边界在 import 路径上一眼可见,配合 eslint-plugin-importno-restricted-paths 把跨层 import 变成编译期+CI 期双重报错。
  • 巨型 barrel 文件(index.ts 里 re-export 一切)慎用:re-export 会让类型域隐式互相可见,是循环依赖和"为什么我改了 A 包 B 包全重查"的常见来源。叶子模块出口按需暴露,别图省事一把梭。
  • CI 里把 type-checklint 分开跑,type-check 用 tsc -b(增量、可缓存),lint 只跑变更文件(lint-staged)。提交前 git diff --name-only 圈定范围,把"全量编译"留给主分支的夜间流水线。
  1. 应用场景

  • 中后台巨型单体:几十万行代码、几十个路由模块。用 2.1 分层 + 2.2 references 后,最常见的收益是 IDE 从"改一行卡三秒"恢复到"秒开跳转",以及换 ORM/换 HTTP 客户端时改动面从"全项目"缩到"防腐层一个目录"。
  • Monorepo 组件库uithemeutilsicons 各成子项目。composite 让组件库的声明文件成为其他包的"编译缓存",ui 改了只重查 ui 自己,消费方拿到的是稳定 .d.ts
  • JS 存量系统改造:按 2.3 三条车道,先让新功能带类型进场、核心服务 JSDoc 化、API 边界加 zod,三个月内把"最痛 20% 的代码"迁到严格模式,风险可控、随时可回滚。
  1. 总结

大型项目的 TypeScript 架构,核心是把两件事做对:边界。边界靠分层类型化(接口下沉、防腐层、import 限制)守住,域靠 project references(每个子项目独立编译、增量缓存)切开。存量代码用渐进三车道降风险,any 用逃生舱制度做成可见负债。回头看 Day09-13 那些类型体操,它们解决的是"单点快不快、准不准",而 Day14 解决的是"整体活不活得下去"。一个大型 TS 工程的健康度,可以用三个数字体检:tsc -b 全量时间、any 登记表长度、跨层 import 被 lint 拦下的次数。这三个数字长期不涨,架构就还活着。