大型 TypeScript 项目架构:tsconfig 分层策略、monorepo paths 与 Project References

面向大型 TypeScript 项目的工程架构:base/app/worker 三层 tsconfig 策略、monorepo paths 别名、Project References 项目引用、模块解析与架构边界治理。

当项目从"一个 src 目录"膨胀到"多包、多应用、多构建目标"时,tsconfig 的配置方式决定了团队的迭代效率与类型安全底线。单文件 tsconfig.json 在大型项目里是灾难的来源:前端构建目标、Node 脚本、测试环境共用一份配置,常常为了兼容最弱的环节而放松全局的类型检查。

本文基于 https://plumephp.com/typescript-strict-config/ 的严格模式基础,面向大型项目给出工程化答案:tsconfig 分层继承、monorepo paths、Project References 三大手段,以及用它们划清模块边界的方法论。


1. tsconfig 分层策略

1.1 三层结构的职责划分

大型项目推荐"基座 + 构建目标 + 应用场景"的分层方式:

tsconfig.base.json      # 全仓库共享的严格编译基座(纯 compilerOptions)
tsconfig.app.json       # 前端 / Node 应用的运行时配置(引用 base)
tsconfig.worker.json    # Web Worker / 定时任务等独立运行时的配置
tsconfig.test.json      # 测试环境专用(宽松一点,支持 vitest globals)

每一层用 extends 继承上一层,只覆盖差异项:

// tsconfig.base.json —— 只放与运行环境无关的严格选项
{
  "compilerOptions": {
    "strict": true,
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "lib": ["ES2022"],
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "skipLibCheck": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "resolveJsonModule": true,
    "verbatimModuleSyntax": true
  }
}
// tsconfig.app.json —— 应用运行时配置
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "jsx": "react-jsx",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "outDir": "dist",
    "sourceMap": true
  },
  "include": ["src"]
}
// tsconfig.worker.json —— Web Worker / 独立脚本运行时
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "lib": ["ES2022", "WebWorker"],
    "outDir": "dist-worker",
    "types": []
  },
  "include": ["src/worker", "src/shared"]
}

1.2 base 配置的"最少覆盖"原则

extends 是深度合并,子配置会覆盖父配置的同名项。三条铁律:

  1. base 里不要放 include / files,它们属于应用层,否则多包继承时会出现意外漏文件。
  2. target、lib 这类与运行时强相关的选项放应用层,base 只放"无论什么运行时都该严格执行"的选项。
  3. strict 系列尽可能全部进 base,这是全仓库的类型安全底线,不能让某个子应用悄悄关闭。

1.3 测试配置的差异化

测试环境通常需要放宽个别选项,但必须显式说明并集中管理:

// tsconfig.test.json
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "types": ["vitest/globals", "node"],
    "lib": ["ES2022", "DOM"]
  },
  "include": ["src/**/*.test.ts", "src/**/*.spec.ts", "tests"]
}

types 字段在这里刻意只放测试所需的全局声明,避免把运行时全局类型泄漏进测试上下文。


2. monorepo 中的 paths 配置

2.1 paths 与 baseUrl

paths 让源码里的导入使用语义化别名,替代脆弱的相对路径:

