什么是 monorepo
Monorepo 这个词你应该不止一次听说了,像Vue3、Vite、ElementPlus 等优秀开源项目都是使用 Monorepo的方式管理项目,且这里说到的这几个项目都是采用 pnpm 作为包管理工具。
Monorepo 是一种项目管理方式,就是把多个项目放在一个仓库里面,可以参考:现代前端工程为什么越来越离不开 Monorepo?,这篇文章中介绍了Monorepo的概念、收益以及MulitRepo的弊端。
在以往的开发中,通常都是一个仓库存放一个项目。比如现在你有三个项目,就需要创建三个远程仓库,如果想要对三个项目进行下载依赖的话,就需要对三个项目分别 install。
那么 monorepo 的 意思就是,在一个仓库中,可以包含多个项目,这些项目可以是独立的,也可以相互依赖。通常情况下,在项目根目录会有一个 packages
目录,内部来存放多个项目。比如下面场景:
当三个项目都需要安装 lodash
,在以往三个项目分离的情况下,需要在分别在三个项目中,执行:
npm i lodash
但是在 monorepo
的项目中,仅仅需要执行一次,就可以实现三个项目都进行共享依赖包
monorepo
项目将公共的依赖项放在公共可以访问的地方,然后每个单独的项目中可能还会有一些私有的依赖项,让几个或多个项目同时共享那些公共依赖项。这样就可以避免在多个项目开发的时候,导致有些依赖项需要安装多次的情况。
环境
- Node版本:14+
- Pnpm版本: 7.13.5 7+
- 编辑器:vscode
pnpm下载,如果已经安装了 pnpm
可以跳过此步骤
npm install pnpm -g
1 简单例子
下面创建一个简单例子,项目内的子项目互相独立,只是将重复使用的依赖库提升到全局,避免多次下载。
初始化项目
首先新建一个文件夹,名为 vue3-pnpm-monorepo
进入 vue3-pnpm-monorepo
文件夹,初始化一个默认的 package.json
文件,执行命令:
pnpm init
这时 package.json
的文件内部应该是这样的:
{
"name": "vue3-pnpm-monorepo",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
先将一些没用的配置项删掉,再新增以下配置:
"private": true
:私有的,不会被发布,是管理整个项目,与要发布的 npm 包解耦。详细可参考这里。
配置完成之后是是这个样子:
{
"name": "vue3-pnpm-monorepo",
"version": "1.0.0",
"private": true,
"scripts": {},
"license": "ISC"
}
接下来再新建 packages
文件夹,来存放项目。进入 packages
目录,这里简单直接初始化三个 vue3 + ts
的项目进行演示,创建命令如下:
npm init vite vue-demo1
npm init vite vue-demo2
npm init vite vue-demo3
目前项目结构如下
├── packages
| ├── vue-demo1
| ├── vue-demo2
| └── vue-demo3
├── package.json
接下来进入到刚才创建的项目中,项目内部结构应该是这样的:
├── packages
| ├── vue-demo1
| | ├── .vscode
| | ├── public
| | ├── src
| | ├── .gitignore
| | ├── index.html
| | ├── package.json
| | ├── README.md
| | ├── tsconfig.json
| | ├── tsconfig.node.json
| | └── vite.config.ts
| ├── vue-demo2
| └── vue-demo3
├── package.json
进入到项目的目录下,打开 package.json
文件,是这样的:
{
"name": "vue-demo1",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.2.45"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"typescript": "^4.9.3",
"vite": "^4.0.0",
"vue-tsc": "^1.0.11"
}
}
我们要知道,目前这三个项目是完全一样的,需要的依赖也是完全一样的,所以这些依赖项就可以直接抽离出来,变成公共的依赖项,添加上版本号,另外调试的话也不需要在这里进行调试,也直接删掉,稍加修改这个文件,最后变成这样:
{
"name": "vue-demo1",
"private": true,
"version": "1.0.0"
}
将三个项目都按照上面的方式进行修改即可。
创建公共依赖配置
接下来就需要将三个公共的依赖项,进行配置到根目录,使用全局的依赖包提供这三个项目使用:
在 根目录下的 package.json
新增之前抽离出来的公共配置项,都添加到公共的配置文件中:
{
"name": "vue3-pnpm-monorepo",
"version": "1.0.0",
"private": true,
"dependencies": {
"vue": "^3.2.45"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"typescript": "^4.9.3",
"vite": "^4.0.0",
"vue-tsc": "^1.0.11"
},
"license": "ISC"
}
那么现在还没有调试的方式,可以新增调试的命令,一般启动项目可以使用 dev:项目名
来进行分别启动项目,后面跟上需要启动的路径即可
{
"name": "vue3-pnpm-monorepo",
"version": "1.0.0",
"private": true,
"scripts": {
"dev:vue-demo1": "vite packages/vue-demo1",
"dev:vue-demo2": "vite packages/vue-demo2",
"dev:vue-demo3": "vite packages/vue-demo3"
},
"dependencies": {
"vue": "^3.2.45"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.0.0",
"typescript": "^4.9.3",
"vite": "^4.0.0",
"vue-tsc": "^1.0.11"
},
"license": "ISC"
}
这样配置之后,就可以根据不同的命令,来启动不同的项目了。
接下来就是需要安装依赖进行测试了,不过安装前还需要配置一个特殊的文件 pnpm-workspace.yaml
,这个文件可以帮助我们在安装公共依赖的情况下,也将 packages
下的项目所需要的依赖也同时进行安装。
在根目录创建 pnpm-workspace.yaml
文件,内容为:
packages:
- 'packages/*'
配置好之后,就可以在根目录执行:
pnpm i
来安装依赖,安装好了之后,通过命令启动一下项目:
pnpm run dev:vue-demo1
pnpm run dev:vue-demo2
pnpm run dev:vue-demo3
发现每一个项目都是正常启动的,成功~
2 组件库项目例子
在 Element-plus 开发初期,也是采用 lerna+yarn 的形式进行的 Monorepo 架构的形式开发的,最后也是变更到了pnpm,可见 pnpm 去做这种架构是尤为方便的,我们可以参考 Element-plus 上手体验一下 Monorepo 的组织形式。
创建项目
首先创建我们的文件夹 custom-ui,然后使用pnpm init来初始化一个简单的 package.json
文件,当我们创建完成之后,会得到这样的一个package.json:
{
"name": "custom-ui",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
这个项目并不需要发布,我们对他进行简单的改造,将其设置为私有,我们不需要版本,不需要关键字、入口文件等等,改为如下基础配置即可:
{
"name": "custom-ui",
"private": true,
"version": "0.0.1",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": ""
}
目录规划
通常来说需要包含 组件库项目、开发时预览调试项目、开发文档项目(用于最终上线的文档) 、公共方法项目(用于抽离公共逻辑)
参考 Elemtn-plus 可以发现,他抽离了更多的项目,在根目录package.json文件当中以@element-plus/开头的都是一个单独的项目
项目起步我们先不需要这么多,可以先创建两个项目,一个 example 用于调试预览(对比Elment-plus的是paly)
另一个就是相对核心的 packages 文件夹了,基本大部分的开源项目的组件库代码都是放在其中,然后我们又可以在 packages下面去创建更多的项目,比如components放置组件、theme-chalk放置样式文件、contants放置场景、utils放置工具方法,更多文件我们使用再具体来说
安装全局依赖
在根目录下安装依赖的话,这个依赖可以在所有的 packages 中使用。
安装 vue3 和 typescript
首先先下载最基础需要用到的 vue3 和 typescript
pnpm install vue@next typescript -D -W
-D 把依赖作为 devDependencies 安装;-W 把依赖安装到根目录的 node_modules
下载完typescript后,在根目录执行:
npx tsc --init
这样就可以生成一份ts的基础配置文件,我们需要对其调整为我们需要的配置,这里的配置很多,可以参考文档或者直接查看Element-plus项目的配置,我们的配置贴在这里:
{
"compilerOptions": {
"module": "ESNext",
"declaration": false,
"noImplicitAny": false,
"removeComments": true,
"moduleResolution": "node",
"esModuleInterop": true,
"target": "es6",
"sourceMap": true,
"lib": [
"ESNext",
"DOM"
],
"allowSyntheticDefaultImports": true,
"experimentalDecorators": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"strict": true,
"skipLibCheck": true,
},
"exclude": [
"node_modules",
"**/__tests__",
"dist/**"
]
}
pnpm-workspace.yaml
pnpm 内置了对 monorepo 的支持,在项目根目录中新建 pnpm-workspace.yaml 文件,并声明对应的工作区就好。
pnpm 内置了对单一存储库(也称为多包存储库、多项目存储库或单体存储库)的支持, 你可以创建一个 workspace 以将多个项目合并到一个仓库中。
一个 workspace 的根目录下必须有 pnpm-workspace.yaml 文件, 也可能会有 .npmrc 文件。
packages:
- "packages/**"
- example
.npmrc 配置
shamefully-hoist = true
https://pnpm.io/zh/npmrc#shamefully-hoist
example 和 packages 搭建
我们对example和packages下面的所有文件进行申明,然后进入到package目录下去创建我们刚刚说到的文件,创建完成后,此时的目录是:
custom-ui
├── example
├── packages
│ ├── components
│ ├── theme-chalk
│ └── utils
├── package.json
├── pnpm-lock.yaml
└── pnpm-workspace.yaml
packages
添加完这些文件夹后,我们依次进入,packages/components、packages/theme-chalk、packages/utils里执行pnpm init
进行项目初始化,拿utils举例,我们生成的文件需要做一定的调整,调整为如下:
{
"name": "@custom-ui/utils",
"version": "0.0.1",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}
在 name 前面添加了一层命名空间,这样表示当前的 utils 项目是我们 @custom-ui 的子项目,其他两个目录同理
{
"name": "@custom-ui/components",
...
}
{
"name": "@custom-ui/theme-chalk",
...
}
example
此时我们就完成了packages下的基础文件结构,然后对example示例开发调试文档进行开发:
同样 cd 到 example 项目当中进行,pnpm init初始化操作,同理并修改掉其name名称,为其添加命名空间
example 基于 vite 进行开发
安装依赖
pnpm install vite @vitejs/plugin-vue -D
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})
创建index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
引入一个入口文件main.ts
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
<template>
<h1>随便看看</h1>
</template>
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
添加启动命令
"scripts": {
"dev": "vite"
},
执行
pnpm dev
预览调试项目已经ok了,后续开发的组件只需要引入到这个项目当中,我们就可以边开发边调试项目了。
不过当前我们执行命令是在brain-ui/example目录下,并不是根文件夹,所以我们需要把这个命令放在,根目录当中,所以我们需要在根目录下的package.json中添加一条新命令:
"scripts": {
"dev": "pnpm -C example dev"
},
此时我们在根目录执行pnpm dev即可实现相同的效果, -C后面的参数表示命名空间,表示在example命名空间下执行dev命令。
开发调试环境初步搭建完成。
子项目间互相引用
将子项目安装到全局
我们已经对 packages/components、packages/theme-chalk、packages/utils 三个项目进行了初始化,我们在不同项目里都进行了 pnpm init,所以他们其实都可以理解为单独的项目,packages下的子包在开发过程中通常是需要互相引用的,为了实现其相互引用,我们将其安装到根目录当中去,正常情况我们这样就可以安装一个包了
pnpm install @custom-ui/components @custom-ui/theme-chalk @custom-ui/utils -w
安装的时候就必须在参数后面添加 -w 表示同意安装到根目录
安装成功,根目录的 package.json 的 dependencies 添加了对应依赖包,因为我们是在根目录下安装的,所以各个子项目中都可以相互引用,此时我们就感觉到了Monorepo架构的便利性了。
平时我们使用的包都是从 npmjs 仓库远程下载的,我们这里下载的其实是本地的包,实际就是创建了一个软链接,将自己的模块创建一个软链安装在根目录下,更加方便,此时我们打开node_modules可以看到,后面有个小箭头,这个就表示本地创建的软链接。
尝试使用 packages 子项目
首先我们在 @packages/utils/index.js 中写入如下内容:
export function test() {
console.log("test import");
}
在 example/src/App.vue 中引入使用
<template>
<h1>随便看看</h1>
</template>
<script setup lang="ts">
import { test } from "@custom-ui/utils";
test();
</script>
packages 子项目可以互相引用,我们在 @packages/components/index.js 写入如下内容:
import { test } from "@custom-ui/utils";
export { test };
在 example/src/App.vue 中引入使用
<template>
<h1>随便看看</h1>
</template>
<script setup lang="ts">
import {test} from "@custom-ui/components";
test();
</script>
子项目单独引入另一个子项目
如果子项目不需要安装到全局,只需在另一个子项目单独引入可以这样操作
pnpm -F @packages/components add @packages/utils@*
这个命令表示在 @packages/components 安装 @packages/utils,其中的 @* 表示默认同步最新版本,省去每次都要同步最新版本的问题。
安装子项目依赖
如果在根目录下安装依赖的话,依赖可以在所有的packages中使用;如果需要为具体的一个 package 子项目安装依赖要如何操作呢?
- 可以直接 cd 到 package 的所在目录然后安装,不过这样比较麻烦;
- 可以通过下面的
--filter
命令安装:
例如我们需要在@packages/components 安装 lodash,命令如下:pnpm --filter <package_selector> <command>
:::infopnpm -F @packages/components add lodash
-F
等价于--filter
:::
限制只能使用 pnpm
pnpm 的 monorepo 项目在 node_modules 以及开发中,项目依赖 pnpm workspace ,使用 npm 或 yarn 运行时会出现问题。
因此需要在安装依赖之前对包管理器进行检查。
"scripts": {
"preinstall": "node ./scripts/preinstall.js"
}
实现当运行 npm install 或 yarn,就会发生错误并且不会继续安装。
if (!/pnpm/.test(process.env.npm_execpath || '')) {
console.warn(
`\u001b[33mThis repository requires using pnpm as the package manager ` +
` for scripts to work properly.\u001b[39m\n`
)
process.exit(1)
}
测试:使用 yarn install 时提示必须使用 pnpm 作为包管理器