什么是 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-demo1npm init vite vue-demo2npm 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-demo1pnpm run dev:vue-demo2pnpm 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 作为包管理器
