Turborepo 介绍

6,767 阅读11分钟

image.png

一、什么是 monorepo

Monorepo 是指在一个 git 仓库下管理多个项目的代码。这些项目可能是相关的,但通常在逻辑上是独立的,可以由不同的团队维护。

antd、umijs、vant、element UI 等开源软件,都是采用 monorepo 的方式组织项目代码。

单一代码库(monorepos) VS 多代码库(multirepos)

多代码仓库(multirepos):每个项目存储在一个完全独立的、版本控制的代码库中。是我们公司普遍使用的一种方式。例如每个项目的web端是一个代码仓库、H5是另一个。

从多代码库到单一代码库的变化就是将所有项目移到一个代码库中。

monorepo 的优缺点

优点:

  • 可见行:每个人都可以看到其他人的代码,对于跨团队开发比较友好,每个人都可去修复bug。
  • 一致性:把所有代码放在同一仓库,可以执行代码质量标准和统一风格更容易。
  • 共享时间线:某一共享项目或API变更会立刻被暴露出来,团队可以快速跟进变化或者第一时间使用新特性。
  • 共享性:不同项目相同点可抽离成单独项目,由其他项目共同使用(UI库、工具类等)。
  • 原子提交:开发人员可以在一次提交中更新多个包或项目。
  • 对于微前端项目更友好

缺点:

  • 性能差:命令及IDE开始变得缓慢
  • 破坏性:某一修改可能会影响到全局,如果没有经过严格的测试出现问题可能会造成多个项目出现bug
  • 学习曲线:增加开发人员学习成本,包括monorepo、多个紧密耦合项目的依赖关系
  • 权限控制:失去按项目控制权限
  • code reviews:多个项目同时修改多代码审查增加复杂度

团队使用 monorepo 解决了什么痛点?

前端团队在实际项目开发中遇到的痛点问题:

  • 团队已项目为导向,一个项目一个仓库,新项目与老项目相互独立,无法复用已有能力
  • 从项目积攒的经验依赖于项目,没有提炼出来,无法快速复用
  • 封装的组件无法共用,无组件文档
  • 使用微前端,采用相同架构时,网络请求、工具类、布局组件等都需要重新开发
  • 开始新项目时,要从老项目复制基本功能出来

团队使用 monorepo(turborepo)是如何解决上述痛点的?

  • 将一些能力抽离成独立项目,例如流程编排、动态表单,其他项目使用时引入即可(可能涉及到改造)
  • 搭建UI库项目,应用项目可以直接引入使用,省去UI库发布、npm内网环境等步骤,同时具备完整的文档
  • 将网络请求、工具类、加密等抽离成独立项目,由其他项目复用

团队采用哪种方式,或者弃用 monorepo,根据团队实际情况决定。

monorepo 是如何工作的?

monorepo 的主要构建块是工作区。您构建的每个应用程序和软件包都将在自己的工作区中,有自己的package.json。工作区可以相互依赖,例如docs工作区可以依赖于shared-utils

{
  "dependencies": {
    "shared-utils": "*"
  }
}

同时,根文件夹被称为根工作区,根文件夹下存在一个 package.json,它的作用是:

  1. 指定存在于整个 monorepo 中的依赖项
  2. 添加运行在整个 monorepo 上的任务

二、方案对比

不同方案对比

待完善

为什么选择 turborepo?

具有缓存机制、任务并行执行优化。同时由于项目使用 umijs 最为脚手架,其官网推荐使用 turbo 的缓存机制。

三、Turborepo 基本介绍

Turborepo是一个针对JavaScript和TypeScript代码库优化的智能构建系统。具有缓存、并行处理的特性。

Turbo 的特性

缓存任务

turbo 每次执行任务后,会缓存任务的结果和日志。原理如下:

  1. 评估任务的输入(默认情况下,工作区文件夹中所有非git忽略的文件),并转换成hash(例如:78awdk123)
  2. 检查本地缓存中是否有此hash缓存文件夹(如:./node_modules/.cache/turbo/78awdk123)
  3. 如果没有,则执行任务。
  4. 任务结束后,turbo 将所有输出(包括文件和日志)保存到以此哈希下命名的缓存文件夹中
  5. 如果有,则代表文件没有任何变化,直接把缓存文件的日志打印出来,并将保存的输出文件恢复到文件系统中的各自位置

并行任务

turbo 运行命令时将利用所有可用CPU处理尽可能多的任务。

如上图,有三个工作区,web 和 doc 都使用了shared。当build时,需要先构建 shared。如果在所有工作区运行所有任务:

yarn workspace run lint
yarn workspace run test
yarn workspace run build

任务流程示意图:

上述命令:先在所有工作区中运行 lint,然后 buildbuild 要先运行sharedbuild),最后test

使用 turbo 运行项目的话:

