前言 📝

每当我在学习一门技术的时候,我都会创建很多的项目。而且这些项目框架基本都是一样的依赖。每次当我想要找一个项目的时候很复杂,我需要用vscode挨个的打开项目,查看是不是我需要的项目。这么多的项目不仅浪费存储空间,项目管理也麻烦。于是就参考了很多优秀的开源项目 :vue、element-pluse、vite等,都是使用pnpm+monorepo管理项目,达到一个git仓库,管理多个项目或模块。我也是一边学习一边写的文章,有些理解不到位的还请大家在评论区发表一下自己的想法意见。接下来我们直接进入正题。

在进入正题之前,我们先参考一下ElementPlus项目目录结构:

image.png

我们看到 element-plus 是通过 monorepo 的方式,将整个 UI 组件库的多个依赖模块都整合到了同一个代码仓中,以其代码仓中的 packages 目录为例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📦element-plus
┣ 📂...
┣ 📂packages
┃ ┣ 📂components # 各种 UI 组件
┃ ┃ ┣ 📂button
┃ ┃ ┣ 📂input
┃ ┃ ┣ 📂...
┃ ┃ ┗ 📜package.json
┃ ┣ 📂utils # 公用方法包
┃ ┃ ┗ 📜package.json
┃ ┣ 📂theme-chalk # 组件样式包
┃ ┃ ┗ 📜package.json
┃ ┣ 📂element-plus # 组件统一出口
┃ ┃ ┗ 📜package.json
┣ 📜...

看到了开源项目的目录结构之后,我的想法就是能不能把我的目录结构换一下呢,但还是换汤不换药,他是管理子模块,那我能不能管理项目呢??于是我就开始动手尝试了一下,发现是可以的。于是我得目录结构就换了个样子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📦monorepo-demo
┣ 📂...
┣ 📂packages
┃ ┣ 📂components # 所有项目的公共组件
┃ ┃ ┣ 📂button
┃ ┃ ┣ 📂input
┃ ┃ ┣ 📂...
┃ ┃ ┗ 📜package.json
┃ ┣ 📂utils # 所有项目公用方法包
┃ ┃ ┗ 📜package.json
┃ ┣ 📂project1 # 项目一
┃ ┃ ┗ 📜package.json
┃ ┣ 📂project2 # 项目二
┃ ┃ ┗ 📜package.json
┣ 📜...

1. Monorepo 介绍

Monorepo 是一种项目代码管理方式,指单个仓库中管理多个项目,有助于简化代码共享、版本控制、构建和部署等方面的复杂性,并提供更好的可重用性和协作性。Monorepo 提倡了开放、透明、共享的组织文化,这种方法已经被很多大型公司广泛使用,如 Google、Facebook 和 Microsoft 等。

monorepo 可以解决我的一些痛点:

  1. 任意一个模块发生修改,另一个项目能够立即反馈而不用走繁琐的发布和依赖更新流程;
  2. 各个模块之间也能够充分复用配;
  3. 各个包的版本和互相之间的依赖关系得到集中管理;
  4. 我可以将多个项目在一起管理,并将公共组件、公共方法提取出来,当作一个模块。这样长久累计下去,我可以发布一个npm包供所有人使用。

总之能了解到这个技术非常的高兴,确实解决了我很大的痛点。

2. 包管理基础 package.json

在进行下一步之前,我们先简单了解一下包的配置文件package.json,一个包或者子模块不一定发布到 npm 仓库,但一定有 package.json 文件。package.json 所在的目录就代表了一个模块/包,这个 json 文件定义了模块的各种配置,例如基本信息、依赖关系、构建配置等等。所有包管理器(npm/yarn/pnpm)以及绝大多数构建工具都会依赖于这个配置文件的信息。

从我们接触前端开始,每个项目的根目录下一般都会有一个package.json文件,这个文件定义了当前项目所需要的各种模块,以及项目的配置信息(比如名称、版本、许可证等)。但是你真正的了解package.json文件吗?

