1. 技术难点:项目一大,类型系统先崩的三种姿势
Day09 到 Day13 解决的是"单个类型怎么写才快、才正确",但真实的大型项目里,类型问题很少是单个类型写错,而是结构的腐烂。代码行数过十万之后,TypeScript 会以三种方式报复你:
- 编译域不分,全仓一个 tsconfig 。几十个包、几万文件共用一个
tsconfig.json,tsc每次全量检查整个依赖图。任何包的类型改动都会波及其他所有包,编译从 3 秒涨到 3 分钟,IDE 的智能提示时灵时不灵。这本质上和"一个进程里所有模块共享全局变量"是同一类问题:耦合的是类型域,不是类型本身。 - 分层边界被类型穿透 。UI 层直接依赖数据库 DTO 的类型,领域层函数签名里出现 axios 的
AxiosResponse<T>,基础设施的类型顺着 import 一路泄漏到视图层。结果是你改一个 ORM 模型,全项目几百个文件飘红。架构分层在运行时可能守得住,但类型系统会诚实地把你打破边界的每一笔账都记下来。 - 类型版本漂移与第三方耦合 。
@types/react升了 minor,某个深层依赖的类型定义和你锁的版本打架;第三方库的类型定义直接被你import的类型别名泄漏到全项目,你想换库的时候,改的不只是一个文件,而是一整片类型网。
还有一个隐形的难点:大型项目往往不是绿地 。老代码是 JS、是 any、是十年前的模式。TypeScript 的收益(编译期安全)和成本(迁移期全项目飘红、构建变慢)在存量项目里是直接冲突的,很多人项目死在"一次性全量迁移"这一步。
2. 完整解法:四层地基 + 三种武器
2.1 分层架构类型化:让依赖方向变成类型约束
经典的分层(UI → 应用服务 → 领域 ← 基础设施)在 TS 里要落到类型上,靠的不是文档,而是每一层只依赖下一层的抽象,不依赖实现。核心手法是"接口下沉 + 依赖注入":
ts
// domain/ports/user-repository.ts ------ 领域层只认抽象,不认实现
export interface UserRepository {
findById(id: UserId): Promise<User | null>;
save(user: User): Promise<void>;
}
// infrastructure/repos/typeorm-user-repository.ts ------ 实现细节锁在基础设施层
export class TypeOrmUserRepository implements UserRepository {
constructor(private readonly dataSource: DataSource) {}
async findById(id: UserId) {
const row = await this.dataSource.getRepository(UserEntity).findOneBy({ id });
return row ? mapEntityToDomain(row) : null; // 防腐层:Entity -> Domain
}
}
// application/use-cases/get-user.ts ------ 应用层只依赖抽象
export function makeGetUser(repo: UserRepository) {
return async (id: UserId): Promise<User> => repo.findById(id);
}
两条硬规则,配合 lint 检查强制执行:
- 防腐层映射是唯一允许出现
Entity的地方 。领域类型和 DTO 之间用mapEntityToDomain这种纯函数转换,禁止把UserEntity直接塞进领域层参数。这样 ORM 换掉时,领域层一个字节都不用改。 - 基础设施类型不许越界 。给 ESLint 配
no-restricted-imports,禁止业务层 importaxios、typeorm、fs等基础设施包的路径。类型泄漏通常从 import 泄漏开始,堵住 import 就堵住了大头。
2.2 project references:把编译域切开
这是大型项目根治编译慢 的官方武器。原理:把项目拆成若干 composite 子项目,每个子项目有自己的 tsconfig.json 和声明文件产物,父项目通过 references 引用的是子项目的 .d.ts 而非源码。于是类型检查变成增量 + 局部:改 UI 层代码,只会重查 UI 层;改基础设施层,只重查它自己的声明文件。
jsonc
// 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 只干一件事:引用所有子项目:
jsonc
{
"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 老代码的"三条车道"
存量项目不要一次性切。把代码按"类型价值"分三条车道,各走各的节奏:
jsonc
// tsconfig 车道一:老 JS 代码,允许但不强制
{
"compilerOptions": {
"allowJs": true,
"checkJs": false
}
}
- 车道一(存量 JS) :
allowJs: true, checkJs: false,先让 JS 文件参与编译、享受模块解析,但不断言类型。零成本接入,不飘红。 - 车道二(关键路径 JS) :在
// @ts-check注释标记的文件上开checkJs,配合 JSDoc 类型标注,把最核心的服务层先"JSDoc 化",拿到类型检查但不写.ts后缀,改造成本最低。 - 车道三(新增代码) :新文件一律
.ts且strict: 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:
ts
// src/types/express-augment.d.ts
import 'express';
declare module 'express-serve-static-core' {
interface Request {
user?: { id: string; role: 'admin' | 'member' };
}
}
2.5 三件工程武器:alias、barrel 节制、CI 分层检查
- path alias 统一为
@xxx/前缀 ,防止../../../../深坑,也让包间边界在 import 路径上一眼可见,配合eslint-plugin-import的no-restricted-paths把跨层 import 变成编译期+CI 期双重报错。 - 巨型 barrel 文件(
index.ts里 re-export 一切)慎用:re-export 会让类型域隐式互相可见,是循环依赖和"为什么我改了 A 包 B 包全重查"的常见来源。叶子模块出口按需暴露,别图省事一把梭。 - CI 里把
type-check和lint分开跑 ,type-check 用tsc -b(增量、可缓存),lint 只跑变更文件(lint-staged)。提交前git diff --name-only圈定范围,把"全量编译"留给主分支的夜间流水线。
3. 应用场景
- 中后台巨型单体:几十万行代码、几十个路由模块。用 2.1 分层 + 2.2 references 后,最常见的收益是 IDE 从"改一行卡三秒"恢复到"秒开跳转",以及换 ORM/换 HTTP 客户端时改动面从"全项目"缩到"防腐层一个目录"。
- Monorepo 组件库 :
ui、theme、utils、icons各成子项目。composite让组件库的声明文件成为其他包的"编译缓存",ui改了只重查ui自己,消费方拿到的是稳定.d.ts。 - JS 存量系统改造:按 2.3 三条车道,先让新功能带类型进场、核心服务 JSDoc 化、API 边界加 zod,三个月内把"最痛 20% 的代码"迁到严格模式,风险可控、随时可回滚。
4. 总结
大型项目的 TypeScript 架构,核心是把两件事做对:边界 和域 。边界靠分层类型化(接口下沉、防腐层、import 限制)守住,域靠 project references(每个子项目独立编译、增量缓存)切开。存量代码用渐进三车道降风险,any 用逃生舱制度做成可见负债。回头看 Day09-13 那些类型体操,它们解决的是"单点快不快、准不准",而 Day14 解决的是"整体活不活得下去"。一个大型 TS 工程的健康度,可以用三个数字体检:tsc -b 全量时间、any 登记表长度、跨层 import 被 lint 拦下的次数。这三个数字长期不涨,架构就还活着。