ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

TypeScript Monorepo 跨包引用失败:tsconfig composite 与 references 实战全解

TypeScript Monorepo 跨包引用失败:tsconfig composite 与 references 实战全解 先说结论Turborepo/Nx这类 monorepo 工具本身不负责类型检查它们只负责把命令编排成带缓存的任务图。跨包引用失败的根子几乎都在tsconfig的composite、references、paths三者没有对齐上。我见过太多项目pnpm workspace 里import一个内部包IDE 不报红、单测也能跑一上 CI 执行tsc --build全线崩盘。这篇文章就把我从报错到修复的完整过程拆开讲搞清楚 composite 到底在约束什么以及 Turborepo/Nx 场景下怎么把类型检查产物正确纳入任务依赖。1. 胶带里的真相IDE 放行、单测全过偏偏 tsc 崩在跨包引用很多 monorepo 新手都经历过一个诡异阶段编辑器里import { Button } from repo/ui完全没有红线vitest跑测试也能过甚至vite dev都正常。但当你执行一次全量构建tsc却抛出一堆莫名其妙的错误指向的恰恰是刚才还好好的跨包引用。这种“局部正常”和“全局崩溃”的撕裂感原因在于不同工具解析模块的路径完全不同。IDE 和打包器通常会跟随package.json的exports字段或者tsconfig里的paths映射直接跳转到源码.ts文件而tsc在做项目构建时走的是另一套逻辑——它要先确认每个包是否是一个完整的编译单元跨包引用是否落在被引用包的声明产物之上。两边规则不一致就会出现“靠 IDE 习惯思考却被编译器教育”的结果。1.1 症状一IDE 没报错CI 里 TS6059 砸场TS6059 的完整报错大意是文件不在rootDir下rootDir预期包含所有源文件。典型场景是这样的你的根 tsconfig 把rootDir设成了仓库根目录.然后apps/web里通过paths把repo/ui直接映射到了packages/ui/src/index.ts。TypeScript 在类型检查时把被引用包的源文件拉进了当前项目这些文件不在apps/web自己的rootDir之内于是编译器认为“你在污染输出目录结构”。这个问题在 IDE 中几乎无法暴露因为 IDE 的 Language Service 默认按最宽松的解析策略提供补全和跳转只有真正执行tsc时才会校验 rootDir。你越是依赖paths做跨包跳转构建时就越容易被 TS6059 追着打。1.2 症状二TS6306直接点名 compositeTS6306 是我见过的最直白的错误引用项目必须设置 composite: true。这个错误通常发生在你尝试在tsconfig中用references引用另一个包但那个包的 tsconfig 里没有打开composite。TypeScript 对项目引用的要求非常死板被引用的项目必须是一个“可构建的复合项目”否则它不知道该怎么增量生成声明文件、不知道从哪里读取.tsbuildinfo。很多从单仓库转 monorepo 的团队会惯性式地写paths代替references因为这看起来最简单。但 TypeScript 的项目引用机制有明确前置条件被引用包必须composite: true。这是第一个需要被正视的硬约束不是“建议”是“必须”。1.3 为什么 Turborepo/Nx 不背锅但会放大问题Turborepo 和 Nx 本质上是任务执行器。Turborepo 根据turbo.json里的任务定义与dependsOn关系调度命令Nx 则通过项目图和 target 推断来执行。它们都不知道 TypeScript 内部的项目引用关系只会忠实地执行你在package.jsonscripts 里写的命令。可它们会放大问题因为缓存机制。假设你之前有一次构建成功Turborepo 把dist目录缓存了下来之后你改了被引用包的源码但某个任务的输入没有正确包含该包Turbo 可能直接命中旧缓存给你返回一个过期的dist。更隐蔽的是如果 TS6059 之类的问题在缓存结果中被“固化”了缓存恢复后你看到的是同样的错误但你已经不知道这份错误到底是当前代码产生的还是两天前某个临时改动留下的。所以当我们在 monorepo 工具链下谈“跨包引用失败”时不能只看tsc的报错信息还要检查任务依赖图是否正确、缓存输入是否完整。前半部分是 TypeScript 的语法规则问题后半部分是构建系统的正确性设计问题两者都需要解决。2. composite 不是为“单包舒适区”设计的它带来的硬约束与背后考量composite这个配置项在 TypeScript 官方文档里的解释很简略大意是“启用项目编译的约束使 TypeScript 可以确定项目是否已构建”。但在 monorepo 情境下它的实际含义要重得多。我把 composite 理解为包与包之间“类型边界契约”的强制条款一个包要想被别人安全引用必须能独立产出.d.ts声明必须知道自己的源码边界在哪里必须能被增量跟踪。2.1 composite 打开后立刻变严格的三件事当你把某个包的 tsconfig 设置composite: true时TypeScript 会强制你遵守至少三条规则必须生成声明文件所以declaration隐含为 true默认还会打开declarationMap用于源码与声明文件之间互相跳转。必须设置rootDir这个值决定了该编译单元的源码根。跨包引用如果拉进外部源码立刻污染 rootDir 判定。不允许noEmit。以前很多纯前端项目习惯用tsc --noEmit做类型检查但在 composite 项目里不可以因为编译器必须产出声明文件和.tsbuildinfo。这些约束乍看很烦人尤其第三条让很多 Vite 用户不适应。但换个角度想TypeScript 需要依赖这些产物来完成“项目引用”的增量逻辑。没有声明文件下游项目无法建立独立类型边界没有构建信息文件下次构建无法判断哪些文件变更过。2.2 声明产物和隔离为什么跨包类型边界必须走 .d.ts假设没有 compositerepo/web引用repo/ui时TypeScript 可能会顺着源码路径直接检查packages/ui/src里的所有内容。这在一两个包的小项目里没问题但当包数量增长到十个、二十个每次全量类型检查都会把整个依赖子树扫描一遍构建时间呈指数级恶化。开启 composite 后repo/ui会先把自己编译成dist下的index.js和index.d.ts同时生成.tsbuildinfo。repo/web在类型检查时只读取repo/ui的dist/index.d.ts不再进入源码目录。这才是真正的“边界”每个包只对自己的声明负责下游看到的是稳定契约而不是随时变化的源码细节。这也是为什么很多项目把启用 composite 的 tsconfig 命名为tsconfig.build.json或直接作为主配置。它天然适合与 Turborepo/Nx 的增量任务配合每个包的构建是独立任务产出的声明文件可以被缓存。2.3 不适配的常见根 tsconfig 写法我很常见到仓库根目录有一个“万能” tsconfig里面写着compilerOptions: { noEmit: true }所有子包都extends它。这种配置在单包项目里很安全一进入 monorepo project references 就会像撞墙一样报错子包打开 composite父配置却要求 noEmit两者直接冲突。类似的问题还有根配置里写了rootDir: .子包含盖成rootDir: src后又因为某些历史文件目录不一致而报错。正确思路是根 tsconfig 只承担“公共编译选项”的角色不写 noEmit、不写 rootDir、不写 composite把这些约束下沉到每个实际编译的包配置里。基线配置越“抽象”子包越自由反之任何全局 writable 的约束都会成为定时炸弹。3. 从报错到绿灯references、rootDir 与构建模式的一整套可复现配置理论聊完直接看一套能跑通的配置模板。下面这套结构是 pnpm workspace 加 Turborepo包管理器换成 yarn/npm 也同理。仓库结构如下repo-root ├── packages │ ├── ui │ │ ├── src │ │ │ └── index.ts │ │ ├── package.json │ │ └── tsconfig.json │ └── utils │ ├── src │ │ └── index.ts │ ├── package.json │ └── tsconfig.json ├── apps │ └── web │ ├── src │ │ └── main.ts │ ├── package.json │ └── tsconfig.json ├── tsconfig.base.json ├── tsconfig.json └── turbo.json3.1 根 tsconfig.base.json只放公共编译选项// tsconfig.base.json { compilerOptions: { target: ES2022, module: ESNext, moduleResolution: Bundler, strict: true, skipLibCheck: true, forceConsistentCasingInFileNames: true, resolveJsonModule: true } }这里刻意不写composite、rootDir、outDir、noEmit。这些是属于单个编译单元的决策放到子配置里。moduleResolution选Bundler是因为仓库里的应用大多走 Vite/打包器运行时代码选择它解析exports字段更顺。如果你的产物需要在 Node 原生 ESM 下直接执行可以换成NodeNext但代价是相对导入必须带.js扩展名这个差异要提前想清楚。3.2 各包 tsconfigcomposite、rootDir、tsBuildInfoFile 齐活packages/ui/tsconfig.json{ extends: ../../tsconfig.base.json, compilerOptions: { composite: true, rootDir: src, outDir: dist, tsBuildInfoFile: ../../node_modules/.cache/ui.tsbuildinfo, declaration: true, declarationMap: true }, include: [src], references: [] }tsBuildInfoFile把增量构建信息统一放到仓库根目录node_modules/.cache下避免每个包自己生成一堆.tsbuildinfo垃圾文件也方便在 CI 里统一清理缓存。declaration其实在 composite 下是隐式开启的但显式写出可以让新同事一眼看懂这个包的产物预期。apps/web/tsconfig.json稍微不同因为它要引用其他包{ extends: ../../tsconfig.base.json, compilerOptions: { composite: true, rootDir: src, outDir: dist, tsBuildInfoFile: ../../node_modules/.cache/web.tsbuildinfo, declaration: true, declarationMap: true, emitDeclarationOnly: true }, include: [src], references: [ { path: ../../packages/ui }, { path: ../../packages/utils } ] }给apps/web打开emitDeclarationOnly是个人推荐应用本身的 JS 产物交给 Vite 处理不需要 tsc 生成一份多余的 JS但 composite 又要求必须产出声明文件那就只产出.d.ts不产出.js。这一招既满足 composite 的硬性要求又不打断应用的构建流程。3.3 根 tsconfigsolution 风格的关键一步仓库根目录的tsconfig.json用 solution 模式{ files: [], references: [ { path: ./packages/ui }, { path: ./packages/utils }, { path: ./apps/web } ] }这样你可以在根目录直接执行tsc -bTypeScript 会按引用关系自底向上构建所有包。files为空数组意味着这个配置本身不编译任何文件它只是项目图的入口。这是tsc --build的标准用法也是整个方案里最容易被忽略的一环——很多人把所有包配好了 references却在命令行里对每个包分别执行tsc -p结果项目引用完全不生效。3.4 构建命令的变革只用 tsc -b不要 tsconfig 单包硬编跨包引用在 build mode 下才生效这是 TypeScript 项目引用最核心的行为差异。单独执行tsc -p packages/ui不会构建它引用的包也不会主动刷新被引用项目的产物。正确姿势是tsc -b # 构建根 solution 引用的所有项目 tsc -b --clean # 删除所有构建过的产物与 tsbuildinfo tsc -b --force # 忽略 tsbuildinfo强制整体重建在 Turborepo 场景下每个包的package.jsonscripts 可以写成build: tsc -b由 Turbo 统一调度。但要注意一个细节如果某个包的 build 命令是tsc -b ../../tsconfig.json这种跨目录写法不同包管理器的 cwd 解析可能不一致尽量让每个包执行tsc -b时自动使用当前目录下的 tsconfig也就是不传参数。到这里跨包引用失败的大部分基础问题已经能被解决。但把这套配置丢进 Turborepo/Nx 之后真正的坑才开始冒头缓存。4. Turborepo/Nx 任务编排里的缓存陷阱类型检查产物同样需要纳入依赖图很多团队把tsc配置好了却依然在 CI 上见到“间歇性”跨包报错。这种诡异现象十有八九是任务编排和缓存导致的。你要意识到在一个 monorepo 工具眼里repo/web的 typecheck 任务和repo/ui的 build 任务是两个相互独立的task。如果你没有显式声明dependsOnTurbo/Nx 根本不知道 web 的类型检查必须等 ui 构建出声明文件以后才能跑。4.1 Turbo 2.x 的 task 配置dependsOn 和 outputs 要覆盖声明Turborepo 2.x 中原来的pipeline关键字改成了tasks。一个能正确覆盖 TypeScript 项目引用的配置长这样// turbo.json { $schema: https://turbo.build/schema.json, tasks: { build: { dependsOn: [^build], outputs: [dist/**] }, typecheck: { dependsOn: [^build] } } }dependsOn: [^build]表示“本任务的执行依赖所有上游依赖包的 build 任务先完成”。注意typecheck 依赖上游的 build而不是依赖上游的 typecheck。因为 web 做类型检查时读取的是 ui 产出的dist/index.d.tsui 必须先执行一次tsc -b把声明文件生成出来。如果你只写dependsOn: [^typecheck]上游只是跑了tsc --noEmit什么都没产出web 类型检查必然失败。outputs里写的dist/**会被 Turborepo 缓存这里建议确认你声明文件确实输出在dist下。如果某个包的outDir改成了lib而outputs忘了同步修改缓存命中时 Turborepo 会恢复旧路径下的产物类型检查就会读到过期的.d.ts。4.2 Nx 的 executor 与 cache inputs把 tsconfig 变化纳入哈希Nx 处理方式更“框架化”。它通过nx/js:tscexecutor 直接执行 TypeScript 构建并在nx.json的targetDefaults里设置缓存规则{ targetDefaults: { build: { dependsOn: [^build], outputs: [{projectRoot}/dist] } } }Nx 的默认缓存输入包括源码和tsconfig文件但有一个细节要留意如果你的tsconfig.base.json在仓库根目录Nx 哈希项目时未必会自动把它算进来。稳妥做法是在nx.json里显式声明{ namedInputs: { default: [{projectRoot}/**/*, tsconfig.base.json, !{projectRoot}/**/*.md] } }否则你改了全局编译选项Nx 可能因为项目文件哈希没变而命中旧缓存。这种“缓存命中但结果错误”的问题比没有缓存还难排查因为日志里自变量清清楚楚地写着cached.4.3 还原缓存之后你应该验证什么无论用 Turborepo 还是 Nx缓存命中后不要只看任务状态是绿色的必须验证缓存内容正确。我的习惯是这样检查被引用包的dist下是否真的存在.d.ts和.d.ts.map文件而不仅仅有.js。如果.js存在但.d.ts缺失说明那次构建并没有以 composite 模式完成缓存的内容本身就是坏的。查看.tsbuildinfo的更新时间是否合理。声明文件讲道理应该跟着源码改动同步刷新。在 CI 里故意执行一次--force或者--skip-nx-cache对比强制重建与缓存命中的产物差异。这几个动作能帮你区分“代码问题”和“缓存问题”。我见过不少团队只加缓存不看缓存最后花了一整天查源码结果罪魁祸首是远程缓存里存了一份老旧的dist。记住一句话缓存只忠实于它被创建时的输入不忠实于你脑补的“当前代码”。5. 我把常见报错整理成一张排障表以及“先怀疑谁”的排查顺序文章后半篇讲实操。下面这张表汇总了我处理 monorepo 跨包引用问题时遇到的高频报错以及对应的根因与修法。建议直接存下来下次报错先对着表定位比在搜索引擎里碰运气快得多。报错代码报错含义常见根因修复方向TS6059文件不在 rootDir 下rootDir 设置过宽paths 把外部源码拉进当前编译单元每个包收紧 rootDir 到自己的 src不要用 paths 跨包指源码TS6306引用项目必须设置 compositereferences 指向的包未开启 composite给被引用包 tsconfig 增加composite: trueTS6307文件不在被引用项目的文件列表中被引用包 include 范围不完整或 import 了不在 src 内的文件调整被引用包的 include确保所需文件属于其编译范围TS6305输出文件未构建.tsbuildinfo 与当前源码状态不一致常见于缓存或手动删档后执行tsc -b --force清理重建或清掉 .tsbuildinfoTS5055输出文件会覆盖输入文件outDir 没有设置或 outDir 与源码目录重叠显式设置 outDir 与 rootDir确保输出落到独立目录TS2307找不到模块包没构建出产物、moduleResolution 无法解析 exports或引用路径写错先构建上游包再检查 package.json exports 与 tsconfig moduleResolution5.1 典型根因排序先查包再查引用最后查命令排障顺序很重要。我自己的习惯是这样第一确认被引用包是否已经构建。进入packages/ui/dist看一眼有没有.js和.d.ts。没有的话问题根本不在这包当前源码而是你还没有执行它的 build 任务或者在 Turborepo/Nx 中它的 build 没有被调度。第二确认 references 是否完整且互无环。用tsc -b --verbose观察 TypeScript 实际选择的构建顺序如果某个包没有被纳入构建列表说明根 solution 配置漏写了引用。如果出现循环引用TypeScript 会直接报错这时需要把共享类型抽到更底层的包。第三确认命令形态。是tsc -p还是tsc -b结果可能完全不同。项目引用必须通过 build mode 驱动单包编译不会自动构建依赖。很多“昨天还能过今天突然不行”的灵异问题最后发现只是因为某个环节用了tsc -p。第四才轮到怀疑缓存。关掉缓存强制重建如果错误消失那就是缓存输入配置不完整如果错误依旧说明代码层面的问题还没解决。5.2 两个容易再犯的隐藏坑继承与公开导出漂移第一个隐藏坑是 tsconfig 继承导致的隐式字段覆盖。子包extends根配置时根配置里任何一个你没意识到的rootDir或outDir都会被继承。更隐蔽的是include数组它在继承时不会合并子包配置里的include会直接替换父级配置。这种替换经常导致“我这个包明明有 src 目录为什么 TS 说找不到文件”的困惑。建议所有子包 tsconfig 都显式写清include绝不依赖父级的默认扫描。第二个隐藏坑是package.json的exports字段与声明文件不一致。composite 构建完成后下游包通过 moduleResolution 解析到的是exports指向的产物路径。如果exports写的是./dist/index.js而实际声明文件叫./dist/index.d.tsTypeScript 会自动找同名.d.ts一般没问题但如果exports里有types条件并且指错了路径下面就是一串莫名其妙的 TS2307。改完 tsconfig 后务必同步 check 一下包入口。5.3 我个人的调试经验让编译器说清楚它看到了什么最后分享一个实用到几乎是“绝招”的技巧利用tsc --traceResolution查看模块解析日志。当 TS2307 出现时它只会告诉你找不到模块却不告诉你它尝试了哪些路径。执行tsc -p packages/web/tsconfig.json --traceResolution 21 | grep repo/ui -A 20你会看到 TypeScript 查找模块时真实尝试过的所有路径。是走到了packages/ui/dist/index.d.ts还是走到了packages/ui/src/index.ts是解析了 package.json 的 exports 还是直接用了 node_modules 里的符号链接全都能看出来。这一步几乎能定位 90% 的跨包引用问题因为大多数这类错误不是“找不到”而是“找错了地方”。另一个惯用操作是新建一个干净缓存目录做验证。每次 TS 主版本升级或者涉及moduleResolution变更时我不会只在 CI 里跑增量构建而是先执行一次tsc -b --force加清空 Turborepo 缓存的组合拳确认从零开始能构建通过。之后再验证增量场景。这套流程虽然朴实但能帮你把“代码问题”和“缓存问题”彻底分开省下的排查时间远超那几分钟强制构建的成本。搞明白了 composite 的硬约束和项目引用的构建顺序跨包引用失败就没什么玄学了让每个包老老实实成为独立编译单元再让 Turborepo/Nx 把这类任务按正确依赖关系串起来。剩下的就是遇到报错时先看看声明文件在不在再跑一次--force最后用--traceResolution让 TypeScript 把话说清楚。
返回列表