下面我找了一个完整的配置文件,详细解释一下主要字段的真实含义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
{
"name": "alvis",
"version":"0.0.1",
"description": "alvis-project",
"keywords":["node.js","alvis", "project"],
"homepage": "https://alvis.org.cn",
"bugs":{"url":"http://path/to/bug","email":"alvis@xxxx.com"},
"license": "ISC",
"author": "alvis",
"contributors":[{"name":"alvis","email":"alvis@xxxx.com"}],
"files": "",
"main": "./dist/index.ts",
"bin": "",
"man": "",
"directories": "",
"repository": {
"type": "git",
"url": "https://path/to/url"
},
"scripts": {
"start": "webpack serve --config webpack.config.dev.js --progress"
},
"config": { "port" : "8080" },
"dependencies": {},
"devDependencies": {
"@babel/core": "^7.14.3",
"@babel/preset-env": "^7.14.4",
"@babel/preset-react": "^7.13.13",
"babel-loader": "^8.2.2",
"babel-plugin-import": "^1.13.3",
"glob": "^7.1.7",
"less": "^3.9.0",
"less-loader": "^9.0.0",
"style-loader": "^2.0.0",
"webpack": "^5.38.1",
"webpack-cli": "^4.7.0",
"webpack-dev-server": "^3.11.2"
},
"peerDependencies": {
"tea": "2.x"
},
"bundledDependencies": [
"renderized", "super-streams"
],
"engines": {"node": "0.10.x"},
"os" : [ "win32", "darwin", "linux" ],
"cpu" : [ "x64", "ia32" ],
"private": false,
"publishConfig": {}
}

2.1 name字段

在配置文件种,最重要的就是nameversion字段,他们俩个一起就是该配置文件唯一标识。该名称会作为参数传递给require,因此它应该是简短的,但也需要具有合理的描述性。

2.2 version字段

version一般的格式是x.x.x, 并且需要遵循该规则。

2.3 description字段

description是一个字符串,用于编写描述信息。有助于人们在npm库中搜索的时候发现你的模块

2.4 keywords字段

keywords是一个字符串组成的数组,有助于人们在npm库中搜索的时候发现你的模块。

2.5 homepage字段

homepage项目的主页地址。

2.6 bugs字段

bugs用于项目问题的反馈issue地址或者一个邮箱。

1
2
3
4
"bugs": { 
"url" : "https://github.com/owner/project/issues",
"email" : "project@hostname.com"
}

2.7 license字段

license是当前项目的协议,让用户知道他们有何权限来使用你的模块,以及使用该模块有哪些限制。更多开源协议

2.8 author字段和contributors字段

author是具体一个人,contributors表示一群人,他们都表示当前项目的共享者。同时每个人都是一个对象。具有name字段和可选的urlemail字段。

2.9 files字段

files属性的值是一个数组,指定了发布为 npm 包时,哪些文件或目录需要被提交到 npm 服务器中。内容是模块下文件名或者文件夹名,如果是文件夹名,则文件夹下所有的文件也会被包含进来(除非文件被另一些配置排除了)

可以在模块根目录下创建一个.npmignore文件,写在这个文件里边的文件即便被写在files属性里边也会被排除在外,这个文件的写法与.gitignore类似。

2.10 main字段

main字段指定了加载的入口文件,require导入的时候就会加载这个文件。这个字段的默认值是模块根目录下面的index.js

2.11 repository字段

源码仓库地址

2.12 scripts字段

scripts指定了运行脚本命令的npm命令行缩写

2.13 dependencies字段和devDependencies字段

在我们的项目开发中,dependencies字段和devDependencies字段并没有任何区别,只是存在语义化的区别。我们常说的开发环境、生产环境是构件行为。构件并不是包管理器的职责,而是webpackvite等工具的工作,此时包管理器起的作用仅仅是执行脚本而已。 各种包管理器处理 dependencies 和 devDependencies 差异的行为都发生在依赖安装时期,即 npm install 的过程中。

我们的web应用中,web应用的的产物往往是部署到服务器,不会发布到npm仓库,作为包的使用。而包管理器对于一级依赖,无论 dependencies 还是 devDependencies 都会悉数安装。但是对于这些一级依赖项具有的更深层级依赖,在深度遍历的过程中,只会安装 dependencies 中的依赖,忽略 devDependencies 中的依赖。 因为包管理器认为:作为包的使用者,我们当然不用再去关心它们开发构建时的依赖,所以会为我们忽略 devDependencies。 而 dependencies 是包产物正常工作所依赖的内容,当然有必要安装。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
// 项目a
{
"name": "a",
"dependencies": {
"b": "^1.0.0"
},
"devDependencies": {
"c": "^1.0.0"
}
}

1
2
3
4
5
6
7
8
9
10
// node_modules/b/package.json
{
"name": "b",
"dependencies": {
"d": "^1.0.0"
},
"devDependencies": {
"e": "^1.0.0"
}
}
1
2
3
4
5
6
7
8
9
10
// node_modules/c/package.json
{
"name": "c",
"dependencies": {
"f": "^1.0.0"
},
"devDependencies": {
"g": "^1.0.0"
}
}

在上面这种情况中, b和c作为npm包来使用,e,g是npm包的开发依赖,所以他们不会被下载安装。

2.14 peerDependencies字段

