Monorepo 已经成了前端和后端项目的常见选择,尤其是当团队需要同时管理多个应用、多个包的时候。Turborepo 在里面扮演的,就像是一个聪明的调度员:它知道哪些任务可以同时跑,哪些任务必须等别人跑完再动。听起来很完美,但真正用起来,很多人会碰到任务并行执行时“打架”的情况。比如两个任务同时写同一个输出目录,或者一个任务读取了另一个任务还没生成完的文件,最后构建要么失败,要么结果对不上。这篇文章就把这类问题掰开揉碎,说说为什么会发生,以及怎么在 Turborepo 里把并行任务安排得明明白白。

一、先聊聊 Monorepo 里的并行冲突

1.1 为什么需要并行任务

一个大的 Monorepo 里,通常有多个小项目,比如一个网站、一个管理后台、一套公共组件库。如果每次构建都一个个按顺序来,时间会非常长。Turborepo 支持并行执行,意思是它可以把相互没有依赖的任务同时跑起来,充分利用机器资源,让整体构建时间从十分钟降到三分钟。这种并行能力,在 CI 环境里尤其重要,因为 CI 卡的时长远比你的咖啡杯容量更宝贵。

1.2 冲突到底长什么样

你想象一下家里只有一个厨房,但是两个人同时想用那个灶台做菜。一个人要炒青菜,另一个人要炖肉,最后火候全乱了。Turborepo 的并行冲突也是类似的道理,常见的有这几种:

  • 两个任务同时向同一个目录输出文件,比如都往 dist 目录里写,结果 A 任务生成的文件被 B 任务覆盖了。
  • 一个任务需要用到另一个任务生成的文件,但 Turborepo 以为它俩没关系,直接让它们同时跑,结果读取的时候文件还没生成。
  • 多个任务共用同一个临时目录,比如 .turbo 或者 node_modules/.cache,并行时互相清理对方的缓存,导致任务莫名其妙地失败。
  • 环境变量不一致,同一个任务在本地跑是好的,到 CI 上就挂了,因为并行时某个环境变量没传进去。

这些冲突如果你不去管它,它就会像个鬼一样,时不时冒出来让你抓狂。好在 Turborepo 提供了一套表达任务依赖关系的机制,你只需要把任务之间的规矩说清楚,它就能帮你把事情安排得井井有条。

二、Turborepo 如何理解任务关系

Turborepo 的核心配置文件是 turbo.json。你会在里面定义一个叫 pipeline(管道)的东西,它就像是任务流程图。每个任务可以声明自己依赖谁,以及自己生成什么。有了这个图,Turborepo 才知道哪个先跑,哪个后跑,哪个可以同时跑。下面我们用一个最小的例子来说明。

2.1 任务依赖(dependsOn)

dependsOn 表示“这个任务跑之前,必须先完成哪些任务”。比如你要构建一个 Web 应用,它依赖公共组件库先完成编译,那么你可以写:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

注意上面这个配置里的 ^build,这里的 ^ 表示“上游依赖的 build 任务”。也就是说,对于当前项目,它依赖的所有 npm 包里的 build 必须先跑完,当前项目的 build 才能开始。这个符号是 Turborepo 很重要的约定。

2.2 管道(pipeline)的配置说明

下面是一个稍微完整一点的 turbo.json,展示了常见任务之间的先后关系:

