今天我们来创建一个 MonoRepo 的项目,一直以来,我们创建这样的项目需要非常多的前置学习资料,我们这次希望能够在做的过程中,也把相关的点说清楚。
该 MonoRepo 项目包含 tool 和 tool-test 两个子项目。
tool 子项目用来提供一些常用的工具,我们这里提供一个字符串工具,该工具提供一个函数 trim 去掉字符串前后空格和中间空格的函数。
test-tool 子项目,用来测试 tool 包中的 trim 函数,遍历常见的测试。
tool 子项目放在 packages 目录下, tool-test 子项目放在 test 目录下。
2. 实验步骤
- 目录规范
- 创建根项目 monorepo
- 创建子项目 tool
- 创建测试子项目 tool-test
3. 目录规范
我们可以看到,根目录下有 packages 和 test 目录。
packages
test
monorepo/
│
├── pnpm-workspace.yaml
├── package.json
├── packages/
│ └── tool/
│ ├── package.json
│ ├── src/
│ │ └── StringTool.ts
└── test/
└── test-tool/
├── package.json
├── src/
│ └── StringTool.test.ts
4.创建根项目 monorepo
4.1 初始化
首先我们创建 monorepo 目录,打开终端,进入 monorepo 目录后,使用 pnpm init 命令初始化根项目。
# 创建项目目录
mkdir -p /lab/monorepo
# 进入项目目录
cd /lab/monorepo
# 初始化根项目
pnpm init
4.2 package.json 解释
该命令,会在当前目录下生成一个 package.json 文件。
{
"name": "monorepo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo "Error: no test specified" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
这段 package.json 文件是 Node.js 项目的标准配置文件,它包含了项目的元信息和配置选项。下面是每个字段的详细解释:
name: 这个字段定义了包的名称。在这个例子中,它被设置为"monorepo",这通常是项目或包的名称。version: 这个字段指定了包的版本号。"1.0.0"表示这是一个初始版本(第一个主要版本)。description: 这个字段是对包的简短描述。在这个例子中,它是一个空字符串,意味着没有提供描述。main: 这个字段指定了项目的主入口文件。在这里,它被设置为"index.js",意味着当你的包被其他项目依赖时,require('monorepo')将会加载index.js文件。scripts: 这个字段是一个对象,包含了可以通过npm run命令执行的脚本。在这个例子中,只有一个test脚本,它运行一个简单的命令,打印一条错误信息,然后退出状态码为1,表示测试未通过。在实际项目中,这里会包含构建、测试、启动等脚本命令。keywords: 这个字段是一个数组,包含了与包相关的关键字。这些关键字有助于其他人在搜索包时找到它。在这个例子中,keywords是空数组,意味着没有指定任何关键字。author: 这个字段定义了包的作者。在这个例子中,它是一个空字符串,意味着没有提供作者信息。license: 这个字段指定了包的许可证。"ISC"是一个许可证类型,ISC 许可证是一种简单的许可证,它允许用户自由地使用、修改和分发软件,同时免除了版权持有者的责任。ISC 许可证与 MIT 许可证类似,但更简洁。
package.json 文件是 Node.js 项目的核心,它不仅包含了项目的元数据,还用于管理项目的依赖关系、脚本任务和构建配置等。通过 npm init 命令可以生成一个基础的 package.json 文件,然后可以根据项目的实际需要对其进行扩展和修改。
4.3 package.json 改造
因为我们是父项目,主要是用来管理项目,并不需要发布,因此,有很多不必须我们可以去掉。
同时,添加我们需要的。
{
"name": "monorepo",
"version": "1.0.0",
"description": "",
"private": true
}
4.4 添加 pnpm-workspace.yaml 文件
pnpm-workspace.yaml 文件是在使用 pnpm 包管理器时用于定义工作空间(Workspace)配置的文件。
这里,我们先做一个最简单的配置,方便大家学习。
首先在根目录创建一个 pnpm-workspace.yaml 文件 , 内容如下:
packages:
- "packages/*"
- "test/*"
在 pnpm-workspace.yaml 文件中,packages 字段用于定义工作空间中包含的包的路径。这个字段支持使用 glob 模式来匹配路径,从而指定多个包的位置。下面是对您提供的两个模式的详细解释:
1."packages/*":
这个模式表示工作空间将包含 packages 目录下的所有直接子目录。
例如,如果 packages 目录结构如下:
packages
├── app1
├── app2
└── lib1
那么 "packages/*" 将会包含 app1、app2 和 lib1 这三个包,因为它们都是 packages 目录的直接子目录。
2."test/*":
这个模式表示工作空间将包含 test 目录下的所有直接子目录。
例如,如果 test 目录结构如下:
test
├── app1-test
├── app2-test
└── lib1-test
那么 "test/*" 将会包含 test-app1、test-app2 和 test-lib1 这三个包,因为它们都是 test 目录的直接子目录。
在实际使用中,packages/* 通常用于包含所有的包或项目,而 test/* 可能用于包含所有的测试项目或与测试相关的包。但是,通常测试目录下的项目不会作为工作空间的一部分,因为它们通常不包含可发布的代码,而是用来测试其他包或应用的。因此,你可能不会经常看到 test/* 包含在工作空间定义中,除非有特定的需求。
此外,如果你不希望测试目录下的包被包含在工作空间中,你可以使用排除模式,如下所示:
packages:
- "packages/*"
- "!test/*"
在这个例子中,"!test/*" 表示从工作空间中排除 test 目录下的所有直接子目录。这样,即使 test 目录下的子目录包含有效的 package.json 文件,它们也不会被视为工作空间的一部分。
5.创建子项目 tool
5.1 初始化子项目tool
我们进入 monorepo 目录,打开终端,进入 ./packages/tool 目录后,使用 pnpm init 命令初始化根项目。
# 创建项目目录
mkdir -p /lab/monorepo/packages/tool
# 进入项目目录
cd /lab/monorepo/packages/tool
# 初始化根项目
pnpm init
5.2 添加 typescript 作为依赖
pnpm add typescript -D
-----results------------
Packages: +1
+
Progress: resolved 1, reused 1, downloaded 0, added 1, done
devDependencies:
+ typescript 5.6.2
Done in 1s
安装后,我们发现添加了devDependencies字段
{
...
"devDependencies": {
"typescript": "^5.6.2"
}
}
它定义了项目的开发依赖(devDependencies)。
在这个上下文中,devDependencies 是一个对象,它的键是包的名称,而值是对应的版本号或版本范围。这里的具体含义如下:
1."devDependencies"::这是 package.json 文件中的一个顶级字段,用于列出所有仅在开发环境中需要的依赖。这些依赖通常不会在生产环境中使用,例如构建工具、测试框架等。
2."typescript": "^5.6.2":这是 devDependencies 对象中的一个键值对,表示 TypeScript 编译器作为一个开发依赖。
"typescript"是包的名称,它是 JavaScript 的一个超集,提供了类型系统和编译时期的错误检查等功能。"^5.6.2"是一个版本范围,它告诉 npm(或 pnpm、yarn 等包管理器)安装5.6.2版本或更高版本的 TypeScript,但要确保与5.6.2版本兼容。这里的符号^是一个常见的版本范围前缀,它允许安装小版本的更新,但不包括主版本的更新。
版本范围的解释如下:
^:与>=和<之间的某个版本兼容。例如,^1.2.3允许1.2.3到1.(2+1).0之间的任何版本,但不包括2.0.0。~:与>=和<之间的某个小版本兼容。例如,~1.2.3允许1.2.3到1.3.0之间的任何版本,但不包括2.0.0。- 没有前缀:精确匹配指定的版本,不允许任何更新。
在这个例子中,如果你运行 npm install 或 pnpm install,包管理器将会安装 TypeScript 的 5.6.2 或更高版本,但不会安装主版本号为 6 的更新,因为这可能会导致不兼容的更改。开发人员通常会使用这样的版本范围来确保依赖项的稳定性,同时仍然能够接收到重要的安全更新和错误修复。
5.3 添加 src 文件夹和 StringTool.js
在子项目tool中新建 src 文件夹,新建 StringTool.js 文件,在 StringTool.js 中编写 trim 函数
export function trim(text: string): string {
return text.replace(/\s+/g, ' ').trim();
}
在 TypeScript 文件 StringTool.ts 中,这段代码定义了一个名为 trim 的函数,它是一个工具函数,用于处理字符串。下面是对这个函数的详细解释:
-
export关键字表示这个函数是模块的一部分,可以在其他文件中导入和使用。 -
function trim(text: string): string定义了一个名为trim的函数,它接受一个参数text,这个参数被明确地指定为string类型。函数的返回值也被标注为string类型。 -
text.replace(/\s+/g, ' ')是一个字符串替换操作。replace方法接受两个参数:第一个参数是一个正则表达式
/\s+/g:\s匹配任何空白字符,包括空格、制表符、换行符等。+是一个量词,表示匹配一个或多个前面的字符(在这里是空白字符)。g是一个全局搜索的标志,意味着匹配字符串中出现的所有空白字符,而不仅仅是第一个。
第二个参数是一个空格
' ',用于替换所有匹配到的空白字符序列。 -
.trim()是一个字符串方法,用于从字符串的开始和结束删除空白字符。在调用.trim()之前,replace方法已经将所有连续的空白字符替换为单个空格,因此.trim()将删除字符串两端的任何额外空格。
综上所述,trim 函数的作用是接收一个字符串,将其中的所有连续空白字符替换为单个空格,并且去除字符串两端的空白字符。这在处理用户输入或从文本文件中读取数据时非常有用,可以确保字符串两端没有不必要的空格,并且连续的空格被合理地压缩。
使用这个函数的一个例子可能是:
import { trim } from './StringTool';
const originalText = ' Hello, World! ';
const trimmedText = trim(originalText);
console.log(trimmedText); // 输出: "Hello, World!"
在这个例子中,originalText 字符串两端有多个空格,并且中间的逗号后面跟随着多个空格。调用 trim 函数后,这些多余的空格被处理,输出的 trimmedText 是一个没有前后空格且中间只有一个空格的字符串。
5.4 添加运行脚本
{
"name": "tool",
"version": "1.0.0",
"description": "",
"main": "./src/StringTool.js",
"scripts": {
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"typescript": "^5.6.2"
}
}
在 package.json 文件中,main 字段和 scripts 字段有特定的作用:
1."main": "./src/StringTool.js":
main字段指定了项目的主入口文件。当有人安装你的包并使用require()函数(在 Node.js 环境中)来引入整个包时,这个字段告诉 Node.js 应该加载哪个文件作为模块的入口点。- 在这个例子中,
"index.js"被指定为主入口文件。这意味着当其他项目通过require('tool')引入这个名为tool的包时,实际上他们会得到index.js文件中导出的内容。 - 这个字段对于模块的使用者来说很重要,因为它定义了模块的公共 API 的起点。
2."scripts": { "build": "tsc" }:
scripts字段是一个对象,包含了一组命令别名,这些命令通常在开发过程中通过 npm 运行。这些脚本可以用来执行各种自动化任务,如构建项目、运行测试、启动服务器等。- 在这个例子中,定义了一个名为
build的脚本,它运行tsc命令。tsc是 TypeScript 编译器的命令行接口,用于将 TypeScript 代码编译成 JavaScript。 - 要运行这个脚本,你可以在命令行中使用
npm run build或npm run build(npm会自动解析run命令)。这将执行tsc命令,通常用于编译整个项目中的 TypeScript 文件。 - 除了
build,scripts字段还可以定义其他脚本,如start、test、lint等,以适应不同的开发需求。
这个 package.json 文件的片段定义了一个简单的 Node.js 项目,它使用 TypeScript 作为开发语言。项目的构建脚本通过 tsc 命令来编译 TypeScript 代码,而 main 字段指定了项目的主入口文件。
package.json 文件中的 main 字段通常指向你的库或应用程序的主要入口点。对于 TypeScript 项目,你可能需要将 TypeScript 文件编译成 JavaScript,因为 Node.js 执行的是 JavaScript 代码。
如果你的主文件是 ./src/StringTool.ts,你需要在编译后的 JavaScript 文件中指定一个入口点。通常,你会在编译 TypeScript 代码时生成一个 index.js 文件或其他命名的文件,这个文件可以作为模块的入口点。
5.5 运行
进入项目对应目录,输入 pnpm build 即可编译。
pnpm build
编译后,会在 dist 目录看到 StringTool.js 文件生成。
6.创建测试子项目 tool-test
6.1 初始化子项目tool-test
我们进入 monorepo 目录,打开终端,进入 ./test/tool-test 目录后,使用 pnpm init 命令初始化根项目。
# 创建项目目录
mkdir -p /lab/monorepo/test/tool-test
# 进入项目目录
cd /lab/monorepo/test/tool-test
# 初始化根项目
pnpm init
6.2 添加测试组件
pnpm add -D jest ts-jest @types/jest
这个命令的作用是在当前项目中安装 jest、ts-jest 和 @types/jest 这三个包作为开发依赖,以便在开发过程中使用 Jest 进行测试,并且支持 TypeScript。
pnpm 是一个流行的 Node.js 包管理器,它旨在提高性能和节省磁盘空间。
add 是 pnpm 的一个命令,用于添加新的包到项目中。
-D 参数表示添加的包是开发依赖,也就是说这些包只在开发环境中使用,在生产环境中不需要。
jest 是一个流行的 JavaScript 测试框架,它允许你编写测试用例来测试你的代码。
ts-jest 是一个预处理器,它允许你在 Jest 测试环境中使用 TypeScript 编写测试代码。
@types/jest 是 Jest 的类型定义文件,它为 TypeScript 提供了类型支持,使得你可以在 TypeScript 项目中使用 Jest 时获得更好的类型检查和自动补全。
6.3 添加需要测试的工具包
在 package.json 中,我们找到 devDependencies 添加 "tool": "workspace:*" 。
这里需要注意的是, 我们在 devDependencies 添加的语法是这样的。
workspace:* 可能是用来指示 pnpm 在本地 Monorepo 的工作区中查找和链接这个包。
* 通配符可能表示安装任何版本的 tool 包,只要它存在于当前 Monorepo 的工作区中。
修改后的 package.json 文件如下。
{
...
"devDependencies": {
"@types/jest": "^29.5.13",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"tool": "workspace:*"
}
}
在 package.json 文件的 devDependencies 部分中,"tool": "workspace:*" 这个条目并不是一个标准的依赖声明。
这是一个特定于某个 Monorepo 工具链或脚本的自定义配置,用于指示 pnpm 或其他工具在 Monorepo 的上下文中处理这个包。
6.4 添加 jest.config.js 配置文件
在项目根目录下创建一个 jest.config.js 文件,配置 Jest。
以下配置文件告诉 Jest 如何处理 TypeScript 测试文件,设置测试环境为 Node.js,并且指定了额外的模块搜索目录。
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleDirectories: ['node_modules', '../../packages'],
};
-
preset: 'ts-jest':这个属性指定了
ts-jest作为预设配置。ts-jest是一个用于在 Jest 中运行 TypeScript 测试文件的预处理器。当你使用
ts-jest预设时,它会自动配置 Jest 来处理.ts和.tsx文件,并且会使用 TypeScript 编译器来转换这些文件为 JavaScript。 -
testEnvironment: 'node':这个属性设置了测试环境。在这里,它被设置为
'node',这意味着测试将在 Node.js 环境中运行,而不是在浏览器环境中。Jest 默认使用一个模拟的浏览器环境,但如果你在编写 Node.js 应用程序,你可能需要 Node.js 的环境来正确运行测试。
-
moduleDirectories: ['node_modules', '../../packages']:moduleDirectories配置项在 Jest 中用于指定 Jest 在解析模块时应该搜索的目录。这个配置项的目的是帮助 Jest 找到那些在测试文件中通过import或require语句引用的模块。默认情况下,Jest 会查找
node_modules目录中的模块。在这里,它被扩展了,还包括了相对于配置文件位置的../../packages目录。这通常用于大型的、多包(monorepo)项目,其中不同的包可能需要互相引用,
moduleDirectories主要是用来搜索运行测试时需要引用的库或模块。这包括:- 运行测试的文件:如果你的测试文件中引用了其他模块,那么 Jest 需要知道在哪里查找这些模块。
- 运行测试需要引用的库:同样,如果测试文件中引用了外部库或项目中的其他模块,Jest 也需要知道在哪里查找这些库或模块。
6.4 添加测试文件 StringTool.test.ts
我们根据被测试工程的文件和目录,添加了 StringTool.test.ts 文件,只比原有的 StringTool.ts 文件名多一个.test。
测试内容如下
import { trim } from 'tool';
describe('trim function', () => {
test('should remove spaces from start and end', () => {
expect(trim(' test ')).toBe('test');
});
test('should remove multiple spaces between words', () => {
expect(trim(' test test2 ')).toBe('test test2');
});
test('should handle empty string', () => {
expect(trim('')).toBe('');
});
test('should handle string with only spaces', () => {
expect(trim(' ')).toBe('');
});
});
6.5 添加运行脚本
在 scripts 中添加运行脚本 “test” : "jest"
// package.json
{
"scripts": {
"test": "jest"
}
}
添加完之后,内容如下
{
"name": "tool-test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "jest"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@types/jest": "^29.5.13",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"tool": "workspace:*"
}
}
7.编译和运行
可以独立到每一个目录执行对应的命令。
cd .\test\tool-test\
pnpm test
也可以回到 monorepo 主目录,从主目录进行执行。
pnpm -r build
pnpm -r test
在使用 pnpm 作为包管理器的 Monorepo 项目中,pnpm -r build 和 pnpm -r test 是两个非常有用的命令,它们允许你在整个项目的所有子包中运行指定的脚本。这里的 -r 参数是递归的意思,用于指示 pnpm 在所有子目录中执行命令。
pnpm -r build
命令 pnpm -r build 会在 Monorepo 的根目录下查找所有子包(通常是在 packages 目录下),并在每个子包中执行 build 脚本。这通常用于以下情况:
- 当你想要构建整个项目时,比如在准备发布之前。
- 如果你的项目中有多个包,且每个包都有自己的构建脚本,这个命令可以一次性构建所有包。
这个命令的好处是它节省了你逐个进入每个子包目录并运行构建命令的时间。
pnpm -r test
命令 pnpm -r test 会在 Monorepo 的所有子包中执行 test 脚本。这通常用于以下情况:
- 当你想要运行整个项目的所有测试用例时。
- 如果你的项目中的每个包都有自己的测试用例,这个命令可以确保所有测试都运行并验证。
这个命令确保了你可以在所有子包中进行测试,而不需要手动进入每个子包目录。
使用场景
在 Monorepo 结构中,通常会有多个独立的包,每个包都有自己的依赖、构建过程和测试。使用 pnpm -r 命令可以极大地简化工作流程,因为它允许你从根目录统一管理所有子包的构建和测试过程。
注意事项
- 确保每个子包的
package.json文件中都定义了build和test脚本。 - 这些命令假设你的 Monorepo 结构是标准的,且所有子包都位于
packages目录下。如果你的项目结构有所不同,你可能需要调整命令或pnpm-workspace.yaml文件来正确识别子包。 - 在运行这些命令之前,确保你已经安装了所有必要的依赖,可以通过运行
pnpm install来完成。
8.添加TurboRepo
Turborepo 是一个高性能的构建系统,专为 JavaScript 和 TypeScript 的 Monorepo 项目设计。
它通过以下几个关键特性来提高构建效率和优化开发体验:
- 并行执行:Turborepo 能够并行执行没有依赖关系的任务,最大化利用系统资源,从而加快构建速度。
- 增量构建:它只重新构建自上次构建以来发生变化的部分,对于未改变的部分则直接使用缓存结果,这样可以避免不必要的工作,提高构建效率。
- 缓存机制:Turborepo 提供了本地和远程缓存,可以缓存构建产物,以便在不同的机器或会话中复用。
- 依赖图:它通过构建依赖图来智能地确定任务的执行顺序,确保在执行任务之前其依赖已经满足。
- 配置灵活性:通过
turbo.json文件,用户可以定义自己的构建管道和任务依赖,实现定制化的构建流程。 - 跨平台:Turborepo 支持多种操作系统,包括 macOS、Windows 和 Linux。
- 易于集成:它可以轻松地集成到现有的 Monorepo 项目中,与现有的工具和工作流程兼容。
- 远程缓存:提供了远程缓存服务,允许团队成员共享构建缓存,进一步提高构建速度。
- 插件系统:允许开发者创建和共享插件来扩展 Turborepo 的功能。
Turborepo 适用于大型项目,特别是那些包含多个包和复杂依赖关系的项目,它可以帮助团队提高开发效率,减少等待时间,并优化持续集成/持续部署(CI/CD)流程。
8.1 在旧项目中加入
-
安装 Turborepo:
在项目根目录下运行
pnpm i turbo --save-dev来安装 Turborepo。 -
创建
turbo.json配置文件:在项目根目录下创建一个
turbo.json文件,用于定义构建管道和任务依赖。 -
配置构建管道:
在
turbo.json文件中配置pipeline,定义build、lint、test等任务。 -
调整
package.json脚本:在项目根目录的
package.json文件中添加 Turborepo 脚本,如"build": "turbo run build"。 -
安装依赖:
使用 pnpm 安装任何必要的依赖项,并确保工作区配置正确。
-
运行 Turborepo:
使用 Turborepo 命令运行任务,如
pnpm turbo run build。 -
配置 CI/CD(如果需要):
在 CI/CD 配置中使用 Turborepo 命令替代原有的脚本命令。
-
优化和调整:
根据项目需要调整 Turborepo 配置,如添加远程缓存、调整任务依赖等。
8.2 配置 turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "!dist/cache/**"]
},
"lint": {
"dependsOn": ["^lint"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
这个 JSON 文件是 Turborepo 的配置文件,它定义了 monorepo 中的构建管道(pipeline)和任务依赖。下面是对这个文件中每个部分的详细解释:
-
"$schema": "https://turbo.build/schema.json":这是一个 JSON Schema 引用,它指定了该 JSON 文件应该遵循的格式和结构。这有助于验证配置文件的正确性。
-
"globalDependencies": ["**/.env.*local"]:这一行定义了全局依赖,即所有任务都需要的文件或模式。在这里,它指定了所有以
.env结尾且包含local的文件。这通常用于定义环境变量,这些环境变量会被所有的包或任务共享。
-
"pipeline":这个对象定义了 monorepo 的构建管道,它指定了任务(如构建、测试、lint 等)如何执行。
-
"build":这是一个任务定义,用于构建任务。
"dependsOn": ["^build"]: 指定了任务依赖。"^build"表示上游依赖的构建任务。在 Turborepo 中,
^符号用于表示依赖于上游任务。这意味着在执行当前包的构建之前,会先执行所有依赖包的构建任务。"outputs": ["dist/**", "!dist/cache/**"]: 定义了构建任务的输出目录。这里的模式表示构建产物会被放在dist目录下,但不包括dist/cache目录。 -
"lint":这是一个任务定义,用于代码 linting 任务。
"dependsOn": ["^lint"]: 类似于构建任务,这表示在执行当前包的 lint 任务之前,会先执行所有依赖包的 lint 任务。 -
"dev":这是一个任务定义,通常用于启动开发服务器或执行其他开发相关的任务。
"cache": false: 指定这个任务不会被缓存。这通常用于那些不适合缓存的任务,比如长期运行的任务。"persistent": true: 表示这个任务是持久性的,比如开发服务器,即使完成也不会自动停止。
在 Turborepo 中,你可以为测试任务创建一个类似 build 或 lint 的定义。例如:
"test": {
"dependsOn": ["build"],
"outputs": ["coverage/**", "!coverage/cache/**"]
}
在这个例子中,test 任务依赖于 build 任务,并且测试覆盖率报告会被输出到 coverage 目录中,但不包括 coverage/cache 目录。
如果你需要添加测试任务,可以在 pipeline 对象中添加一个类似于 build 或 lint 的条目,并指定相应的依赖和输出。