什么是 monorepo

Monorepo 这个词你应该不止一次听说了,像Vue3、Vite、ElementPlus 等优秀开源项目都是使用 Monorepo的方式管理项目,且这里说到的这几个项目都是采用 pnpm 作为包管理工具。

Monorepo 是一种项目管理方式,就是把多个项目放在一个仓库里面,可以参考:现代前端工程为什么越来越离不开 Monorepo?,这篇文章中介绍了Monorepo的概念、收益以及MulitRepo的弊端。

在以往的开发中,通常都是一个仓库存放一个项目。比如现在你有三个项目,就需要创建三个远程仓库,如果想要对三个项目进行下载依赖的话,就需要对三个项目分别 install。

那么 monorepo 的 意思就是,在一个仓库中,可以包含多个项目,这些项目可以是独立的,也可以相互依赖。通常情况下,在项目根目录会有一个 packages 目录,内部来存放多个项目。比如下面场景:

当三个项目都需要安装 lodash,在以往三个项目分离的情况下,需要在分别在三个项目中,执行:

  1. npm i lodash

但是在 monorepo 的项目中,仅仅需要执行一次,就可以实现三个项目都进行共享依赖包

monorepo 项目将公共的依赖项放在公共可以访问的地方,然后每个单独的项目中可能还会有一些私有的依赖项,让几个或多个项目同时共享那些公共依赖项。这样就可以避免在多个项目开发的时候,导致有些依赖项需要安装多次的情况。

环境

  • Node版本:14+
  • Pnpm版本: 7.13.5 7+
  • 编辑器:vscode

image.png
image.png

pnpm下载,如果已经安装了 pnpm 可以跳过此步骤

  1. npm install pnpm -g

pnpm

1 简单例子

下面创建一个简单例子,项目内的子项目互相独立,只是将重复使用的依赖库提升到全局,避免多次下载。

初始化项目

首先新建一个文件夹,名为 vue3-pnpm-monorepo

进入 vue3-pnpm-monorepo 文件夹,初始化一个默认的 package.json 文件,执行命令:

  1. pnpm init

这时 package.json 的文件内部应该是这样的:

  1. {
  2. "name": "vue3-pnpm-monorepo",
  3. "version": "1.0.0",
  4. "description": "",
  5. "main": "index.js",
  6. "scripts": {
  7. "test": "echo \"Error: no test specified\" && exit 1"
  8. },
  9. "keywords": [],
  10. "author": "",
  11. "license": "ISC"
  12. }

先将一些没用的配置项删掉,再新增以下配置:

  • "private": true:私有的,不会被发布,是管理整个项目,与要发布的 npm 包解耦。详细可参考这里

配置完成之后是是这个样子:

  1. {
  2. "name": "vue3-pnpm-monorepo",
  3. "version": "1.0.0",
  4. "private": true,
  5. "scripts": {},
  6. "license": "ISC"
  7. }

接下来再新建 packages 文件夹,来存放项目。进入 packages 目录,这里简单直接初始化三个 vue3 + ts 的项目进行演示,创建命令如下:

  1. npm init vite vue-demo1
  2. npm init vite vue-demo2
  3. npm init vite vue-demo3

目前项目结构如下

  1. ├── packages
  2. | ├── vue-demo1
  3. | ├── vue-demo2
  4. | └── vue-demo3
  5. ├── package.json

接下来进入到刚才创建的项目中,项目内部结构应该是这样的:

  1. ├── packages
  2. | ├── vue-demo1
  3. | | ├── .vscode
  4. | | ├── public
  5. | | ├── src
  6. | | ├── .gitignore
  7. | | ├── index.html
  8. | | ├── package.json
  9. | | ├── README.md
  10. | | ├── tsconfig.json
  11. | | ├── tsconfig.node.json
  12. | | └── vite.config.ts
  13. | ├── vue-demo2
  14. | └── vue-demo3
  15. ├── package.json

进入到项目的目录下,打开 package.json 文件,是这样的:

  1. {
  2. "name": "vue-demo1",
  3. "private": true,
  4. "version": "0.0.0",
  5. "type": "module",
  6. "scripts": {
  7. "dev": "vite",
  8. "build": "vue-tsc && vite build",
  9. "preview": "vite preview"
  10. },
  11. "dependencies": {
  12. "vue": "^3.2.45"
  13. },
  14. "devDependencies": {
  15. "@vitejs/plugin-vue": "^4.0.0",
  16. "typescript": "^4.9.3",
  17. "vite": "^4.0.0",
  18. "vue-tsc": "^1.0.11"
  19. }
  20. }