peerDependencies 声明包的同步依赖。但是包管理器不会像 dependencies 一样,自动为用户安装好依赖,当用户使用包时,必须遵照该包的 peerDependencies 同步安装对应的依赖,否则包管理器会提示错误。

2.15 private字段

private 用于指定项目是否为私有包。当我们的项目不想被意外发布到公共 npm 仓库时,就设置 private: true

2.16 publishConfig字段

当我们的项目需要发布到私有的 npm 仓库时(比如公司内网的仓库),需要设置 publishConfig 对象.

入口信息

入口信息主要被 Node.js、各路构建工具(Vite / Rollup / Webpack / TypeScript)所识别。未正确设置会导致 npm 包无法被加载或者实际加载了预料之外的文件。入口文件的加载机制是比较复杂的,在不同的构建工具中有着不同的加载逻辑,对此给大家分享一篇文章:package.json 导入模块入口文件优先级详解

3. workspace 模式

pnpm 支持 monorepo 模式的工作机制叫做 workspace(工作空间)

它要求在代码仓的根目录下存有 pnpm-workspace.yaml 文件指定哪些目录作为独立的工作空间,这个工作空间可以理解为一个子模块或者 npm 包。例如以下的 pnpm-workspace.yaml 文件定义:components 目录、shared 目录、project 目录,都会各自被视为独立的模块。

1
2
3
4
packages:
- components
- shared
- project
1
2
3
4
5
6
7
8
9
📦my-project
┣ 📂components
┃ ┗ 📜package.json
┣ 📂shared
┃ ┗ 📜package.json
┣ 📂project
┃ ┗ 📜package.json
┣ 📜package.json
┣ 📜pnpm-workspace.yaml

4. 项目实战

因为我的项目都是基于vue3的,所以我先搭建一个vue3项目,然后基于vue3项目进行改造。

1
pnpm create vue@latest

之后我们在项目下新建一个pnpm-workspace.yaml文件,以及存放我们项目包的目录packages,并创建一个项目目录project

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
touch pnpm-workspace.yaml
mkdir packages
cd packages
mkdir project

mkdir components
cd ./components
touch package.json
vim package.json
# 文件开始
{
  "name": "@learn_vue3/components",
  "version": "0.0.1",
  "scripts": {
    "build": "vite build"
  },
  # 对等依赖
  "peerDependencies": {
    "vue": ">=3.4.0"
  }
}
# 文件结束

cd ../
mkdir shared
cd ./shared
touch package.json
vim package.json # 参考上面的文件,记着要更换名字

在然后我们手动整理一下目录, 将这些文件迁移到project目录下

image.png

1
2
3
4
5
6
7
vim pnpm-workspace.yaml

# 文件信息开始
packages:
  # packages 目录下的每一个目录都作为一个独立的模块
  - packages/*
# 文件信息结束

接下来我们统一一下,在每一个包中,都有一个src目录,src/index.ts作为当前包的统一导出
image.png

4.1 中枢(父级)管理

  • 安装项目公共开发依赖,声明在根目录的 package.json - devDependencies 中。-w 选项代表在 monorepo 模式下的根目录进行操作。
  • 每个子包都能访问根目录的依赖,适合把 TypeScriptViteeslint 等公共开发依赖装在这里。
1
pnpm install -wD xxx

4.2 子包管理操作

1. 为指定模块安装外部依赖

1
2
3
# 为 a 包安装 lodash
pnpm --filter a i -S lodash
pnpm --filter a i -D lodash

2. 指定内部模块之间的互相依赖

1
2
# 指定 a 模块依赖于 b 模块
pnpm --filter a i -S b

配置好对应的package.json之后,直接安装对应的依赖,然后启动对应的项目或者打包对应的项目即可。

由于讲的不太好,我把我的package.json文件都列出来给大家看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 项目根目录下package.json
{
// 名字
  "name": "learn_vue3",
// 版本
  "version": "0.0.0",
// 不发布npm仓库
  "private": "false",
// 执行脚本
  "scripts": {
// 安装所有依赖
    "install:all": "pnpm i -w",
// 打包packages目录下的所有项目,还包括生成ts文件并移动到对应的目录
    "build:all": "pnpm run ts:build && pnpm --filter ./packages/** run build && pnpm run mv:type",
// 运行carshop项目
    "dev:carshop": "pnpm --filter @learn_vue3/car_shop run dev",
// 打包该项目
    "build:carshop": "pnpm --filter @learn_vue3/car_shop run build-only",
// 打包公用组件
    "build:components": "pnpm run ts:build && pnpm --filter @learn_vue3/components run build && pnpm run mv:type",
// 打包公用方法模块
    "build:shared": "pnpm run ts:build && pnpm --filter @learn_vue3/shared run build && pnpm run mv:type",
    "clean:type": "rimraf ./dist",
    "mv:type": "tsx ./scripts/dtsMv.ts",
    "ts:check": "vue-tsc -p tsconfig.web.json --noEmit --composite false",
    "ts:build": "pnpm run clean:type && vue-tsc -p tsconfig.web.json --composite false --declaration --emitDeclarationOnly",
    "lint:script": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
    "lint:style": "stylelint --fix ./**/*.{css,scss,vue,html}"
  },
  // 所有子模块共用的生产依赖
  "dependencies": {
    "vue": "^3.4.29"
  },
