TypeScript tsconfig references 项目引用配置详解
什么是 references
它是一个对象数组,指定要引用的项目,每个引用的 path 属性可以指向包含 tsconfig.json 文件的目录,也可以指向配置文件本身。
作用
项目引用是一种以更小的块方式去 组件 typescript 项目。这样可以明显提供代码的构造/编辑时间,根据逻辑加强组件化,并以一种改进后全新的方式组织代码。
示例
假设你的项目结构如下
├── src
│ ├── list.ts
│ └── login.ts
├── test
│ ├── list-test.ts
│ └── login-test.ts
├── tsconfig.json
可以看出来,这个 src 是项目的开发文件目录,而 test 下的文件是用来测试 src 目录中文件的。
那么这么做有什么问题呢?
- 它们公用一个
tsconfig.json文件,其中任何一个目录下的文件改动,都会让tsc把src和test一起重新检查一遍。测试文件通常比源码还多,项目一大,每次编译的等待时间就很难接受。 - 编译产物混在一起。
test目录下的测试代码也会被编译输出,而我们发布时其实只想要src的产物。 - 依赖方向失控。
src里的代码可以随手import测试目录里的东西,类型检查不会拦,但这明显是错误的依赖方向,直到打包时才可能暴露问题。
references 要解决的就是这类问题:把一个大工程拆成若干个可以独立编译、彼此有明确依赖关系的小项目,让 TypeScript 知道谁依赖谁,从而按拓扑顺序增量构建,并在类型层面强制约束依赖边界。
怎么配置
按上面的结构,我们给 src 和 test 各建一个 tsconfig.json。
先是 src/tsconfig.json,它是被依赖的一方,关键是打开 composite:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"outDir": "../dist/src",
"rootDir": "."
},
"include": ["**/*.ts"]
}
然后是 test/tsconfig.json,它通过 references 声明自己依赖 src:
{
"compilerOptions": {
"outDir": "../dist/test",
"rootDir": "."
},
"include": ["**/*.ts"],
"references": [{ "path": "../src" }]
}
path 既可以像这样指向包含 tsconfig.json 的目录,也可以直接指向配置文件本身,例如 { "path": "../src/tsconfig.build.json" }。当一个目录下有多份配置文件时,必须写全路径到文件。
composite 是前提
被引用的项目必须开启 composite: true,否则 tsc 会直接报错说被引用的项目没有启用 composite。这个选项本身带着几个强制约束,理解它们能省掉不少调试时间:
declaration被强制为true。因为引用方拿到的不是源码,而是编译产出的.d.ts声明文件,没有声明文件就无从谈起。rootDir默认变成tsconfig.json所在的目录,而不是推断出来的公共根目录。- 所有参与编译的实现文件都必须被
include/files匹配到。如果某个文件被import了却没进入 include 范围,会报错提示它不在项目文件列表里。 - 会生成
.tsbuildinfo文件,里面记录了上次构建的输入文件和版本信息,这是增量构建能跳过未变更项目的依据。
必须用 tsc -b 构建
配好 references 之后,直接跑 tsc 是不够的,普通模式不会去构建被引用的项目。要用构建模式:
tsc -b
-b 是 --build 的简写。它会读取 references,先算出项目之间的依赖拓扑顺序,再逐个检查每个项目是否需要重新构建(比对 .tsbuildinfo 里的时间戳和文件版本),只编译真正变了的那些。这就是"明显提升构建时间"的来源:改一个测试文件,src 完全不会被重新编译。
构建模式常用的几个参数:
# 指定要构建的项目,可以一次多个
tsc -b src test
# 监听模式,改动后自动增量重建
tsc -b -w
# 打印每个项目为什么需要或不需要重建,排查缓存问题很有用
tsc -b --verbose
# 忽略缓存强制全量重建
tsc -b --force
# 删除所有构建产物
tsc -b --clean
# 只输出 .d.ts,跳过 .js,做纯类型检查时更快
tsc -b --emitDeclarationOnly
--verbose 值得单独说一句。当遇到"明明改了代码但产物没更新"这类问题时,它会明确告出每个项目的判定结果,比如 Project 'src' is up to date because newest input is older than oldest output,一眼就能看出是缓存判断出了偏差,通常删掉 .tsbuildinfo 或加 --force 就好。
依赖边界会被强制约束
这是 references 除了构建速度之外更重要的收益。声明了引用关系之后:
test可以正常importsrc里的模块,TypeScript 会去解析src的输出声明。src不能importtest里的东西,因为src/tsconfig.json里没有声明对test的引用,这时会直接报错。
也就是说,模块之间的依赖方向从"靠人自觉"变成了编译器强制。在 monorepo 里这一点尤其有价值,能有效防止基础包反向依赖业务包这种结构性错误。
需要注意的是,引用方看到的是被引用项目的输出而非源码,所以被引用项目必须先构建出 .d.ts,否则会报找不到模块。开发时可以配合 tsc -b -w 常驻监听解决。如果希望编辑器里"跳转定义"能落到源码而不是 .d.ts,可以确认被引用项目开启了 declarationMap(即声明文件的 source map)。
monorepo 里的典型用法
真实项目里更常见的是这种组织方式:根目录放一份"solution 风格"的 tsconfig.json,它自己不编译任何文件,只负责把所有子项目串起来。
{
"files": [],
"references": [
{ "path": "./packages/utils" },
{ "path": "./packages/core" },
{ "path": "./packages/ui" }
]
}
files: [] 是关键,它告诉 TypeScript 这个配置不包含任何自己的源文件。之后在根目录一句 tsc -b 就能按依赖顺序把所有包构建完,编辑器也能同时理解整个仓库的类型关系。
子包之间的依赖照常用 references 声明,比如 packages/ui/tsconfig.json 里引用 core,core 再引用 utils,tsc -b 会自动排出 utils → core → ui 的构建顺序。
几个容易踩的点
- 别忘了
paths和references要对得上。如果用了路径别名(@/utils之类)指向另一个包的源码目录,而references指向的是它的输出目录,两条解析路径会打架,出现"同一个类型被认为是两个不同类型"的诡异报错。要么统一走references,要么把别名也指到输出。 .tsbuildinfo不要提交进 git,它是本地构建缓存,包含绝对路径,跨机器无效。加进.gitignore。prepend谨慎使用。引用项里可以写"prepend": true把被引用项目的输出拼接到当前项目产物前面,但它只在outFile模式下有效,现在基本被打包工具取代了,新项目不建议用。- 构建工具不一定认这套配置。Vite、esbuild、SWC 这些工具走自己的模块解析,
references主要影响tsc的类型检查和构建。实践中常见的分工是:打包交给 Vite,类型检查交给tsc -b --noEmit,两边各管一段。

还没有留言,来发表第一条吧~