一、先搞懂 Turborepo 里的“任务”
很多刚接触 monorepo 的朋友,可能会被“任务”这个词吓到。其实你可以把它理解成一份流水线作业单。你站在车间里,手里拿着几张纸,每张纸上写着一条工序:先做零件、再做组装、最后质检。Turborepo 就是帮你管理这份作业单的监工。
在 Turborepo 中,每个任务都对应 package.json 里的一个脚本。比如某个包里写了 "build": "tsc",那这个包就有一个叫 build 的任务。如果你不做任何配置,直接运行 turbo run build,它会找到所有 workspace 里的 build 脚本,然后并行跑起来。
注意,并行跑是默认行为,但不一定正确。因为如果你的前端项目依赖一个公共库,而公共库还没编译,前端项目就会报错。这时候你需要的不是“并行”,而是“先编译公共库”,再编译前端。Turborepo 的任务类型设计,就是为了让这种先后关系变得明确、可控。
1.1 任务类型到底指什么
任务类型就是你在 turbo.json 中定义的一个个“任务名”。比如 build、typecheck、lint、test,这些名字本身没有魔法,只有当你把它们和 package.json 里的 scripts 对应起来,它们才会发挥作用。你可以把任务类型理解为“一顶帽子”,戴上这顶帽子的命令,会被 Turborepo 统一调度。
比如你想自定义一个叫 compile 的任务,只需要在每个需要的包里都写一个 compile 脚本,然后在 turbo.json 里配置 compile 的依赖和输出。Turborepo 不会限制你只能用官方推荐的几个名字,这是它灵活的地方。
1.2 任务类型和普通脚本的区别
普通脚本只是“一条命令”,你只能自己控制顺序。任务类型则多了一层“元数据”,它告诉 Turborepo 这个任务的输入是什么、输出在哪里、依赖哪些别的任务、是否要缓存。有了这层信息,Turborepo 才能智能地跳过重复工作。
以前你可能会这样写:
# 先编译公共库
cd packages/shared && npm run build
# 再编译前端
cd apps/web && npm run build
但这种方式在包一多之后就很难维护。用任务类型描述依赖关系,不需要关心具体在哪执行,Turborepo 会自动拓扑排序。
二、基础配置一眼看懂
2.1 一个最小的 monorepo 结构
我们先搭一个最简单的 monorepo,技术栈使用 TypeScript 和 Node.js。目录结构如下:
my-mono/
package.json
turbo.json
apps/
web/
package.json
tsconfig.json
src/
index.ts
packages/
shared/
package.json
tsconfig.json
src/
index.ts
根目录的 package.json 需要声明 workspace,并且把 turbo 的命令放到 scripts 里。内容如下:
{
"name": "my-mono",
"private": true,
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"build": "turbo run build",
"typecheck": "turbo run typecheck"
}
}
2.2 turbo.json 里的任务清单
接下来,我们在 turbo.json 里定义任务。这里只放两个任务:build 和 typecheck。
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"typecheck": {
"dependsOn": ["^typecheck"]
}
}
}
现在逐行解释。build 和 typecheck 就是我们自定义的任务类型。dependsOn 表示依赖关系,^build 的意思是“依赖包里的 build 任务”。也就是说,如果 apps/web 依赖 packages/shared,那么在运行 web 的 build 之前,Turborepo 会先运行 shared 的 build。这个 ^ 号特别重要,没有它的话含义就变成“先跑自己包里的 build”,而不是“先跑依赖包的 build”。
outputs 用来告诉 Turborepo 这个任务生成哪些文件。比如 dist/** 表示 dist 目录下的所有文件都会被当作产物。有了 outputs,Turborepo 才能实现增量缓存:如果上一次构建的输入都没变化,它就跳过执行,直接复用旧产物。
2.3 包的脚本要配齐
接下来看各个包里的 package.json。这才是真正执行命令的地方。
{
"name": "@my-mono/shared",
"version": "1.0.0",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit"
}
}
{
"name": "@my-mono/web",
"version": "1.0.0",
"dependencies": {
"@my-mono/shared": "1.0.0"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit"
}
}
注意,这两个包里的脚本名称必须和 turbo.json 里定义的任务名称完全一致,否则 Turborepo 找不到对应脚本。shared 包里的 typecheck 脚本是 tsc --noEmit,意思是不生成文件,只做类型检查。web 包的 build 在真正执行时,会先由 Turborepo 确保 shared 包已经构建完成,然后才调用 tsc。
下面是一个简单的 TypeScript 源码,用于验证模块引用关系。
// packages/shared/src/index.ts
export function add(a: number, b: number): number {
return a + b; // 一个简单的加法
}
export function hello(name: string): string {
return `Hello, ${name}`;
}
// apps/web/src/index.ts
import { add } from "@my-mono/shared"; // 引用公共包
console.log(add(1, 2)); // 输出 3
三、把类型检查加入管线
很多项目只把 build 当作任务,忽略了 typecheck。其实 typecheck 是一个非常典型的“自定义任务类型”:它不产生 dist 产物,但能提前发现类型错误。在大型 monorepo 中,每个包都可能存在类型不一致的问题,如果所有代码都编译到一半才发现错误,那会非常浪费时间。
所以我们把 typecheck 单独拎出来,让它在 build 之前执行。要实现这个顺序,只需要在 turbo.json 里这样配置:
{
"tasks": {
"build": {
"dependsOn": ["^build", "typecheck"],
"outputs": ["dist/**"]
},
"typecheck": {
"dependsOn": ["^typecheck"]
}
}
}
这里把 typecheck 加进了 build 的 dependsOn。含义是:每个包在运行自己的 build 之前,必须先运行自己这个包的 typecheck。同时 ^typecheck 保证了依赖包的 typecheck 也会先跑。这样整个流程就变成:先跑所有依赖包的 typecheck,再跑依赖包的 build,然后跑本包的 typecheck,最后跑本包的 build。顺序非常清晰。
有人会问:那为什么不直接在 build 的脚本里写 npm run typecheck && tsc?那样当然可以,但你就放弃了 Turborepo 对 typecheck 的独立缓存和单独调度能力。假设你只想跑 typecheck,不编译,那么用 Turborepo 可以做到。而且如果 typecheck 失败,Turborepo 会跳过后续 build,避免在错误代码的基础上继续编译。
如果你担心顺序不对,可以打开详细模式查看执行计划。命令如下:
# 显示每个任务的执行顺序和耗时
npx turbo run build --verbose
四、依赖关系的更多“花活儿”
4.1 区分自身任务和依赖任务
dependsOn 里的任务名字可以带 ^,也可以不带。带 ^ 指的是 workspace 依赖中的同名任务,不带 ^ 指的是本包内需要先在它前面执行的任务。举个例子:
{
"tasks": {
"test": {
"dependsOn": ["build"]
}
}
}
这个配置表示:每个包在跑 test 之前,先跑本包的 build。注意,这里没有 ^,所以 Turborepo 不会为此去跑依赖包的 build。如果你想在跑 test 之前,先把所有依赖包构建好,那就要写 ["^build"]。
4.2 使用自定义任务名称
Turborepo 允许你用任意名字定义任务,不限于 build、test、typecheck。比如你可以定义 compile 作为编译任务,定义 check-types 作为类型检查任务。只要每个包里的 scripts 都有对应脚本就行。
{
"tasks": {
"compile": {
"dependsOn": ["^compile", "check-types"],
"outputs": ["dist/**"]
},
"check-types": {
"dependsOn": ["^check-types"]
}
}
}
然后在需要参与执行的包里加上这两个脚本。以 shared 包为例:
{
"name": "@my-mono/shared",
"scripts": {
"compile": "tsc -p tsconfig.json",
"check-types": "tsc --noEmit"
}
}
现在,当你运行 turbo run compile 时,Turborepo 会把 check-types 也带上来跑,因为 compile 依赖它。这样的好处是,如果你只想 check-types,直接运行 turbo run check-types 就行,它不会触发编译。
4.3 用 inputs 精准控制缓存
每个任务都会计算一个哈希值,用于判断缓存是否命中。默认情况下,Turborepo 会考虑这个包里的所有文件。但你也可以指定只关心哪些文件,这样能提高缓存命中率。例如:
{
"tasks": {
"compile": {
"dependsOn": ["^compile", "check-types"],
"outputs": ["dist/**"],
"inputs": ["src/**/*.ts", "tsconfig.json"]
}
}
}
这段配置的意思是:只有 src 目录下的 TypeScript 文件,以及 tsconfig.json 发生变化时,compile 的缓存才会失效。如果只是改了一个 README,就不会触发重新编译。这在实际开发中特别省事,尤其是当你的依赖包很多时。
4.4 任务筛选与远程缓存
当你不想运行所有包的任务时,可以用 filter 来限定范围。比如只想检查 shared 包的类型,可以这样:
# 只对 @my-mono/shared 执行 typecheck
npx turbo run typecheck --filter=@my-mono/shared
如果你希望检查所有依赖了 shared 包的应用,也可以按依赖条件筛选:
# 对所有依赖 @my-mono/shared 的包执行 typecheck
npx turbo run typecheck --filter=...@my-mono/shared
另外,Turborepo 支持远程缓存,可以把产物哈希上传到云端。你只需要登录并关联项目:
# 登录 Turborepo 账号
npx turbo login
# 关联当前项目
npx turbo link
远程缓存对于 CI 来说非常有用:同一个任务的缓存一旦被某个成员或 CI 生成,其他人就能直接下载,不再重复执行。这在大型团队中的收益非常明显。
五、应用场景、优缺点和注意事项
5.1 最典型的应用场景
第一种是“库先编译,应用后编译”的 monorepo。比如你有一个 components 库,两个前端应用都依赖它,这时候在 build 任务里设置 dependsOn 为 ^build,就能保证库先构建,应用后构建,不会出现“找不到模块”的尴尬情况。
第二种是 CI 流水线。你希望每个 Pull Request 都执行类型检查、单元测试和构建,但不想每次都从头跑。Turborepo 的缓存可以让你只检查改动的包,其它包直接复用上次结果。再加上远程缓存,团队成员还能共享缓存,效率更高。
第三种是大型仓库的批量修改。当你改了公共库的接口,想看看哪些下游包会报类型错误,直接运行 turbo run typecheck 即可,它会精准地按依赖关系逐个检查。
5.2 这个方案有哪些优点和缺点
优点很明显。第一,执行顺序由配置文件维护,不用靠脑子记忆。第二,每个任务都有自己的缓存,重复操作非常省时间。第三,任务粒度细,可以单独跑 typecheck、build、lint,也可以把它们组合起来。第四,如果使用远程缓存,还能把缓存放到云端,让 CI 和本地共享。
缺点也不是没有。最大的问题是学习成本。^ 前缀、outputs、inputs、cache 这些概念,第一次接触容易晕。另外,你必须在每个包的 package.json 里都把脚本写全,少一个 Turborepo 就会报错“找不到任务”。还有,如果设置不当,可能产生循环依赖,导致任务永远无法完成。比如 A 的 build 依赖 B 的 build,B 的 build 又依赖 A 的 build,Turborepo 会直接报错。
5.3 实际操作中要注意什么
第一,脚本名字别写错。turbo.json 里的任务名和 package.json 里的 scripts key 要严格一致。第二,outputs 不要写成绝对路径,也不要写到包目录外面去。第三,像 dev、start 这类常驻进程,一定要在 turbo.json 里设置 cache 为 false,否则它会一直尝试缓存一个永远不会结束的任务。第四,注意 dependsOn 的粒度,不要一个任务依赖太多不必要的东西,否则会失去并行优势。
常驻任务的配置如下:
{
"tasks": {
"dev": {
"cache": false,
"persistent": true
}
}
}
这里 persistent 表示这个任务是长期运行的,Turborepo 会取消对它的超时限制。
5.4 一个简化的完整流程示例
最后,我们用一个相对完整的示例来收尾。假设你定义了 compile 和 check-types 两个任务,并且在根 package.json 中这样串联它们:
{
"scripts": {
"compile": "turbo run compile",
"check-types": "turbo run check-types",
"verify": "turbo run check-types && turbo run compile"
}
}
当你运行 npm run verify 时,就会先对所有包做类型检查,再执行编译。如果类型检查失败,编译根本不会开始。这种模式非常适合用在发布前或者合并代码前。
六、总结
Turborepo 的任务类型设计,本质上是对 npm scripts 的一次升级。它原本只是“一条命令”,你现在可以在配置里声明它的前置任务、产物文件、输入文件、是否需要缓存。这种做法的核心价值,是把开发者的心智负担从“记住顺序”变成“描述关系”。
对于编译和类型检查这种前后关联紧密的任务,强烈建议把它们拆成独立的任务类型,然后用 dependsOn 组合。编译负责产出,类型检查负责验证,两者各司其职,缓存也能分开复用。当你发现某个包的改动导致下游类型错误时,只要看任务输出的依赖链路,就能快速定位问题。
最后记住一句话:Turborepo 不关心你怎么写命令,它只关心这些命令之间的关系。关系定义好了,剩下的事就交给它来执行。
评论
围绕“自定义Turborepo任务类型:从编译到类型检查的全流程管线设计与依赖关系定义技巧”参与讨论