当项目从"一个 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 是深度合并,子配置会覆盖父配置的同名项。三条铁律:
- base 里不要放
include/files,它们属于应用层,否则多包继承时会出现意外漏文件。 target、lib这类与运行时强相关的选项放应用层,base 只放"无论什么运行时都该严格执行"的选项。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 产出声明+ESM | tsc |
| 应用(app) | Vite / Webpack | tsc --noEmit + ESLint |
| 类型检查 CI | tsc -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 一个路径时去哪个文件"。现代项目三选一:
| 模式 | 适用场景 | 关键行为 |
|---|---|---|
bundler | Vite / esbuild / tsup | 无扩展名、exports、imports 全支持 |
node16 / nodenext | Node 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)保留必要的差异化——这正是大型项目从"能跑"走向"可持续演进"的关键。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。