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

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 的收益(编译期安全)和成本(迁移期全项目飘红、构建变慢)在存量项目里是直接冲突的,很多人项目死在"一次性全量迁移"这一步。

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,禁止业务层 import axiostypeormfs 等基础设施包的路径。类型泄漏通常从 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
  }
}
  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
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-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 圈定范围,把"全量编译"留给主分支的夜间流水线。

3. 应用场景

  • 中后台巨型单体:几十万行代码、几十个路由模块。用 2.1 分层 + 2.2 references 后,最常见的收益是 IDE 从"改一行卡三秒"恢复到"秒开跳转",以及换 ORM/换 HTTP 客户端时改动面从"全项目"缩到"防腐层一个目录"。
  • Monorepo 组件库uithemeutilsicons 各成子项目。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 拦下的次数。这三个数字长期不涨,架构就还活着。

相关推荐
计算机魔术师3 小时前
Dario Amodei 发文呼吁为前沿 AI 降速并提出三点计划
前端
计算机魔术师3 小时前
OpenAI的AI代理偷偷给RubyGems下毒,我们却毫无察觉
前端
甲维斯3 小时前
0代码,0建模,3句话开发一个3D游戏!
前端·游戏·游戏开发
IT_陈寒3 小时前
Vue的响应式更新把我坑惨了,原来问题出在这
前端·人工智能·后端
tingke3 小时前
别再堆 AGENTS.md 了:前端团队的 Agent 上下文分层落地指南
前端
李明卫杭州4 小时前
React 复合事件系统对比解析
前端·javascript·react.js
葡萄城技术团队4 小时前
SpreadJS V19.2 新特性揭秘:FILTERXML 函数
前端
袋鼠云数栈UED团队5 小时前
AI Coding 方法论分析:同一个需求 SpecKit 、 Superpowers、 MattpocockSkills 不同设计
前端·aigc·ai编程
掰头战士5 小时前
Prompt Cache,如果agent全靠LLM方做隐式缓存实在不够用。
前端·llm·agent