我们要知道,目前这三个项目是完全一样的,需要的依赖也是完全一样的,所以这些依赖项就可以直接抽离出来,变成公共的依赖项,添加上版本号,另外调试的话也不需要在这里进行调试,也直接删掉,稍加修改这个文件,最后变成这样:

  1. {
  2. "name": "vue-demo1",
  3. "private": true,
  4. "version": "1.0.0"
  5. }

将三个项目都按照上面的方式进行修改即可。

创建公共依赖配置

接下来就需要将三个公共的依赖项,进行配置到根目录,使用全局的依赖包提供这三个项目使用:
在 根目录下的 package.json 新增之前抽离出来的公共配置项,都添加到公共的配置文件中:

  1. {
  2. "name": "vue3-pnpm-monorepo",
  3. "version": "1.0.0",
  4. "private": true,
  5. "dependencies": {
  6. "vue": "^3.2.45"
  7. },
  8. "devDependencies": {
  9. "@vitejs/plugin-vue": "^4.0.0",
  10. "typescript": "^4.9.3",
  11. "vite": "^4.0.0",
  12. "vue-tsc": "^1.0.11"
  13. },
  14. "license": "ISC"
  15. }

那么现在还没有调试的方式,可以新增调试的命令,一般启动项目可以使用 dev:项目名 来进行分别启动项目,后面跟上需要启动的路径即可

  1. {
  2. "name": "vue3-pnpm-monorepo",
  3. "version": "1.0.0",
  4. "private": true,
  5. "scripts": {
  6. "dev:vue-demo1": "vite packages/vue-demo1",
  7. "dev:vue-demo2": "vite packages/vue-demo2",
  8. "dev:vue-demo3": "vite packages/vue-demo3"
  9. },
  10. "dependencies": {
  11. "vue": "^3.2.45"
  12. },
  13. "devDependencies": {
  14. "@vitejs/plugin-vue": "^4.0.0",
  15. "typescript": "^4.9.3",
  16. "vite": "^4.0.0",
  17. "vue-tsc": "^1.0.11"
  18. },
  19. "license": "ISC"
  20. }

这样配置之后,就可以根据不同的命令,来启动不同的项目了。

接下来就是需要安装依赖进行测试了,不过安装前还需要配置一个特殊的文件 pnpm-workspace.yaml,这个文件可以帮助我们在安装公共依赖的情况下,也将 packages 下的项目所需要的依赖也同时进行安装。

在根目录创建 pnpm-workspace.yaml 文件,内容为:

  1. packages:
  2. - 'packages/*'

配置好之后,就可以在根目录执行:

  1. pnpm i

来安装依赖,安装好了之后,通过命令启动一下项目:

  1. pnpm run dev:vue-demo1
  2. pnpm run dev:vue-demo2
  3. pnpm run dev:vue-demo3

发现每一个项目都是正常启动的,成功~

2 组件库项目例子

在 Element-plus 开发初期,也是采用 lerna+yarn 的形式进行的 Monorepo 架构的形式开发的,最后也是变更到了pnpm,可见 pnpm 去做这种架构是尤为方便的,我们可以参考 Element-plus 上手体验一下 Monorepo 的组织形式。

创建项目

首先创建我们的文件夹 custom-ui,然后使用pnpm init来初始化一个简单的 package.json 文件,当我们创建完成之后,会得到这样的一个package.json:

  1. {
  2. "name": "custom-ui",
  3. "version": "1.0.0",
  4. "description": "",
  5. "main": "index.js",
  6. "scripts": {
  7. "test": "echo \"Error: no test specified\" && exit 1"
  8. },
  9. "keywords": [],
  10. "author": "",
  11. "license": "ISC"
  12. }

这个项目并不需要发布,我们对他进行简单的改造,将其设置为私有,我们不需要版本,不需要关键字、入口文件等等,改为如下基础配置即可:

  1. {
  2. "name": "custom-ui",
  3. "private": true,
  4. "version": "0.0.1",
  5. "scripts": {
  6. "test": "echo \"Error: no test specified\" && exit 1"
  7. },
  8. "author": ""
  9. }

目录规划

通常来说需要包含 组件库项目、开发时预览调试项目、开发文档项目(用于最终上线的文档) 、公共方法项目(用于抽离公共逻辑)
参考 Elemtn-plus 可以发现,他抽离了更多的项目,在根目录package.json文件当中以@element-plus/开头的都是一个单独的项目
image.png
image.png
项目起步我们先不需要这么多,可以先创建两个项目,一个 example 用于调试预览(对比Elment-plus的是paly)