{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@app/*": ["src/*"],
      "@shared/*": ["../../packages/shared/src/*"],
      "@core/*": ["../../packages/core/src/*"]
    }
  }
}

baseUrl 在现代配置中不再是必须的:TS 4.1+ 允许 paths 中的目标使用相对路径(相对于 tsconfig 所在目录),因此推荐省略 baseUrl,直接写 "@shared/*": ["./packages/shared/src/*"],避免"baseUrl 指向哪"的歧义。

2.2 workspace 包的别名映射

pnpm/yarn workspaces 的 monorepo 中,paths 应指向包的 源码目录,让 IDE 与类型检查都看到最新的 TS 源码,而不是编译产物:

// 根 tsconfig.json(仅为 IDE 服务,不作为构建依据)
{
  "extends": "./tsconfig.base.json",
  "compilerOptions": {
    "paths": {
      "@plume/shared": ["./packages/shared/src/index.ts"],
      "@plume/shared/*": ["./packages/shared/src/*"],
      "@plume/core": ["./packages/core/src/index.ts"]
    }
  }
}

关键认知:paths 影响的是类型解析与 IDE 跳转,不影响最终打包。Vite、Webpack、tsup 各自有 runtime 别名配置(resolve.alias / tsconfig-paths),需要与 paths 保持一份同步清单,避免"类型检查通过了、运行时报模块找不到"。

2.3 边界:paths 不能无节制使用

滥用 paths 会掩盖包之间的真实依赖关系,破坏架构边界。最佳实践是给每个包维护属于自己的 tsconfig + paths,并把"只能导入本包公开入口"作为 review 红线:

packages/
  shared/tsconfig.json     # paths: { "@shared/*": ["./src/*"] }
  core/tsconfig.json       # paths: { "@core/*": ["./src/*"] }
  app/tsconfig.json        # paths: { "@app/*": ["./src/*"], "@shared/*": ["../shared/src/*"], "@core/*": ["../core/src/*"] }

这样 shared 的代码无法 import @core,只有 app 这类"组装层"才拥有跨包导入权限。


3. 项目引用(Project References)

3.1 composite 与 declaration 前置条件

Project References 让 TypeScript 在包级别做依赖管理与增量构建。被引用的包必须开启 composite: true(它隐含 declaration: true 与 incremental: true):

// packages/core/tsconfig.json
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}
// packages/app/tsconfig.json —— 引用 core 与 shared
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "composite": true,
    "outDir": "dist"
  },
  "include": ["src"],
  "references": [
    { "path": "../core" },
    { "path": "../shared" }
  ]
}

3.2 用 tsc --build 管理构建顺序

references 本身不做类型检查,它声明的是"构建依赖"。正确的构建入口是 tsc -b(build 模式),它会按拓扑序编译被引用包:

# 在 packages/app 下:先构建 core、shared,再构建 app
tsc -b packages/app/tsconfig.json

# 全仓库构建
tsc -b tsconfig.json

# 强制全量重建(忽略增量缓存)
tsc -b --force

tsc -b 会读 references 建立依赖图,只重新编译变更过的包,并自动生成 .tsbuildinfo 增量缓存。

3.3 与构建工具的配合

Project References 是 tsc 的构建方案,若使用 Vite/Webpack 构建,则 references 仍可用于 IDE 的类型隔离,但产物由 bundler 自己管理。常见的混合策略:

层谁构建谁做类型检查
库包(shared/core)tsc -b 产出声明+ESMtsc
应用(app)Vite / Webpacktsc --noEmit + ESLint
类型检查 CItsc -b 或 tsc --noEmit -p 各包tsc

这样既享受 bundler 的 HMR 与 tree-shaking,又保留 references 的依赖顺序约束。

3.4 循环引用检测

Project References 会拒绝循环引用(A 引用 B、B 引用 A),这正是架构边界的强约束:如果两个包相互依赖,说明它们其实应该合并或拆分。把这条作为 monorepo 拆包的重要判断依据。


4. 模块解析与边界

4.1 moduleResolution 策略对比

moduleResolution 决定了"import 一个路径时去哪个文件"。现代项目三选一:

模式适用场景关键行为
bundlerVite / esbuild / tsup无扩展名、exports、imports 全支持
node16 / nodenextNode ESM/CJS 混合严格按 Node 语义,exports 生效
node(legacy)老项目不读 exports,向后兼容
// Node 后端包
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022"
  }
}
// 前端应用(Vite)
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler"
  }
}

4.2 exports 字段:发布包的"类型边界"

包的 package.json 的 exports 字段是运行时与类型系统的双重边界:它决定外部代码能 import 哪些子路径:

{
  "name": "@plume/shared",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./internal/*": {
      "types": "./dist/internal/*.d.ts",
      "import": "./dist/internal/*.js"
    }
  },
  "files": ["dist"]
}

exports 里没有列出的子路径,外部永远无法 import——这就是"public API 边界"的落地方式。配合 moduleResolution: bundler 或 nodenext,TypeScript 会严格执行这份边界。

4.3 架构边界治理:依赖方向 lint

tsconfig 能管类型,但管不住"谁不该 import 谁"。架构边界需要 lint 规则兜底:

# eslint 配置要点
- no-restricted-imports:禁止 app 直接 import 数据库驱动
- import/no-cycle:禁止循环依赖
- boundaries/element-types:按包角色限制依赖方向

推荐工具组合:eslint-plugin-boundaries 或 eslint-plugin-import 的 no-restricted-paths。典型规则:

// eslint.config.js(片段)
{
  files: ['**/packages/app/**'],
  rules: {
    'no-restricted-paths': ['error', {
      zones: [
        { target: './src/pages', from: './src/services' },   // 页面层禁止反向依赖
        { target: './src/**', from: '../../database' },      // app 禁止直接碰数据库
      ],
    }],
  },
}

5. 一个完整的最小 monorepo 示例

把前三节的配置组合成一个 3 包结构的骨架:

repo/
├── tsconfig.base.json
├── package.json            # workspaces
└── packages/
    ├── core/               # 纯类型与工具,无运行时依赖
    │   ├── tsconfig.json   # composite: true
    │   └── src/index.ts
    ├── shared/             # 共享组件/工具,依赖 core
    │   ├── tsconfig.json   # composite: true, references core
    │   └── src/index.ts
    └── app/                # 前端应用,依赖 shared
        ├── tsconfig.json   # composite: true, references shared
        ├── vite.config.ts
        └── src/main.tsx

根级 package.json 脚本让构建顺序可复现:

{
  "workspaces": ["packages/*"],
  "scripts": {
    "build": "tsc -b packages/app/tsconfig.json",
    "typecheck": "tsc -b packages/app/tsconfig.json --dry",
    "dev": "vite --cwd packages/app"
  }
}

tsc -b 会根据 references 自动把 core → shared → app 的编译顺序排好,--dry 只做类型检查不产出文件。


6. 常见陷阱与最佳实践

6.1 陷阱清单

  • paths 与 include 不同步:paths 指向的文件没被 include 收录,IDE 能跳转但 tsc 类型检查会报"文件不在工程中"。
  • composite 项目不能关闭 declaration:composite 强制要求可被引用的声明产出。
  • tsc -b 与 --noEmit 冲突:build 模式必须能产出文件,CI 里做纯类型检查请用 tsc --noEmit -p(对每个包)而不是 tsc -b --noEmit。
  • node_modules 类型不一致:不同包安装了同一依赖的不同版本,声明可能冲突;用 skipLibCheck 跳过 .d.ts 内部检查缓解。
  • 根 tsconfig 被 IDE 误用:monorepo 根 tsconfig 只服务 IDE 时,应用层仍需各自的严格检查入口。

6.2 团队协作最佳实践

  • 把"类型检查"拆进 CI 的独立 job,与构建并行,避免构建失败才暴露类型错误。
  • 用 include 收敛范围:include: ["src"] 优先于 **/*,避免把 dist、.next 卷进类型检查。
  • 版本统一:整个 monorepo 用统一 TS 版本(根 devDependencies 锁定),防止子包各自为政。
  • 配置即文档:每个 tsconfig 的注释解释"为什么这里这么配",大型项目尤其重要。

6.3 进阶方向

架构配置是为类型安全服务的底座,之上的类型能力可以持续扩展:

  • 结合 https://plumephp.com/typescript-type-level-programming/ 在共享包中沉淀公共工具类型;
  • 结合 https://plumephp.com/typescript-decorators-metaprogramming/ 为框架层搭建声明式基础设施;
  • 结合 https://plumephp.com/typescript-runtime-validation-typesafe/ 在包边界上做运行时校验,让"架构边界"同时具备编译期与运行期的双保险。

7. 总结

大型 TypeScript 项目的架构核心可以浓缩为一句话:让每个包拥有清晰的编译单元、显式的依赖方向与受控的模块边界。tsconfig 分层负责"编译目标差异",Project References 负责"包间依赖与构建顺序",paths 与 exports 负责"导入别名与公开 API 边界",lint 规则负责"人的纪律"。

这四个层次的组合,既保证全仓库类型安全底线不松动,又允许各运行时(app/worker/test)保留必要的差异化——这正是大型项目从"能跑"走向"可持续演进"的关键。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. CSS 架构与样式方案:从方法论到现代 CSS 新特性
  2. 可访问性与国际化:WCAG 2.2、ARIA 与 i18n 工程实践
  3. SSR/SSG 渲染模式全景:Next.js App Router、流式渲染与岛屿架构