turbo run lint test build

任务流程示意图:

linttest 任务都是立即运行,因为它们在 turbo.jsono 中没有指定 dependsOnsharedbuild 任务首先完成,然后 webdocs 同时buildturbo.json 的配置如下:

{
  "pipeline": {
    "build": {
      "outputs": [".next/**", "!.next/cache/**",".svelte-kit/**"],
      "dependsOn": ["^build"]
    },
    "test": {},
    "lint": {}
  }
}

四、Turborepo 的使用

Turbo 接管工作区 package.json 中声明的脚本任务,并通过声明任务之间的依赖关系,并行执行任务。

(一)声明 pipeline

turbo.json 中,最重要的是 pipeline 配置,它用来声明工作区的任务及任务之间的依赖关系,同时可以用来配置任务的缓存。例如以下配置,声明了 build、test、lint、deploy 四个任务:

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", ".svelte-kit/**"]
    },
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"]
    },
    "lint": {},
    "deploy": {
      "dependsOn": ["build", "test", "lint"]
    }
  }
}

配置任务:

  1. 配置任务之间的依赖关系
  • 在同一工作区

如果工作区的任务依赖当前工作区的其他任务使用如下语法配置:“dependsOn”:["build"]。例如,执行 test 任务之前需要先执行当前任务区的 build 任务:

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"]
    },
  }
}
  • 在不同工作区

^符号用来声明任务依赖于它所依赖的工作区中的任务。例如,build 任务需要先完成它所依赖工作区的build 任务后,再执行自己的 build 任务

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
    }
  }
}
  • 依赖特定工作区的任务

语法:<workspace>#<task>,例如:frontend工作区的deploy 任务需要 uitestbackenddeploy 任务执行完后,才执行

{
  "$schema": "https://turbo.build/schema.json",
  "pipeline": {
    "frontend#deploy":{
       "dependsOn": ["ui#test", "backend#deploy"]
    }
  }
}
  1. 运行根节点任务

运行 monorepo 根目录package.json文件中的任务,语法:"//#<task>"。例如:"//#test":{}

  1. 其他

如果 pipeline 中声明的任务在所有工作区的package.json 中不存在,turbo会优雅地忽略这些任务,并不会报错.

pipeline 是唯一声明 turbo 任务的地方,未在 pipeline 中声明的任务,执行会报错

配置缓存约定

  • 配置缓存输出:

覆盖默认缓存输出行为,需设置 pipeline.<task>.outputs 数组。例如:

{
  "pipeline": {
    "build": {
      "outputs": [".next/**", "!.next/cache/**"],
      "dependsOn": ["^build"]
    }
  }
}

turbo 将缓存 build 任务产生的:排除cache文件夹的 .next 文件夹内容。

如果任务没有产生任何输出文件(例如test任务),可以省略 outputs 声明。即使没有任何文件产生,turbo 也会自动记录和缓存每个任务的日志,原文件没有更改且重新运行任务,会直接输入缓存的日志。

  • 配置缓存的输入:

只有某些相关文件更改时才重新运行该任务。可以通过 inputs 指定文件,只有当input 指定的文件发生更改时,才会运行该任务。配置示例:

{
  "pipeline": {
   
    "test": {
      "dependsOn": ["build"],
      "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts"]
    }
  }
}
  • 关闭缓存:

⚠️注意:写入缓存和读取缓存是分开的。

禁用写入缓存:

  1. 特定任务,通过配置:"cache": false
  2. 命令行:turbo dev --no-cache

禁用读取缓存,重新执行任务:

命令行:turbo build --force

(二)过滤工作区

有些情况,我们可能只想运行某个工作区的任务。这时候需要用到过滤工作区,语法如下:

按工作区名称过滤

语法:turbo build --filter=my-pkg

名称相似工作区内运行任务:--filter=*my-pkg*例如:turbo run build --filter=admin-*

多个过滤器:

想同时过滤两个工作区,语法:turbo build --filter=my-pkg --filter=my-app

运行依赖该工作区的所有其他工作区

语法:turbo test --filter=...my-lib,所有依赖my-lib的工作区都将执行,同时执行 my-lib

如果要排除 my-lib 本身:turbo test --filter=...^my-lib

运行该工作区及其依赖

语法:turbo build --filter=my-app...

如果要排除my-app本身:turbo build --filter=my-app^...

按目录过滤

语法:turbo build --filter='./app/*'

排除工作区

语法:turbo run build --filter=!@foo/bar

(三)在单个工作区配置 turbo.json

除了在根目录下配置turbo.json外,还可以在每个工作区单独配置一个turbo.json,用来声明针对当前工作区的 turbo 配置。

工作区配置与 <workspace>#<task> 语法的区别:

在根 turbo.json 中声明特定于工作区的任务时,它会覆盖基线任务配置,使用工作区配置,会将任务配置合并。推荐使用工作区配置。

示例:只有my-appbuild需要依赖compile任务,且只有my-appcompile任务。根turbo.json需要声明如下内容:

{
  "pipeline": {
    "build": {
      "dependsOn": ["compile"] // 2.添加任务依赖,所有工作区都要去执行,但实际上只有 my-app 需要
    },
    "compile": {} // 1. 添加compile任务声明
  }
}

使用工作区配置,可以在my-app工作区的turbo.json中声明自己的任务依赖,⚠️注意必须定义extends字段:

{
  "extends": ["//"], // extends唯一有效值是["//"]。//是一个用于标识monorepo根目录的特殊名称
  "pipeline": {
    "build": {
      "dependsOn": ["compile"]
    },
    "compile": {}
  }
}

然后可以移除根turbo.json中的声明:

{
  "pipeline": {
+    "build": {}
-    "build": {
-      "dependsOn": ["compile"]
-    },
-    "compile": {}
  }
}

(四)Yarn Workspace

Turborepo 只是接管任务的处理及缓存任务执行结果,turbo 不负责依赖管理和工作区管理。因此需要使用 npm、yarn、pnpm 进行依赖管理和工作区管理。团队中使用yarn1 最为依赖管理。

管理工作区

  • yarn 配置工作区
{
  "name": "my-monorepo",
  "workspaces": [
    "docs",
    "apps/*",
    "packages/*"
  ]
}
  • 给工作区命名

每个工作区都有一个唯一的名称,该名称在其 package.json 中用name指定:

{
  "name": "shared-utils"
}

name 的作用是: 1. 指定依赖安装到哪个工作区; 2. 在其他工作区中使用此工作区; 3. 发布到npm。

  • 引入某个工作区

当前工作区要使用其他工作区,需要使用其名称将其指定为依赖项。例如,如果想让apps/docs导入packages/shared-utils,需要在apps/docs/package.json中添加shared-utils作为依赖项:

{
  "dependencies": {
    "shared-utils": "*" // "*" 允许我们引入最新版本的依赖项。如果软件包版本发生变化,无需增加依赖项的版本
  }
}

就像声明普通包一样,声明后需要从根目录运行 install 安装。使用方式同普通的 npm 包。

  • 管理工作区

当我们添加/删除工作区或更改它们在文件系统上的位置时,都需要从 root 重新运行 install 命令来重新设置工作区。当从根目录运行 install 命令时,发生的事:

  1. 检查已安装的工作区依赖项
  2. 任何工作区都符号链接到 node_modules
  3. 其他软件包被下载并安装到 node_modules

依赖管理

  • 安装依赖

根目录下执行 yarn install

  • 在工作区中添加依赖

使用 yarn 的 workspace 语法

yarn workspace <workspace> add <package>

(五)快速开始

安装 turbo

yarn global add turbo

创建一个新的 monorepo

// 注意:dlx 命令是 yarn2 中的,yarn1 不支持,如果使用的是yarn1 可在官网下载示例DEMO,再进行yarn
yarn dlx create-turbo@latest

// 或者使用 pnpm(在命令行中可指定项目使用的软件包管理器)
pnpm dlx create-turbo@latest

创建完成后会看到 turborepo 的示例项目,这些项目的依赖关系如下:

  • web:依赖 uitsconfigeslint-config-custom
  • docs:依赖 uitsconfigeslint-config-custom
  • ui:依赖tsconfigeslint-config-custom
  • tsconfig:没有依赖项
  • eslint-config-custom:没有依赖项

这些依赖项的管理是由包管理器(yarn、npm、pnpm)处理,turbo 不负责管理依赖!!它只是帮助运行任务更加简单、高效。

Turbo 是如何运行的?

turbo 的配置文件在项目根目录下的 turbo.json:

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "lint": {},
    "dev": {
      "cache": false
    }
  }
}

上面使用 turbo 注册了三个任务:buildlintdevturbo.json 内注册的任务都可以通过 turbo run <task> (或 turbo <task>)运行。

每个任务都是来自每个工作区的 package.json 中声明的脚本。例如 build 任务:

{
  "name": "web",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  }
  ...
}

所有package.json中的脚本,都可以在 turbo.json 中配置对应的任务。

执行任务:

// 执行 lint
turbo lint

再次执行 lint

turbo lint

命令行会提示:3 cached, 3 total,这是因为 turbo 具有缓存机制,当我们代码没有任何更改时,执行相同任务会从缓存中直接取出上次的结果,这也是 turbo 具有高效的原因之一。

参考:

monorepo介绍:

xie.infoq.cn/article/4f8…

monorepo方案对比:

juejin.cn/post/714417…

turborepo官网:
turbo.build/repo