另一个就是相对核心的 packages 文件夹了,基本大部分的开源项目的组件库代码都是放在其中,然后我们又可以在 packages下面去创建更多的项目,比如components放置组件、theme-chalk放置样式文件、contants放置场景、utils放置工具方法,更多文件我们使用再具体来说

image.png

安装全局依赖

在根目录下安装依赖的话,这个依赖可以在所有的 packages 中使用。

安装 vue3 和 typescript

首先先下载最基础需要用到的 vue3 和 typescript

  1. pnpm install vue@next typescript -D -W

-D 把依赖作为 devDependencies 安装;-W 把依赖安装到根目录的 node_modules

下载完typescript后,在根目录执行:

  1. npx tsc --init

这样就可以生成一份ts的基础配置文件,我们需要对其调整为我们需要的配置,这里的配置很多,可以参考文档或者直接查看Element-plus项目的配置,我们的配置贴在这里:

  1. {
  2. "compilerOptions": {
  3. "module": "ESNext",
  4. "declaration": false,
  5. "noImplicitAny": false,
  6. "removeComments": true,
  7. "moduleResolution": "node",
  8. "esModuleInterop": true,
  9. "target": "es6",
  10. "sourceMap": true,
  11. "lib": [
  12. "ESNext",
  13. "DOM"
  14. ],
  15. "allowSyntheticDefaultImports": true,
  16. "experimentalDecorators": true,
  17. "forceConsistentCasingInFileNames": true,
  18. "resolveJsonModule": true,
  19. "strict": true,
  20. "skipLibCheck": true,
  21. },
  22. "exclude": [
  23. "node_modules",
  24. "**/__tests__",
  25. "dist/**"
  26. ]
  27. }

pnpm-workspace.yaml

pnpm 内置了对 monorepo 的支持,在项目根目录中新建 pnpm-workspace.yaml 文件,并声明对应的工作区就好。
pnpm 内置了对单一存储库(也称为多包存储库、多项目存储库或单体存储库)的支持, 你可以创建一个 workspace 以将多个项目合并到一个仓库中。
一个 workspace 的根目录下必须有 pnpm-workspace.yaml 文件, 也可能会有 .npmrc 文件。

  1. packages:
  2. - "packages/**"
  3. - example

.npmrc 配置

  1. shamefully-hoist = true

https://pnpm.io/zh/npmrc#shamefully-hoist
image.png

example 和 packages 搭建

我们对example和packages下面的所有文件进行申明,然后进入到package目录下去创建我们刚刚说到的文件,创建完成后,此时的目录是:

  1. custom-ui
  2. ├── example
  3. ├── packages
  4. ├── components
  5. ├── theme-chalk
  6. └── utils
  7. ├── package.json
  8. ├── pnpm-lock.yaml
  9. └── pnpm-workspace.yaml

packages

添加完这些文件夹后,我们依次进入,packages/components、packages/theme-chalk、packages/utils里执行pnpm init进行项目初始化,拿utils举例,我们生成的文件需要做一定的调整,调整为如下:

  1. {
  2. "name": "@custom-ui/utils",
  3. "version": "0.0.1",
  4. "description": "",
  5. "main": "index.js",
  6. "scripts": {
  7. "test": "echo \"Error: no test specified\" && exit 1"
  8. },
  9. "keywords": [],
  10. "author": "",
  11. "license": "ISC"
  12. }

在 name 前面添加了一层命名空间,这样表示当前的 utils 项目是我们 @custom-ui 的子项目,其他两个目录同理

  1. {
  2. "name": "@custom-ui/components",
  3. ...
  4. }
  1. {
  2. "name": "@custom-ui/theme-chalk",
  3. ...
  4. }

example

此时我们就完成了packages下的基础文件结构,然后对example示例开发调试文档进行开发:

同样 cd 到 example 项目当中进行,pnpm init初始化操作,同理并修改掉其name名称,为其添加命名空间

example 基于 vite 进行开发
安装依赖

  1. pnpm install vite @vitejs/plugin-vue -D

image.png

  1. import { defineConfig } from 'vite'
  2. import vue from '@vitejs/plugin-vue'
  3. export default defineConfig({
  4. plugins: [vue()],
  5. })

创建index.html

  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  6. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  7. <title>Vite + Vue + TS</title>
  8. </head>
  9. <body>
  10. <div id="app"></div>
  11. <script type="module" src="/src/main.ts"></script>
  12. </body>
  13. </html>

