TS Project References 在 Monorepo 中应用

1,424 阅读4分钟

源码跳转

在 monorepo 中,包源码通常位于同一仓库不同目录下。

─ projects
  ├── parent
  └── child

Coding 时,我们常需要点击跳转,查看源码。

  • 直接点击跳转 → DTS 文件,这不是我们想要的。
  • 全局搜索定义 → 倒也可以,就是有些低效了。

如果能直接跳转到源码查看,开发体验势必更上一层楼。基于这个目的,TS 在 3.0 版本之后支持了 Project References 特性,它允许我们将 TS 项目分割成若干个模块,彼此独立,这与 monorepo 的使用场景和理念相似。

下面是一份开启 references 的示例配置:

// parent/tsconfig.json
{
  "compilerOptions": {},
  // Usual config
  "references": [
    { "path": "../child" }
  ]
}

在 TS 语言服务的作用下,编辑器(如 vscode)将具备源码跳转能力,效果大概是这样的:

6217da2b-a529-4320-973e-942ec59ad71e.gif

不配置则跳转产物,而非源码

f8d2bdea-b020-4bdd-9927-19b28f622b45.gif

开启 Project References 非常简单,但想要在开启的基础上实现源码跳转,我们还有一些配置需要完善。

实现原理

举个栗子🌰,假设我们在 <root>/projects 目录下分别有 @project/parent@project/child 两个项目,其中父项目以 workspace 依赖的方式引用子项目。

{
  "name": "@projects/parent",
  "dependencies": {
    "@project/child": "workspace:*"
  }
}

projects/parent/src/index.ts 中,会有如下代码

import { xxx } from "@project/child";

项目 child 的 packages.jsontypes 配置如下

{
  "name": "@projects/child",
  "types": "dist/src/index.d.ts"
}

那么在 parent 没有配置 references 时,引用路径等效为

import { xxx } from "@project/child/dist/src/index.d.ts";

配置 references 后,在 TS Server 作用下,引用会被重定向到源码,如下

import { xxx } from "<root>/projects/child/src/index.ts";

这就是 references 的第一个作用——源码跳转,开启 Project References 的项目需要满足以下条件,以确保 TS 能够快速找到引用项目的输出内容:

  • 父子项目需同时启用 compositedeclaration
  • 子项目需正确设置 compilerOptions.rootDir,通常设置为 "src"
  • 子项目需确保 tsconfig.json 中的输出目录(outDirdeclarationDir)与 package.json 中声明的 types 对应。

以上步骤,是为了确保 TS Server 能够找到引用项目(也就是子项目)的输出内容。 而上面👆提到的「输出内容」指,TS 如何查找引用项目的源码,举个栗子🌰:

import { xxx } "@project/child"; // 源码在 projects/child/src 下
import { xxx } "<root>/projects/child"; // 配置了 references 后的效果,但还缺少 src 层级
import { xxx } "<root>/projects/child/src"; // ✅ 配置 rootDir: "src" 后正确加载到了源码

产物层级调整

我们通常只需要构建 src 下的内容,而 TSC 构建的产物结构却是与源目录结构一致的。如果你觉得多了一个 dist/src 层级不够优雅,想将 package.json 中的 types 配置成 dist/index.d.ts,那么你可以通过配置 rootDirinclude 来实现。

child 项目的 tsconfig 修改之前长这样:

{
  "compilerOptions": {
    "outDir": "dist",
  }
}

修改后,增加 rootDir 和 include 配置:

{
  "compilerOptions": {
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

为了确保源码跳转正常,我们还需要修改 child 项目的 package.json,去掉 src 以维持「对应关系

{
  "name": "@projects/child",
  "types": "dist/index.d.ts"
}

调整完成后,回到上文中的例子,在 parent 没有配置 references 时,引用路径等效为

import { xxx } from "@project/child/dist/index.d.ts"; // ✅ 没有了 src

配置 references 后,由于 child 项目的 outDir 为 dist,删除 dist 后理论上应该重定向到 child/index.ts 而非 child/src/index.ts,但因为配置了 rootDir,TS Server 依旧能正常识别

import { xxx } from "<root>/projects/child/src/index.ts"; // 大功告成

对应关系

通常我们的 package.json 长这样:

{
  ...
  "types": "./dist/types/index.d.ts",
  ...
}

tsconfig.json 长这样:

{
  "compilerOptions": {
    "outDir": "dist",
  }
}

上面的配置是无法被正常映射的,需要保证 types 中的目录和 TS 类型的输出目录一致。

下列操作三选一

  • compilerOptions.outDir修改为 dist/types
  • 或将 compilerOptions.declarationDir设置为 dist/types
  • 或将 package.json 中的 types设置为 ./dist/index.d.ts

实际应用时,大家灵活配置,统一即可。

推荐配置

package.json

{
  ...
  "types": "./dist/types/index.d.ts",
  ...
}

tsconfig.json

{
  "compilerOptions": {
    "outDir": "dist",
    "declarationDir": "dist/types",
  }
}

类型联合校验

当我们将项目彼此关联起来之后,它们会构成一个整体,在 TS 语言服务的作用下,可赋予编辑器直接跳转到源码的能力,非常便利。

但除此之外,Project References 还可以辅助我们在仓库内进行大范围的类型联合校验,这便是 references 的第二个作用,它在 CI 中有着积极意义。

我们可以在仓库根目录下(位置随意),新建一个空的 tsconfig.json 文件,作为构建入口,更多信息可以参考 overall-structure

// <root>/tsconfig.json
{
  "files": [], // 空文件,全量校验使用
  "references": [
    { "path": "projects/a" },
    { "path": "projects/b" },
    { "path": "projects/c" },
  ]
}

然后运行以下命令:

npx tsc --build tsconfig.json --force --verbose

在 build 模式下,tsc 会按照依赖关系,拓扑构建引用链中的所有项目,从而实现了类型联合校验

错误治理

历史项目往往存在大量 TS 错误,导致校验不通过。为此,我们可以使用 ts-migrate 批量忽略存量 TS 错误,然后再开启联合校验。