{
  "pipeline": {
    "clean": {
      "cache": false
    },
    "compile": {
      "dependsOn": ["^compile"]
    },
    "build": {
      "dependsOn": ["^build", "compile"],
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {}
  }
}

这里面的意思很直观:build 之前要保证上游依赖的 build 完成了,同时本地项目的 compile 也要先跑完。test 则要等 build 完成。lint 没有依赖,那它就可以和别的任务并行跑,前提是它不和别人共享输出目录。

通过这样的配置,Turborepo 就能画出一张“先来后到”的图。它会自动调度,把没有依赖关系的任务放在同一时间片里跑,把有依赖关系的任务按顺序排队。

三、实用排错与解决手段

配置看起来简单,但实际使用中还是会遇到不少状况。接下来我们逐个看看最常见的冲突,以及怎么处理。

3.1 明确任务的输入和输出

如果你不告诉 Turborepo 一个任务生成什么,它就不知道哪些文件可以被缓存,哪些文件可能冲突。频繁出问题的地方就是:多个任务都往同一个全局目录里输出。比如你所有子包都叫 dist,但构建工具默认会输出到各自的 dist,这没问题。但如果你把 outputs 写成了 ../dist,那所有任务都会往仓库根目录的 dist 里写,自然就撞车了。

正确做法是,每个任务只负责自己目录下的输出,并且在 outputs 里精确声明:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

这里的 dist/** 是相对于当前子包目录的。Turborepo 会自动拼接。如果你发现两个任务确实需要共享同一个输出目录,那你必须给它们加一个串行的依赖关系,或者把输出拆分成独立的子目录。

3.2 处理共享缓存目录冲突

Turborepo 默认会在 node_modules/.cache/turbo 里存放缓存数据。如果多个任务同时操作这个目录,或者你的构建工具自己也写了一堆缓存,比如 .eslintcache、.tsbuildinfo,这些文件会被 Turborepo 视为散落的“脏东西”。当两个任务并行时,它们可能同时去写同一个 .tsbuildinfo,然后互相踩踏。

解决办法有两个方向。第一,让每个任务使用独立的缓存文件名,比如给 TypeScript 编译指定不同的 tsconfig 或输出名:

# 给某个子包单独指定编译缓存文件
tsc --build --incremental .tsbuildinfo.app

第二,如果你用的构建工具不太听话,非要用固定的缓存目录,那你可以在任务配置里禁掉缓存,或者把该任务设置为串行。

3.3 控制并发度

有时候冲突不是因为任务之间有什么真正的依赖,而是因为机器资源不够。比如一个任务特别吃内存,但同时并行十几个,结果内存爆了,任务被系统杀了。这种情况看起来很像是“并行冲突”,其实是并发度太高导致的。

Turborepo 允许你通过命令行参数限制并行任务的数量:

# 同时最多跑 4 个任务
turbo run build --concurrency=4

不过这个参数不推荐直接写死在配置里,因为它属于运行环境层面的设置。更合适的做法是放到 CI 的配置文件里,或者在 package.json 的脚本中传参:

{
  "scripts": {
    "build": "turbo run build --concurrency=4"
  }
}

3.4 环境变量与哈希的一致性

Turborepo 的缓存和增量构建依赖“哈希”。它会根据任务的代码、依赖、环境变量来计算一个哈希值,如果哈希变了,它才重新执行任务。假如一个任务依赖了某个环境变量,但你没告诉 Turborepo,那两个并行任务可能拿到不同的环境变量,结果构建出来的产物不一样,甚至互相覆盖。这算是一种隐蔽的冲突。

要解决这个问题,你需要在 turbo.json 中显式声明任务会使用哪些环境变量:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["API_BASE_URL", "NODE_ENV"]
    }
  }
}

这样 Turborepo 在计算哈希时,就会把这些环境变量的值也考虑进去。如果环境变量变了,它就认为任务需要重新执行,而不是直接拿旧缓存。这个声明不仅避免了环境变量不一致带来的问题,也让远程缓存更安全。

四、实际案例:从卡顿到顺畅

为了让上面的知识落地,我们用一个 Node.js 生态的示例工程走一遍。技术栈:Node.js + Turborepo。

4.1 工程结构

假设我们的 Monorepo 里有这么几个包:

  • packages/ui:公共 UI 组件库
  • apps/web:一个 Web 应用,依赖 ui
  • apps/docs:一个文档站点,也依赖 ui

目录结构大概长这样:

my-monorepo/
├── turbo.json
├── package.json
└── packages
    ├── ui
    │   ├── package.json
    │   └── src
    └── apps
        ├── web
        │   ├── package.json
        │   └── src
        └── docs
            ├── package.json
            └── src

4.2 第一次运行:冲突的现场

一开始,我们的 turbo.json 写得很粗糙,所有任务都没有声明依赖关系,只给了 output:

{
  "pipeline": {
    "build": {
      "outputs": ["dist/**"]
    },
    "test": {
      "outputs": ["coverage/**"]
    }
  }
}

然后我们运行:

turbo run build

结果经常报错。原因很简单:web 和 docs 的 build 都依赖 ui 的 build 产物,但在配置里没有写 dependsOn。于是 Turborepo 把三个包的 build 全部并行执行了。当 web 在读取 node_modules/ui/dist/index.js 时,ui 的构建还没完成,文件还没生成,于是直接报错“模块找不到”。这个错误看起来像是代码问题,其实是任务调度问题。

4.3 修正后的配置

我们要做的,就是明确告诉 Turborepo:所有应用在构建之前,必须等它依赖的包先构建完。于是我们修改 turbo.json:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["NODE_ENV"]
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "cache": false
    }
  }
}

这么改完以后,Turborepo 会这样调度:

  • ui#build 最先跑,因为它没有上游依赖。
  • web#build 和 docs#build 都会等 ui#build 完成后再并行开始。
  • 因为 web 和 docs 彼此没有依赖关系,所以它们俩可以同时跑,互不干扰。

我们再看一下更复杂的场景。如果 web 的 test 还需要 web#build 完成,那么配置里的 "test": { "dependsOn": ["build"] } 就能确保这一点。这里的 build 不带 ^,表示当前包自己的 build 任务。

为了让这个例子更真实,我们再写一个简单的 package.json 脚本,用来模拟构建过程。注意,这个脚本只是用来演示并行任务如何看到彼此的状态,不是完整的构建工具。

// packages/apps/web/build-demo.js
// 技术栈:Node.js
const fs = require('fs');
const path = require('path');

// 表示这个脚本是给 apps/web 用的
console.log('开始构建 web 应用...');

// 模拟需要读取 ui 包的构建产物
const uiDistPath = path.join(__dirname, '../../ui/dist/index.js');
if (fs.existsSync(uiDistPath)) {
  console.log('成功读取 ui 的产物,内容长度:', fs.readFileSync(uiDistPath, 'utf8').length);
} else {
  console.error('错误:没有找到 ui 的构建产物,说明 ui#build 还没跑完!');
  process.exit(1);
}

// 模拟自己生成 dist 目录
const distDir = path.join(__dirname, 'dist');
fs.mkdirSync(distDir, { recursive: true });
fs.writeFileSync(path.join(distDir, 'index.js'), 'console.log("web app");');
console.log('web 构建完成');

上面的脚本可以看到,如果 ui#build 还没生成 index.js,脚本会直接退出并报错。而当我们修正了 dependsOn 之后,这个脚本就能稳定运行了。

五、哪些场景最容易踩坑

5.1 大仓库的构建任务

包数量越多,并行冲突的概率越大。尤其是几十个包都引用了同一个基础包时,基础包的构建任务会成为一个“显眼”的瓶颈。你要是不给它设置应有的依赖,基础包就会被反复构建,还会和后启动的任务撞车。

5.2 依赖互相引用的微服务

有的微服务项目里,服务 A 需要依赖服务 B 的生成的接口类型。如果 A 和 B 的构建任务并行,A 拿不到 B 的类型定义,就会报错。这种场景除了需要 dependsOn: ["^build"],有时候还要给服务 B 额外加一个 generate 任务,保证类型文件先产出。

5.3 CI 环境中的清理工作

在 CI 环境里,每次构建都会重新安装依赖。如果你有类似 clean 的任务,它会把一些公共目录删掉。这个 clean 任务如果和别的任务并行,那别的任务还在读文件,你把它删了,自然就出问题。所以 clean 这类任务一般要设置为串行,并且关闭缓存。比如:

{
  "pipeline": {
    "clean": {
      "cache": false
    }
  }
}

这样 Turborepo 就不会把 clean 任务拿到并行队列里,它总是单独运行。

六、Turborepo 方案的优缺点

6.1 优点

  • 声明式依赖让任务关系非常清楚,代码可读性好。
  • 能自动计算缓存,跳过没改动的任务,大大加速本地和 CI 的构建。
  • 支持远程缓存,团队之间可以共享构建成果,避免重复劳动。
  • 与 npm/yarn/pnpm 的 workspace 无缝集成,上手成本低。

6.2 缺点

  • 如果任务之间的依赖关系比较复杂,dependsOn 的配置需要细心维护,否则容易漏掉某个引用关系。
  • 并行调度会带来一定的排查难度,当出现一个难以复现的失败时,你得学会看任务日志和缓存命中的情况。
  • 对于非 JS/TS 生态的项目支持有限,虽然也能用,但不如针对 Node.js 那么顺手。
  • 它更擅长解决“任务调度”的冲突,如果你自己写的构建脚本里有静态的全局状态,那还得靠代码质量来保证。

七、注意事项

在使用 Turborepo 并行任务时,有几条小建议可以帮你减少麻烦。

第一,尽量让每个任务只负责自己的目录,不要越过包边界去写别的包的文件。如果确实需要共享一些文件,请把它们放到一个公共包中,并且用一个独立的任务来生成。

第二,不要把所有环境变量都放到全局配置里。Turborepo 要求你显式声明任务依赖哪些环境变量。这个看似麻烦,实际上是在帮你避免隐性依赖。

第三,要记得为任务设置 outputs。如果你忘了写,Turborepo 可能会把不该缓存的文件也当成缓存的一部分,或者因为无法判断产物而干脆不缓存,这样并行调度的效率就低了。

第四,在 CI 中,如果任务失败,需要检查是否是缓存误命中导致的。你可以用 turbo run build --force 来强制忽略缓存重新跑一次,看看能否复现问题。这在排查“之前好好的,怎么这次挂了”时特别有用。

第五,不要忽视日志里关于 dependsOn 的警告。Turborepo 有时候会提示某个任务没有明确的依赖,但其他任务却引用了它的产物。这种警告意味着你的任务图有漏洞,迟早会出问题。

八、总结

并行任务本身不是坏事,它省时间、省资源,但前提是任务之间不能有隐形的拉扯。Turborepo 给我们提供了一套优雅的依赖描述方式,只要我们老老实实声明好 dependsOn、outputs、env 这些信息,它就能帮我们把并行调度做得井井有条。回到文章开头那个厨房的比喻,如果你明确告诉每个人谁先做饭、谁后做饭、谁用什么灶,那即使厨房很小,也能顺畅地出餐。Turborepo 就是那个帮你贴任务卡片的厨房管家。它解决的不是“要不要并行”的问题,而是“怎么在并行的同时不打起来”的问题。当你把这些冲突一个个解决掉,你的 Monorepo 才会真正变得又稳又快。