很多前端团队把项目拆成多个小模块,用Lerna管理多包项目,后来发现Turborepo更快更顺手,就想迁移,但迁移时经常遇到脚本跑不通、任务没按顺序执行的问题,今天就把这些容易忽视的点讲清楚,帮大家少踩坑。

一、Lerna到Turborepo迁移时最容易踩的坑:脚本结构差异

1.1 脚本归属主体的差异

Lerna的脚本是写在每个子包的package.json里,相当于每个小模块自己管自己的命令,根目录没有统一的任务框架;而Turborepo是把所有统一的任务(比如编译、检查代码、测试)放在根目录的turbo.json里,形成一个全局的任务管道,子包不用写重复的脚本,只需要在全局配置里声明依赖关系就行。比如我们有两个核心子模块:工具模块utils和应用模块app,它们的原始Lerna脚本分别是这样的:

// 子包utils的package.json,自行定义命令
{
  "name": "utils",
  "version": "1.0.0",
  "scripts": {
    "build": "tsc",
    "lint": "eslint src"
  }
}
// 子包app的package.json,硬编码依赖utils的输出路径
{
  "name": "app",
  "version": "1.0.0",
  "scripts": {
    "build": "tsc && cp -r ../utils/dist ./shared-utils",
    "lint": "eslint src"
  }
}

迁移到Turborepo后,根目录会生成统一的turbo.json,把所有任务规则集中管理:

// 根目录turbo.json,全局任务管道配置
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"], // 当前任务执行前,要等所有依赖的子包的build任务完成
      "outputs": ["dist/**"] // 指定任务的输出目录,用于缓存判断
    },
    "lint": {
      "outputs": [] // lint任务没有输出,无需缓存
    }
  }
}

这种变化的好处是,应用模块不用再硬编码工具模块的路径,Turborepo会自动找到依赖的输出,减少了路径写错的概率。

1.2 脚本执行的触发方式差异

Lerna用lerna run build --scope app指定执行应用模块的编译,或者lerna run build默认执行所有子包的编译;而Turborepo用turbo run build,它会自动按依赖关系执行,比如app依赖utils的话,就会先执行utils的编译,再执行app的编译,不用手动指定模块,更智能。这里要注意,如果要实时看所有输出,原来的Lerna命令是lerna run build --stream,迁移后要换成turbo run build --output-logs=full,不然会看不到所有模块的执行日志。

二、任务定义的兼容性核心问题

2.1 任务依赖的声明方式差异

Lerna的任务依赖是通过命令行参数或者子包脚本里的硬编码实现的,比如app的测试脚本要依赖utils的编译,就得写lerna run build --scope utils;而Turborepo是在turbo.jsondependsOn里声明全局依赖,不用在每个子包脚本里写额外命令。比如原来Lerna里app的测试脚本是这样的:

// 子包app的package.json,测试依赖utils的编译
{
  "scripts": {
    "test": "jest && lerna run build --scope utils --ignore app"
  }
}

迁移后,只需要在turbo.json里给test任务加依赖声明:

// turbo.json里的测试任务配置
{
  "pipeline": {
    "test": {
      "dependsOn": ["^build"] // 自动等所有依赖包的build完成
    }
  }
}

这样就不用在每个子包的测试脚本里写lerna run,统一管理更方便,也避免了漏改脚本的问题。

2.2 任务输出的兼容性问题

Lerna默认不会缓存任务输出,每次执行都会重新跑一遍,而Turborepo默认会缓存,提升后续执行速度。这里很容易踩的坑是,原来的脚本如果输出目录不是统一的dist,比如app的编译输出到build/app,就需要在turbo.jsonoutputs里指定,不然缓存会失效,导致每次都重新编译。比如原来app的编译脚本是tsc --outDir build/app,迁移后要修改turbo.jsonbuild任务配置:

// 修改后的turbo.json,指定所有子包的输出目录
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", "build/app/**"] // 把所有子包的输出目录都列出来
    }
  }
}

如果漏写输出目录,Turborepo会误以为任务没有变化,直接复用旧缓存,而实际代码已经修改,就会出现运行时错误。

三、迁移的应用场景、优缺点与注意事项

3.1 应用场景

Turborepo更适合这些情况:项目拆成3个以上子包,每个子包有独立功能,需要统一管理编译、检查、测试等任务;团队成员经常修改不同子包,需要缩短任务执行时间,减少重复编译;项目要接入CI/CD流程,Turborepo的远程缓存能大幅提升构建速度。不适合的情况是项目只有单个包,没有子包,这时候用npm脚本即可,没必要换工具;或者项目的任务逻辑非常特殊,每个子包需要自定义大量命令,Turborepo的全局配置会限制灵活性。

3.2 技术优缺点对比

Lerna的优点是社区成熟,文档丰富,旧项目用得最多,自定义脚本的空间大,适合想完全掌控每个包命令的团队;缺点是任务分散,每个子包的脚本格式可能不一样,很难统一规范,没有缓存功能,CI/CD执行速度慢,大项目跑lerna run会非常慢。Turborepo的优点是任务管道清晰,统一配置,有本地和远程缓存,执行速度快,支持并行任务,适合大项目;缺点是需要学习配置逻辑,新手容易写错dependsOnoutputs,对于非常特殊的自定义脚本,需要调整原来的逻辑。

3.3 关键注意事项

迁移前要备份代码,把所有子包的当前版本提交到Git,避免出错后无法回滚;要检查每个脚本的退出码,Turborepo会根据退出码判断任务是否成功,原来Lerna里如果有忽略错误的脚本(比如eslint src || true),迁移后要改成正确的,不然任务流程会中断;先从两个子包的小项目开始迁移,测试编译、检查、测试能不能正常运行,没问题再迁移整个项目;迁移后要删掉旧的缓存目录,重新生成Turborepo的缓存,避免旧缓存干扰。

四、迁移实操与总结

4.1 实操步骤

第一步,在项目根目录安装Turborepo:

npm install --save-dev turbo

第二步,初始化基础配置,不用自己手动写所有内容,减少错误:

npx turbo init

第三步,调整子包脚本,把每个子包里重复的内容删掉,比如app的编译脚本可以改成"build": "tsc",依赖关系交给turbo.json管理; 第四步,测试执行,运行npx turbo run build,看是否按顺序执行utilsapp的编译,有没有错误; 第五步,接入CI/CD,比如GitHub Actions里配置Turborepo的缓存,进一步提升CI速度。

4.2 总结

迁移时最容易忽视的就是脚本的归属和任务依赖的声明,原来Lerna的脚本是分散在每个子包里,Turborepo是集中管理全局任务,必须把原来的子包依赖逻辑改成dependsOn的全局声明,同时要正确配置输出目录保证缓存生效。另外,要注意任务触发方式的变化,原来的lerna run可以换成turbo run,更智能灵活。只要把这些细节调整好,就能顺利完成迁移,享受到Turborepo带来的速度提升和规范管理的好处。