一、什么是 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,它的作用是:
- 指定存在于整个 monorepo 中的依赖项
- 添加运行在整个 monorepo 上的任务
二、方案对比
不同方案对比
待完善
为什么选择 turborepo?
具有缓存机制、任务并行执行优化。同时由于项目使用 umijs 最为脚手架,其官网推荐使用 turbo 的缓存机制。
三、Turborepo 基本介绍
Turborepo是一个针对JavaScript和TypeScript代码库优化的智能构建系统。具有缓存、并行处理的特性。
Turbo 的特性
缓存任务
turbo 每次执行任务后,会缓存任务的结果和日志。原理如下:
- 评估任务的输入(默认情况下,工作区文件夹中所有非git忽略的文件),并转换成hash(例如:78awdk123)
- 检查本地缓存中是否有此hash缓存文件夹(如:./node_modules/.cache/turbo/78awdk123)
- 如果没有,则执行任务。
- 任务结束后,turbo 将所有输出(包括文件和日志)保存到以此哈希下命名的缓存文件夹中
- 如果有,则代表文件没有任何变化,直接把缓存文件的日志打印出来,并将保存的输出文件恢复到文件系统中的各自位置
并行任务
turbo 运行命令时将利用所有可用CPU处理尽可能多的任务。
如上图,有三个工作区,web 和 doc 都使用了shared。当build时,需要先构建 shared。如果在所有工作区运行所有任务:
yarn workspace run lint
yarn workspace run test
yarn workspace run build
任务流程示意图:
上述命令:先在所有工作区中运行 lint,然后 build(build 要先运行shared 的build),最后test。
使用 turbo 运行项目的话:
turbo run lint test build
任务流程示意图:
lint 和 test 任务都是立即运行,因为它们在 turbo.jsono 中没有指定 dependsOn;shared 的 build 任务首先完成,然后 web、docs 同时build。turbo.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"]
}
}
}
配置任务:
- 配置任务之间的依赖关系
- 在同一工作区
如果工作区的任务依赖当前工作区的其他任务使用如下语法配置:“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 任务需要 ui的test、backend 的deploy 任务执行完后,才执行
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"frontend#deploy":{
"dependsOn": ["ui#test", "backend#deploy"]
}
}
}
- 运行根节点任务
运行 monorepo 根目录package.json文件中的任务,语法:"//#<task>"。例如:"//#test":{}
- 其他
如果 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"]
}
}
}
- 关闭缓存:
⚠️注意:写入缓存和读取缓存是分开的。
禁用写入缓存:
- 特定任务,通过配置:
"cache": false - 命令行:
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-app的build需要依赖compile任务,且只有my-app有compile任务。根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 命令时,发生的事:
- 检查已安装的工作区依赖项
- 任何工作区都符号链接到 node_modules
- 其他软件包被下载并安装到 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:依赖ui、tsconfig和eslint-config-customdocs:依赖ui、tsconfig和eslint-config-customui:依赖tsconfig和eslint-config-customtsconfig:没有依赖项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 注册了三个任务:build、lint、dev。turbo.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介绍:
monorepo方案对比:
turborepo官网:
turbo.build/repo