引入一个入口文件main.ts

  1. import { createApp } from "vue";
  2. import App from "./App.vue";
  3. createApp(App).mount("#app");
  1. <template>
  2. <h1>随便看看</h1>
  3. </template>
  1. /// <reference types="vite/client" />
  2. declare module '*.vue' {
  3. import type { DefineComponent } from 'vue'
  4. const component: DefineComponent<{}, {}, any>
  5. export default component
  6. }

添加启动命令

  1. "scripts": {
  2. "dev": "vite"
  3. },

执行

  1. pnpm dev

image.png
image.png

预览调试项目已经ok了,后续开发的组件只需要引入到这个项目当中,我们就可以边开发边调试项目了。

不过当前我们执行命令是在brain-ui/example目录下,并不是根文件夹,所以我们需要把这个命令放在,根目录当中,所以我们需要在根目录下的package.json中添加一条新命令:

  1. "scripts": {
  2. "dev": "pnpm -C example dev"
  3. },

此时我们在根目录执行pnpm dev即可实现相同的效果, -C后面的参数表示命名空间,表示在example命名空间下执行dev命令。

image.png

开发调试环境初步搭建完成。

子项目间互相引用

将子项目安装到全局

我们已经对 packages/components、packages/theme-chalk、packages/utils 三个项目进行了初始化,我们在不同项目里都进行了 pnpm init,所以他们其实都可以理解为单独的项目,packages下的子包在开发过程中通常是需要互相引用的,为了实现其相互引用,我们将其安装到根目录当中去,正常情况我们这样就可以安装一个包了

  1. pnpm install @custom-ui/components @custom-ui/theme-chalk @custom-ui/utils -w

安装的时候就必须在参数后面添加 -w 表示同意安装到根目录
image.png

安装成功,根目录的 package.json 的 dependencies 添加了对应依赖包,因为我们是在根目录下安装的,所以各个子项目中都可以相互引用,此时我们就感觉到了Monorepo架构的便利性了。

image.png

平时我们使用的包都是从 npmjs 仓库远程下载的,我们这里下载的其实是本地的包,实际就是创建了一个软链接,将自己的模块创建一个软链安装在根目录下,更加方便,此时我们打开node_modules可以看到,后面有个小箭头,这个就表示本地创建的软链接。

image.png

尝试使用 packages 子项目

首先我们在 @packages/utils/index.js 中写入如下内容:

  1. export function test() {
  2. console.log("test import");
  3. }

在 example/src/App.vue 中引入使用

  1. <template>
  2. <h1>随便看看</h1>
  3. </template>
  4. <script setup lang="ts">
  5. import { test } from "@custom-ui/utils";
  6. test();
  7. </script>

image.png
packages 子项目可以互相引用,我们在 @packages/components/index.js 写入如下内容:

  1. import { test } from "@custom-ui/utils";
  2. export { test };

在 example/src/App.vue 中引入使用

  1. <template>
  2. <h1>随便看看</h1>
  3. </template>
  4. <script setup lang="ts">
  5. import {test} from "@custom-ui/components";
  6. test();
  7. </script>

可以看到 components 引入了 utils

子项目单独引入另一个子项目

如果子项目不需要安装到全局,只需在另一个子项目单独引入可以这样操作

  1. pnpm -F @packages/components add @packages/utils@*

这个命令表示在 @packages/components 安装 @packages/utils,其中的 @* 表示默认同步最新版本,省去每次都要同步最新版本的问题。

安装子项目依赖

如果在根目录下安装依赖的话,依赖可以在所有的packages中使用;如果需要为具体的一个 package 子项目安装依赖要如何操作呢?

  1. 可以直接 cd 到 package 的所在目录然后安装,不过这样比较麻烦;
  2. 可以通过下面的 --filter 命令安装:
    1. pnpm --filter <package_selector> <command>
    例如我们需要在@packages/components 安装 lodash,命令如下:
    1. pnpm -F @packages/components add lodash
    :::info -F 等价于 --filter :::

限制只能使用 pnpm

pnpm 的 monorepo 项目在 node_modules 以及开发中,项目依赖 pnpm workspace ,使用 npm 或 yarn 运行时会出现问题。

因此需要在安装依赖之前对包管理器进行检查。

  1. "scripts": {
  2. "preinstall": "node ./scripts/preinstall.js"
  3. }

实现当运行 npm install 或 yarn,就会发生错误并且不会继续安装。

  1. if (!/pnpm/.test(process.env.npm_execpath || '')) {
  2. console.warn(
  3. `\u001b[33mThis repository requires using pnpm as the package manager ` +
  4. ` for scripts to work properly.\u001b[39m\n`
  5. )
  6. process.exit(1)
  7. }

测试:使用 yarn install 时提示必须使用 pnpm 作为包管理器
image.png

参考文章