1. Monorepo简介
1.1 Monorepo 和 Polyrepo
Monorepo
和Polyrepo
是两种不同的代码仓库的管理策略。
其中Monorepo
是使用一个仓库管理多个项目的代码,而Polyrepo
是使用一个仓库管理一个项目代码。
Polyrepo
策略的问题:
- 复用繁琐:项目之间的代码复用一般需要通过私有npm仓库或者文件拷贝
- 数据冗余:大量重复的命令行脚本、构建工具的配置文件等
- 依赖冗余:系统中会存在大量重复的npm依赖包
- 调试困难:在排查定位问题时,需要切换环境和项目仓库
Monorepo
策略的优势:
- 环境简单:代码属于同一个仓库,开发过程无需切换环境
- commit原子化:每一次commit都是基于某个特定的功能的修改(可能涉及多项目)
- 无版本冲突:所有项目的公共依赖可以保证版本的统一
- 协作高效:由于多项目在一个仓库中,更方便检视其他项目开发人员的代码提交,便于发现问题和协作
1.2 常用的Monorepo
管理工具
-
Bazel 是一个基于Make和Maven的Java构建工具。
- Bazel可以跨平台构建不同类型的代码,包括但不限于Java、C++、Python和Go等。
- Bazel可以将多个代码仓库和构建过程组合为一个单一的模块化构建过程,并通过构建缓存来提供高效和可靠的增量编译。
- Bazel支持在线构建,允许开发人员在离线环境之外构建和测试代码。
- Bazel支持Docker镜像,并可以将其用于代码构建和测试。
-
Lerna 它主要用于管理JavaScript的
monorep
项目。- Lerna的主要功能是使得在代码库中同时管理多个模块更加容易,从而优化跨包的代码共享和开发流程。
- Lerna可以通过一系列命令来管理多包,包括初始化项目、安装依赖关系、发布子包和管理版本等。
2. Pnpm
简介
pnpm
是一个javascript
的包管理器,可以替代npm
和yarn
。
相比于其他包管理器,pnpm
的主要优势在于:
- 共享依赖:
pnpm
会将多个项目中的相同依赖下载到一个共享的位置,并将它们链接到每个项目中。这意味着更少的重复下载和磁盘空间占用,以及更快的安装。 - 更快的安装:由于
pnpm
会使用硬链接和符号链接,因此在安装依赖时,不需要重新复制整个依赖树,只需要复制依赖树的一部分即可。这使得pnpm安装速度比npm更快。
2.1 Pnpm
和Lerna
的区别
pnpm
和lerna
都是JavaScript
项目管理工具。
以下是pnpm
和lerna
的一些异同点:
相同点:
- 一次性安装多个项目的依赖项。
- 具有类似于npm的功能,如版本锁定和依赖项查找。
- 允许您在本地存储中缓存依赖项,以便加快安装速度。
- 支持分别管理项目的依赖项。
- 可以在
Monorepo
项目中使用。
不同点:
pnpm
采用唯一依赖共享机制,而lerna
则采用符号链接。pnpm
使用单一存储库,而lerna
使用多存储库。pnpm
支持自动垃圾收集删除不再需要的依赖项,而lerna
不支持。pnpm
可以并行安装依赖项,而lerna
在这方面的表现相对较差。pnpm
支持自动版本锁定,而lerna
需要手动处理版本锁定。
3. Workspaces简介
在使用Npm
/Pnpm
/Yarn
等包管理工具时,Workspaces的概念是实现 monorepo
的一种手段。Workspace的本质是建立起本地文件和node_modules依赖之间的链接,从而实现不同的package之间的引用。
当使用npm
时,Workspace的实现是通过在package.json
文件中创建一个 workspaces
字段来定义相关的数据,该字段的值是一个字符串数组:
package.json
{
"workspaces": [ "packages/b", "packages/a" ]
}
当使用pnpm
时,Workspace的实现是在项目根目录下创建一个pnpm-workspace.yaml
文件,文件内容示例如下:
pnpm-workspace.yaml
packages:
# all packages in direct subdirs of packages/
- 'packages/\*'
# all packages in subdirs of components/
- 'components/\*\*'
# exclude packages that are inside test directories
- '!**/test/**'
4. 构建项目
- 首先使用
pnpm
初始化一个项目
pnpm init
- 在项目根目录下创建
apps
和packages
文件夹,其中apps
文件夹用于存储可构建、可发布的应用,packages
文件夹用于存储各组件或功能模块
mkdir apps packages
3.在项目根目录下配置Workspace数据,在根目录下创建一个pnpm-workspace.yaml
文件,并写入如下内容
pnpm-workspace.yaml
packages:
# all packages in direct subdirs of packages/
- 'packages/*'
# all apps in direct subdirs of apps/
- 'apps/**'
# exclude packages that are inside test directories
- '!**/test/**'
配置pnpm的代理,在项目根目录下创建 .npmrc
文件,并写入
registry=https://registry.npm.taobao.org/
上述步骤执行完成执行完成之后一个简单的目录结构如下
.
+-- packages
+-- apps
`-- .gitignore
`-- .npmrc
`-- package.json
`-- pnpm-worksapce.yaml
4. 创建一个vue3
+TypeScript
的app
# 进入apps的文件夹
cd apps
# 根据模板创建一个vite+vue+TypeScript项目
pnpm create vite
# 进入vite项目
cd vite-project
# 安装项目的依赖
pnpm install
成功执行完成之后,整个的项目目录结构变成了下面的样子
.
+-- packages
+-- apps
`-- vite-project
`-- package.json
+-- public
+-- src
...
`-- .gitignore
`-- .npmrc
`-- package.json
`-- pnpm-worksapce.yaml
5. 一般情况下,在monorepo项目中,无需从根目录进入到子目录中去启动项目。使用worksapce的方式,在项目根目录下,使用如下格式命令即可运行对应项目package.json
中scripts
脚本中的内容
pnpm --filter <package-name> <command>
上述命令运行时,pnpm
会去Workspace的文件夹下的项目中寻找名为package-name
的项目,并运行项目package.json
文件scripts
中的command
命令。其中项目的名字由项目中package.json
里name
字段指定。
pnpm --filter vite-project dev
当该命令执行完成之后,项目就正常运行起来了。
- 在项目可以正常运行后,我们需要为应用安装组件库依赖,在项目根目录下执行以下的命令去安装
element-plus
组件库
# pnpm add --filter <package-name> <depends>
pnpm add --filter vite-project element-plus
- 创建一个功能模块
# 从根目录进入packages文件夹
cd packages
# 创建一个utils文件夹
mkdir utils
# 进入utils文件夹
cd utils
# 初始化一个空的项目
pnpm init
在utils文件夹下创建 index.ts
文件,修改package.json
文件的内容
{
"name": "utils",
"main": "dist/index.js",
"script": {
"build": "rm -rf dist && tsc"
}
}
在utils
文件夹下配置tsconfig.json
文件
{
"compilerOptions": {
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"module": "commonjs",
"outDir": "./dist"
},
"include": ["."],
"exclude": ["dist", "node_modules", "**/*.spec.ts"]
}
在index.ts
中编写一个生成随机字符串UUID的函数
export function generateUUID() {
let uuid = ''
let i
let random
for (i = 0; i < 32; i += 1) {
// eslint-disable-next-line no-bitwise
random = (Math.random() * 16) | 0
if (i === 8 || i === 12 || i === 16 || i === 20) {
uuid += '-'
}
// eslint-disable-next-line no-nested-ternary, no-bitwise
uuid += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random).toString(16)
}
return uuid
}
为了避免编写冗余的TypeScript配置文件,有些通用的配置可以在项目根目录下进行配置,在根目录下创建tsconfig.json
文件
{
"compilerOptions": {
"baseUrl": "./",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": \["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@packages/*": ["packages/*"],
"@apps/*": ["apps/*"]
}
},
"include": \[
"apps/**/\*.ts",
"apps/**/*.d.ts",
"apps/\*\*/*.tsx",
"apps/**/\*.vue",
"packages/**/*.ts",
"packages/\*\*/*.d.ts",
"packages/**/\*.tsx",
"packages/**/*.vue"
],
"exclude": \["dist", "node\_modules", "\*\*/*.spec.ts"]
}
在Workspace中的子项目中设置TypeScript配置信息的继承关系
apps/vite-project/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
/* Bundler */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve"
},
"include": \["src/**/\*.ts", "src/**/*.d.ts", "src/\*\*/*.tsx", "src/\*\*/\*.vue"],
"references": \[{ "path": "./tsconfig.node.json" }]
}
packages/utils/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist"
},
"include": \["."]
}
构建utils
pnpm --filter utils build
- 在项目中引入依赖, 使用
pnpm
的add
命令进行这一操作
pnpm add utils --filter vite-project --workspace
在执行完上述的命令之后,vite-project
的package.json
中dependcies
字段下会多出一条记录
{
"dependencies": {
...
"utils": "workspace:^",
...
}
}
workspace:^
表示依赖包通过workspace指定的本地文件解析,而不是从某个远程注册表(如NPM
)解析。^
只是表示我们希望依赖于它的最新版本,而不是特定的版本。只有在使用外部NPM
包时,使用特定版本才有意义。
- 在
vite-project
项目中引入utils
包,并使用其中的功能函数
<script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue'
import { generateUUID } from 'utils'
</script>
<template>
<div>
<a href="https://vitejs.dev" target="_blank">
<img src="/vite.svg" class="logo" alt="Vite logo">
</a>
<a href="https://vuejs.org/" target="_blank">
<img src="./assets/vue.svg" class="logo vue" alt="Vue logo">
</a>
</div>
<h1>{{ generateUUID() }}</h1>
<helloworld msg="Vite + Vue">
</helloworld>
</template>
之后运行项目
pnpm --filter vite-project dev
构建项目
pnpm --filter vite-project build
5. 包管理工具优化
使用pnpm
可以对monorepo
项目进行一个基础的管理,但是也面临一些问题:
- 指定单个项目构建时,如果依赖有更新,需要手动构建该项目的依赖
- 运行项目时,如果worksapce中的依赖项未构建,无法找到对外暴露的构建后的内容,则会报错(在上述步骤中如果在utils创建后未进行构建,vite-project项目在运行时就会报错)
- 在构建时,没有文件缓存,项目变庞大或者项目文件未变化时,启动项目或者构建项目需要很长时间
- 命令行使用较为繁琐,需要指定–filter参数等
针对上面的这些问题,可以使用nx
工具,从而完成一些自动化的操作。
- 在项目中进行nx初始化
npx nx@latest init
在完成一些初始化的配置之后,项目根目录中会生生一个nx.json的文件
{
"tasksRunnerOptions": {
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"cacheableOperations": \[]
}
}
},
"affected": {
"defaultBase": "main"
}
}
2.运行和构建项目,nx
使用如下格式运行命令
npx nx <target> <project>
其中target
是package.json
scripts
字段下定义的脚本,project
对应workspace中的项目名称。
使用nx构建vite-project时,可以使用以下命令
npx nx build vite-project
接下来建议使用全局安装nx
,这样就可以避免每次运行命令时使用npx前缀
pnpm install --global nx@latest
运行构建命令如下:
nx build vite-project
当第一次执行完该命令后,构建完成耗时2s
再次执行该命令时,由于没有文件变更,构建仅仅耗时11ms
由于缓存机制,缓存输入未变动时,二次构建的时间大卫缩短。
- 配置缓存,执行
nx init
命令时,nx
会进行以下配置:
- 收集worksapce中所有依赖的NPM script数据
- 询问用户哪些脚本执行后需要缓存
- 询问用户哪些脚本需要在项目执行时去执行,例如当运行build脚本时需要预先执行项目依赖的build脚本
- 询问脚本执行后是否有文件输出,如果有则会作为缓存的一部分
在上述初始化配置完成之后,会在项目根目录下生成一个nx.json
的文件
{
"tasksRunnerOptions": {
"default": {
"runner": "nx/tasks-runners/default",
"options": {
"cacheableOperations": [
"build"
]
}
}
},
"affected": {
"defaultBase": "master"
},
"targetDefaults": {
"build": {
"dependsOn": [
"^build"
],
"outputs": [
"{projectRoot}/dist"
]
},
"test": {
"dependsOn": [
"^test"
]
}
}
}
在进行缓存时,更多的是关注源码的改动,如果文档类型的改动我们并不希望脚本重新执行,例如,我们修改了项目的readme.md
但是并没有修改源码,我们并不希望在build
时进行重新的构建,这个时候我们就需要再输入变更的检查中剔除.md
文件
改动如下
{
...
"targetDefaults": {
"build": {
"dependsOn": [
"^build"
],
"outputs": [
"{projectRoot}/dist"
],
"inputs": [
"!{projectRoot}/**/*.md"
]
},
...
}
如此一来,在build
运行时,md
文件就不会作为影响输出输入文件,而不会影响到缓存数据。在nx.json
中我们可以自定义的编辑cacheableOperations
的值去更改我们需要缓存的NPM script
命令。
为了更好的复用这个缓存输入变量,我们把他注入到一个全局的变量noMarkdown
之中:
{
"tasksRunnerOptions": {
...
},
"namedInputs": {
"noMarkdown": ["!{projectRoot}/**/*.md"]
},
"targetDefaults": {
"build": {
"inputs": ["noMarkdown", "^noMarkdown"]
},
"test": {
"inputs": ["noMarkdown", "^noMarkdown"]
}
}
}
其中^
符号表示的是同样的规则也适用于当前项目的依赖项目。
- 编写脚本依赖,在上文中,当我们编写完
utils
文件夹,但是没有进行build
时,如果此时运行vite-project
的dev
命令,就会报错,我们希望在dev
时,能够执行utils
的build
命令,接着对nx.json
进行优化
{
...,
"targetDefaults": {
"build": {
"dependsOn": [
"^build"
],
"outputs": [
"{projectRoot}/dist"
],
"inputs": [
"noMarkdown",
"^noMarkdown"
]
},
"test": {
"inputs": [
"noMarkdown",
"^noMarkdown"
]
},
"dev": {
"dependsOn": [
"^build"
]
}
...
}
运行vite项目
nx dev vite-project
可以看到在vite项目启动前,会运行对utils
项目的构建工作。
- nx也支持基于分支收集依赖,并触发目标脚本,更新缓存。如下:
当在某一个feature分支修改了lib2的内容之后,我们可以运行一下命令
nx affected:<target>
nx
会比较当前分支和基础分支(配置文件中定义为main
)的commit
信息,运行
nx affected:test
由于appB
依赖于lib2
,当lib2
触发test
时,也会触发appB
的test
但是当lib2
触发build
时,由于在配置文件中定义了build
依赖于依赖模块的build
,因而会触发appB
lib2
和 lib3
的build
- 可视化的分析workspace的依赖关系,如果想要去查看项目间的依赖关系,
nx
也提供了一个可视化的界面给我们,当运行nx graph
时,可以看到项目之间的依赖以图形化的方式表示了出来。
总结
pnpm init
- 初始化文件夹,创建
package.json
pnpm install
- 安装项目依赖
pnpm add <pkg>
--filter <package-name>
指定workspace中的项目--worksapce
在workspace中寻找,并安装依赖- 为指定项目安装依赖包
nx init
- 使用nx时初始化
- 收集项目的npm脚本、配置缓存脚本、指定npm脚本执行时的依赖、缓存脚本运行的输出目录
- 创建
nx.json
nx <command name> <project name> <options>
- 运行npm脚本,target指定项目,command指定脚本名称
Demo仓库:github.com/Arfly/mono-… 文章中如有错误,欢迎评论指正。
参考资料
什么是monorepo
monorepo和polyrepo的对比
package.json中workspace字段说明
pnpm官网
pnpm workspace说明
nx的使用