◆

TypeScript tsconfig references 项目引用配置详解

2023-02-02 01:370

什么是 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 可以正常 import src 里的模块,TypeScript 会去解析 src 的输出声明。
  • src 不能 import test 里的东西,因为 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,两边各管一段。
💬留言板0

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

PAGE LOAD / LIVETypeScript tsconfig references 项目引用配置详解加载中