使用pnpm搭建monorepo项目

什么是Monorepo?

Monorepo(单一代码仓库) 是一种软件开发策略,它将多个相关项目或包存储在同一个版本控制仓库中。与传统的每个项目一个仓库(Multi-repo)不同,Monorepo 允许在单一仓库中管理多个独立但相互关联的模块。

核心特征:

  • 统一代码库:所有项目共享同一个根目录。

  • 集中化管理:共享依赖、配置和工具链。

  • 原子提交:跨项目的修改可以在一次提交中完成。

  • 隐式依赖:项目间可直接引用,无需发布到包管理器。

简单来说,Monorepo可以帮助我们统一管理多个相关联的项目,多个项目可以共享同一个配置,同时可以使我们在本地开发的工具在多个项目间直接使用,不必每个项目都进行多次编写,也不必发布到包管理器,保证了项目的闭源。

同时与pnpm对包的软链接和硬链接特性,大大节省了磁盘空间,同时避免多仓库的版本依赖冲突。

关于pnpm的软链接和硬链接,可以看看这个包管理工具pnpm

使用pnpm搭建monorepo项目

我们先初始化一个项目:pnpm init

然后创建一个pnpm-workspace.yaml文件,在这个文件里指定每个项目的包名,如下:

1
2
3
4
5
6
7
8
9
packages:
# 指定根目录直接子目录中的包
- 'my-app'
# packages/ 直接子目录中的所有包
- 'packages/*' # /* 只包括包,本身并不能被使用
# components/ 子目录中的所有包
- 'components/**'
# 排除测试目录中的包
- '!**/test/**'

然后创建一个.npmrc文件,这个文件是pnpm操作时读取配置的配置文件,替代命令行输入。

在里面填写:

1
link-workspace-packages=true

link-workspace-packages:将 monorepo 工作空间中本地可用的包链接到 node_modules,而不是从注册源中重新下载它们。就是设置这个以后,我们可以在项目中通过 pnpm 下载本地包了。

然后根据pnpm-workspace.yaml中的配置创建文件夹:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
test
├─ .npmrc
├─ package.json
├─ workspace.yaml
├─ packages
│ └─ utils
│ └─ package.json
├─ my-app
│ ├─ .gitignore
│ ├─ index.html
│ ├─ jsconfig.json
│ ├─ package.json
│ ├─ README.md
│ ├─ vite.config.js
│ ├─ src
│ │ ├─ App.vue
│ │ └─ main.js
│ └─ public
│ └─ favicon.ico
└─ components
└─ package.json

这里我在packages下创建了utils文件夹,作为一个工具包。

然后在每个包下面进行pnpm初始化或创建一个package.json文件自行填写内容。

package.json中的name项进行修改,这一步是为了避免包名的name相同,导致下载冲突,比如修改为:@xxx/包名

my-app作为总项目运行包,初始化一个vue项目:pnpm create vue@latest

utils包下创建一个测试文件,如:count.js

填写一个测试函数:

1
2
3
export function count(a, b) {
return a + b;
}

在命令行中切换到my-app项目下,下载utils

1
2
# pnpm add <package.json中的name值>
pnpm add @zrb/utils

下载成功如下:

测试一下:

my-app/src/main.js
1
2
3
4
5
6
7
import { createApp } from 'vue';
import App from './App.vue';
import { count } from '@zrb/utils/count';

console.log(count(1, 2)); //3

createApp(App).mount('#app');

普通文件测试完了,我们在components包中创建一个Button.vue文件进行测试:

目录结构如下:

1
2
3
4
5
6
components
├─ index.js
├─ package.json
└─ Button
├─ Button.vue
└─ index.js

各个文件内容如下:

components/Button/Button.vue
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<script setup>
defineOptions({
name: 'ZButton',
});
</script>

<template>
<button class="z-button">
<slot>Button</slot>
</button>
</template>

<style scoped>
.z-button {
width: 120px;
height: 60px;
border-radius: 10px;
box-shadow: 1px 1px 5px 0 rgba(0, 0, 0, 0.1);
}
</style>
components/Button/index.js
1
2
3
4
5
6
7
import Button from './Button.vue';

Button.install = (app) => {
app.component(Button.name, Button);
};

export default Button;
components/index.js
1
2
3
4
5
6
7
import Button from './Button';

export default {
install(app) {
app.use(Button);
},
};
components/package.json
1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "@zrb/components",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"packageManager": "pnpm@10.6.4"
}

然后切换到my-app目录下,执行pnpm add @zrb/components

my-app/src/main.js中进行注册:

1
2
3
4
5
6
7
8
import { createApp } from 'vue';
import App from './App.vue';
import { count } from '@zrb/utils/count';
import component from '@zrb/components';

console.log(count(1, 2)); //3

createApp(App).use(component).mount('#app');

my-app/src/App.vue中进行测试:

1
2
3
4
5
6
7
8
9
<script setup lang="ts"></script>

<template>
<div>
<z-button></z-button>
</div>
</template>

<style scoped></style>

测试结果成功如下:

然后进行打包测试:

注意:Vite默认打包资源的引入是绝对路径的引入,如果打包的项目运行在服务器设置的根目录下,是没问题的,但如果不是根目录,就会出现404错误。

我们需要修改vit.config.jsbase'./',表示以运行的默认html路径,如:index.html的当前路径寻找'./'作为相对路径引入资源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { fileURLToPath, URL } from 'node:url';

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueDevTools from 'vite-plugin-vue-devtools';

// https://vite.dev/config/
export default defineConfig({
base: './',
plugins: [vue(), vueDevTools()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
});

修改之后进行打包测试就没问题了。