// 所有子模块共用的依赖
  "devDependencies": {
    "@rushstack/eslint-patch": "^1.8.0",
    "@tsconfig/node20": "^20.1.4",
    "@types/node": "^20.16.1",
    "@vitejs/plugin-vue": "^5.0.5",
    "@vue/eslint-config-prettier": "^9.0.0",
    "@vue/eslint-config-typescript": "^13.0.0",
    "@vue/tsconfig": "^0.5.1",
    "eslint": "^8.57.0",
    "eslint-plugin-vue": "^9.23.0",
    "npm-run-all2": "^6.2.0",
    "prettier": "^3.2.5",
    "rimraf": "^6.0.1",
    "stylelint": "^16.8.2",
    "stylelint-config-recess-order": "^5.0.1",
    "stylelint-config-recommended-vue": "^1.5.0",
    "stylelint-config-standard-scss": "^13.1.0",
    "tsx": "^4.17.0",
    "typescript": "~5.4.0",
    "vite": "^5.3.1",
    "vite-plugin-vue-devtools": "^7.3.1",
    "vue-tsc": "^2.0.29"
  }

}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// /packages/shared/packages.json  所有项目公用的方法
{
  "name": "@learn_vue3/shared",
  "version": "0.0.1",
  // 作为发布包使用
  "description": "第一次发布,公用方法组件",
  "repository": {
    "type": "git",
    "url": "git@github.com:chenjieya/monorepo_vue3.git"
  },
  "keywords": [
    "utils"
  ],
  "author": "alvis",
  "license": "MIN",
  "bugs": {
    "url": "https://github.com/chenjieya/monorepo_vue3/issues"
  },
  // 打包脚本
  "scripts": {
    "build": "vite build"
  },
  // 当前模块的依赖
  "dependencies": {
    "@types/lodash": "^4.17.7",
    "lodash": "^4.17.21"
  },
  // 作为发布包使用的入口信息
  "main": "./dist/learn_vue3-shared.umd.js",
  "module": "./dist/learn_vue3-shared.mjs",
  "exports": {
    ".": {
      "require": "./dist/learn_vue3-shared.umd.js",
      "module": "./dist/learn_vue3-shared.mjs",
      "types": "./dist/types/index.d.ts"
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// /packages/components/packages.json  所有项目公用的vue组件
{
  "name": "@learn_vue3/components",
  "version": "0.0.1",
  // 作为发布包使用
  "description": "第一次发布,vue组件",
  "repository": {
    "type": "git",
    "url": "git@github.com:chenjieya/monorepo_vue3.git"
  },
  "keywords": [
    "vue3",
    "components"
  ],
  "author": "alvis",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/chenjieya/monorepo_vue3/issues"
  },
  "scripts": {
    "build": "vite build"
  },
  // 对等依赖,需要vue3
  "peerDependencies": {
    "vue": ">=3.4.0"
  },
  // 该模块的依赖信息,可见依赖了内部模块@learn_vue3/shared
  "dependencies": {
    "@learn_vue3/shared": "workspace:^"
  },
  // 作为发布包使用的入口信息
  "main": "./dist/learn_vue3-components.umd.js",
  "module": "./dist/learn_vue3-components.mjs",
  "exports": {
    ".": {
      "require": "./dist/learn_vue3-components.umd.js",
      "module": "./dist/learn_vue3-components.mjs",
      "types": "./dist/types/index.d.ts"
    }
  },
  "publishConfig": {
    "access": "public",
    "registry": "https://registry.npmjs.org/"
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// /packages/carshop/packages.json  这是一个项目模块
{
  "name": "@learn_vue3/car_shop",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "build-only": "vite build",
    "type-check": "vue-tsc --build --force",
    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
    "format": "prettier --write src/"
  },
  // 依赖内部模块 @learn_vue3/components公用组件
  "dependencies": {
    "@learn_vue3/components": "workspace:^"
  },
  "peerDependencies": {
    "vue": ">=3.4.0"
  }
}