背景
随着前端项目和团队规模的不断扩大,如何高效地管理多个相关的项目成为了一个需要解决的重要问题。传统的多个代码仓库(multi-repo)方式已经显现出它的劣势,特别是在项目之间共享代码和依赖管理时。为了解决这些问题,Monorepo(单一代码库)方式应运而生。而结合使用 pnpm,可以进一步提升开发效率和资源利用率。
monorepo简介
monorepo是一种项目开发与管理的策略模式,它代表“单一代码仓库(Monolithic Repository)”。
在monorepo模式中,所有相关的项目和组件都被存储在一个统一的代码仓库中,而不是分散在多个独立的代码仓库中,这些项目之间还可能会有依赖关系。
优点
- 代码复用
- 统一代码规范
- 统一依赖管理
- 统一构建和部署流程,降低了配置和维护多个项目所需的工作量
- 提高团队协作,方便代码检索
缺点
- Monorepo 可能随着时间推移变得庞大和复杂,导致构建时间增长和管理困难,git clone、pull 的成本增加。
- 权限管理问题:项目粒度的权限管理较为困难,容易产生非owner管理者的改动风险。
项目管理模式历程
| Monolithic | Multirepo | Monorepo | |
|---|---|---|---|
| 代码复用 | ✅ | ❌ | ✅ |
| 代码规范 | ✅ | ❌ | ✅ |
| 版本控制 | ✅ | ❌ | ✅ |
| 工程配置复用 | ✅ | ❌ | ✅ |
| 部署流程复用 | ✅ | ❌ | ✅ |
| 依赖管理 | ✅ | ❌ | ✅ |
| 开发调试效率 | ✅ | ❌ | ✅ |
| 构建时长 | ❌ | ✅ | ✅ |
| 代码耦合 | ❌ | ✅ | ✅ |
| git操作时长 | ❌ | ✅ | ❌ |
| 代码权限粒度 | ❌ | ✅ | ❌ |
已有案例
- Babel
- Vue
- React
- Vite
- Element UI
- Varlet UI、
- Vant UI
。。。
pnpm
一种新的包管理工具,类似于 npm 和 yarn,但它有一些独特的优势:
- 高效的磁盘空间利用:
pnpm使用了独特的符号链接(symlink)机制,使得项目之间可以共享相同的依赖包,大幅减少磁盘空间占用。 - 快速的安装速度:由于
pnpm底层实现上的优化,依赖安装速度明显快于npm和yarn。 - 严格的依赖关系:
pnpm强制依赖包写入node_modules时不得侵入其他包的空间,避免了意外的全局依赖冲突。
选择 pmpm 作为包管理工具主要是由于 pnpm 很好的解决了 npm 与 yarn 遗留的历史问题。
workspace
工作区(Workspace)在现代 JavaScript 项目管理工具中是一个非常重要的概念。它允许开发人员在一个单一的仓库中管理多个包或项目,从而极大地优化了跨包管理和依赖管理。npm、yarn 和 pnpm 都支持工作区的概念,但各自的实现方式有所不同。
Workspace 的基本概念
- 多包管理:Workspace 允许在一个仓库中管理多个包,这些包之间可以互相依赖,并且可以在本地进行快速开发和测试。
- 依赖关系:可以将某个包设为其他包的依赖,而不需要发布到 npm registry。Workspace 会自动处理这些依赖关系,确保它们在开发环境中正常工作。
- 统一的依赖管理:工作区可以统一管理所有包的依赖,从而保证一致性并节省磁盘空间。
- 简化的开发体验:通过工作区,可以在一个地方运行脚本、构建和测试所有包,而不需要进入每个包的目录逐个执行命令。
包管理工具通过以下方式实现 workspace 的支持
- 代码结构组织:在 Monorepo 中,不同的项目或模块通常位于同一个代码库的不同目录中。包管理工具通过识别并管理这些目录结构,可以将它们作为独立的项目或模块进行操作。
- 共享依赖:Monorepo 中的不同项目或模块可以共享相同的依赖项。包管理工具可以通过在根目录中维护一个共享的依赖项列表,以确保这些依赖项在所有项目或模块中都可用。
- 交叉引用:在 Monorepo 中,不同项目或模块之间可能存在相互引用的情况。包管理工具需要处理这些交叉引用,以确保正确解析和构建项目之间的依赖关系。
- 版本管理:Monorepo 中的不同项目或模块可能具有不同的版本。包管理工具需要能够管理和跟踪这些版本,并确保正确地安装和使用适当的版本。
- 构建和测试:包管理工具需要支持在 Monorepo 中进行增量构建和测试。这意味着只有发生更改的项目或模块会重新构建和测试,而不需要重新构建和测试整个代码库。
pnpm 中的 workspace
pnpm 的 workspace 是通过一个配置文件 pnpm-workspace.yaml 来定义的,该文件指定了工作区包含的所有包。
pnpm 的 workspace 功能利用符号链接和严谨的依赖管理机制,为大型项目的单仓库管理提供了强有力的支持
基于 pnpm 包管理器的 monorepo 基本使用
pnpm 并不是通过目录名称,而是通过目录下 package.json 文件的 name 字段来识别仓库内的包与模块的。
根目录-中枢管理操作
执行一些全局操作,安装一些共有的依赖,每个子模块都能访问根目录的依赖,适合把 TypeScript、Vite、eslint 等公共开发依赖装在这里
- 安装项目公共开发依赖,声明在根目录的 package.json - devDependencies 中。
-w选项代表在monorepo模式下的根目录进行操作。
// 安装
pnpm install -wD xxx
// 卸载
pnpm uninstall -w xxx
- 执行根目录的 package.json 中的脚本
pnpm run xxx
子包管理操作
在 workspace 模式下,pnpm 主要通过 --filter 选项过滤子模块,实现对各个工作空间进行精细化操作的目的。
- 为指定模块安装外部依赖。
// 为 pkg-a 包安装 lodash
pnpm --filter pkg-a i -S lodash // 生产依赖
pnpm --filter pkg-a i -D lodash // 开发依赖
2. 指定内部模块之间的互相依赖。
// 指定 pkg-a 模块依赖于 pkg-b 模块
pnpm --filter pkg-a i -S pkg-b
pnpm workspace 对内部依赖关系的表示不同于外部,它自己约定了一套 Workspace 协议 (workspace:)。下面给出一个内部模块 pkg-a 依赖同是内部模块 pkg-b 的例子。
{
"name": "pkg-a",
// ...
"dependencies": {
"pkg-b": "workspace:^"
}
}
在实际发布 npm 包时,workspace:^ 会被替换成内部模块 pkg-b 的对应版本号(对应 package.json 中的 version 字段)。替换规律如下所示:
{
"dependencies": {
"pkg-a": "workspace:*", // 固定版本依赖,被转换成 x.x.x
"pkg-b": "workspace:~", // minor 版本依赖,将被转换成 ~x.x.x
"pkg-c": "workspace:^" // major 版本依赖,将被转换成 ^x.x.x
}
}
如何取舍
是否使用?怎么使用?
小范围地将关联性强的几个项目整合到一个代码仓,控制提交代码的人数
如何解决monorepo无法进行细粒度权限管理的缺点
1. 使用代码所有权文件 使用如 CODEOWNERS 文件(GitHub 等平台支持)来指定某个目录或文件的所有者。当这些文件或目录被修改时,只有指定的所有者才能批准更改。这种方法能够实现对项目或模块级别的权限粒度控制。
2. 利用CI/CD流程 在持续集成/持续部署(CI/CD)流程中设置权限和访问控制。例如,可以配置流程,只允许具有特定权限的用户触发构建或部署到生产环境。这种方式可以在流程层面上控制谁可以对代码库进行重要操作。
3. 分支策略 通过严格的分支管理策略,如Git Flow,控制不同级别的开发人员可以访问和修改的分支。比如只允许项目负责人合并代码到主分支,而其他开发人员只能在特定的功能分支上工作。
4. 使用Git钩子 配置Git钩子(Hooks),在代码提交或合并前执行脚本来检查提交者的权限。例如,可以设定pre-commit钩子,确保提交的代码符合访问权限要求。
5. 利用子模块 虽然这种做法在传统Monorepo中较少使用,但通过Git子模块(submodules)可以实现对特定部分的仓库独立控制,从而在需要时提供更细粒度的权限管理。
6. 第三方工具和扩展 考虑使用一些第三方工具和扩展来管理权限。例如,GitLab和Bitbucket等平台提供了更细粒度的权限控制设置,允许在项目或组织级别进行详细的访问控制。
上手-将vue3+vite+element-plus的两个项目整合
将pkg-a和pkg-b整合到一个项目,公共部分抽离成子包
改造后目录
test
├─ .browserslistrc
├─ .editorconfig
├─ .eslintignore
├─ .eslintrc.json
├─ .git
│ ├─ config
│ ├─ ...
├─ .gitignore
├─ .npmrc
├─ .prettierrc.json
├─ package.json
├─ packages
│ ├─ apps
│ │ ├─ pkg-a
│ │ │ ├─ index.html
│ │ │ ├─ package.json
│ │ │ ├─ public
│ │ │ │ ├─ vite.svg
│ │ │ ├─ README.md
│ │ │ ├─ src
│ │ │ │ ├─ App.vue
│ │ │ │ ├─ assets
│ │ │ │ │ └─ images
│ │ │ │ │ └─ 404.jpg
│ │ │ │ ├─ components
│ │ │ │ │ ├─ ...
│ │ │ │ ├─ directives
│ │ │ │ │ ├─ ...
│ │ │ │ ├─ index.d.ts
│ │ │ │ ├─ layout
│ │ │ │ │ ├─ components
│ │ │ │ │ │ ├─ Header
│ │ │ │ │ │ │ └─ index.vue
│ │ │ │ │ │ ├─ IframeLink
│ │ │ │ │ │ │ └─ index.vue
│ │ │ │ │ │ ├─ Sidebar
│ │ │ │ │ │ │ ├─ index.vue
│ │ │ │ │ │ │ └─ Sidebar.vue
│ │ │ │ │ │ └─ Tagsview
│ │ │ │ │ │ └─ index.vue
│ │ │ │ │ ├─ hooks
│ │ │ │ │ │ └─ useTagViewApi.ts
│ │ │ │ │ ├─ index.vue
│ │ │ │ │ └─ utils
│ │ │ │ │ ├─ const.ts
│ │ │ │ │ └─ util.ts
│ │ │ │ ├─ locale
│ │ │ │ │ ├─ en.ts
│ │ │ │ │ ├─ i18n.ts
│ │ │ │ │ └─ zh.ts
│ │ │ │ ├─ main.js
│ │ │ │ ├─ router
│ │ │ │ │ └─ index.ts
│ │ │ │ ├─ shims-vue.d.ts
│ │ │ │ ├─ store
│ │ │ │ │ ├─ index.ts
│ │ │ │ │ └─ modules
│ │ │ │ │ ├─ controls.ts
│ │ │ │ │ └─ user.ts
│ │ │ │ ├─ typings.d.ts
│ │ │ │ ├─ utils
│ │ │ │ │ ├─ const.ts
│ │ │ │ │ ├─ countries.js
│ │ │ │ │ ├─ service.ts
│ │ │ │ │ └─ type.d.ts
│ │ │ │ └─ views
│ │ │ │ ├─ ...
│ │ │ ├─ tsconfig.json
│ │ │ └─ vite.config.ts
│ │ └─ pkg-b
│ │ ├─ index.html
│ │ ├─ package.json
│ │ ├─ public
│ │ │ └─ vite.svg
│ │ ├─ src
│ │ │ ├─ App.vue
│ │ │ ├─ assets
│ │ │ │ ├─ images
│ │ │ │ │ ├─ 403.png
│ │ │ │ │ ├─ 404.png
│ │ │ │ │ ├─ home-bg.png
│ │ │ │ │ ├─ logo.png
│ │ │ │ │ └─ nodata.png
│ │ │ │ └─ styles
│ │ │ │ └─ fonts
│ │ │ │ ├─ element-icons.ttf
│ │ │ │ └─ element-icons.woff
│ │ │ ├─ components
│ │ │ │ ├─ ...
│ │ │ ├─ config
│ │ │ │ └─ index.ts
│ │ │ ├─ directives
│ │ │ │ ├─ ...
│ │ │ ├─ hooks
│ │ │ │ └─ usePageScroll.ts
│ │ │ ├─ index.d.ts
│ │ │ ├─ layout
│ │ │ │ ├─ index.vue
│ │ │ │ └─ utils
│ │ │ │ └─ const.ts
│ │ │ ├─ locale
│ │ │ │ ├─ diff.ts
│ │ │ │ ├─ en.ts
│ │ │ │ ├─ i18n.ts
│ │ │ │ └─ zh.ts
│ │ │ ├─ main.js
│ │ │ ├─ router
│ │ │ │ └─ index.ts
│ │ │ ├─ shims-vue.d.ts
│ │ │ ├─ store
│ │ │ │ ├─ index.ts
│ │ │ │ └─ modules
│ │ │ │ └─ user.ts
│ │ │ ├─ typings.d.ts
│ │ │ ├─ utils
│ │ │ │ ├─ const.ts
│ │ │ │ ├─ emoji.ts
│ │ │ │ ├─ excel.ts
│ │ │ │ ├─ service.ts
│ │ │ │ ├─ url.ts
│ │ │ │ └─ utils.ts
│ │ │ └─ views
│ │ │ ├─ ...
│ │ ├─ tsconfig.json
│ │ └─ vite.config.ts
│ ├─ components
│ │ ├─ package.json
│ │ ├─ src
│ │ │ ├─ FileUpload
│ │ │ │ └─ index.vue
│ │ │ └─ index.ts
│ │ └─ tsconfig.json
│ ├─ hooks
│ │ ├─ package.json
│ │ ├─ tsconfig.json
│ │ ├─ useCurrentInstance.ts
│ │ ├─ useDialogVisible.ts
│ │ ├─ useDialogVisibleSelf.ts
│ │ └─ useListPage.ts
│ ├─ styles
│ │ ├─ common.scss
│ │ ├─ element-overwrite.scss
│ │ ├─ element-variables.scss
│ │ ├─ mixin.scss
│ │ ├─ package.json
│ │ ├─ transition.scss
│ │ └─ variables.scss
│ └─ utils
│ ├─ cookie.ts
│ ├─ env.ts
│ ├─ fetch.ts
│ ├─ object.ts
│ ├─ package.json
│ ├─ storage.ts
│ ├─ tsconfig.json
│ ├─ type.d.ts
│ ├─ url.js
│ └─ util.ts
├─ pnpm-lock.yaml
├─ pnpm-workspace.yaml
├─ tsconfig.json
└─ vite.config.shared.ts
pnpm-workspace.yaml
packages:
- 'packages/apps/**'
- 'packages/components'
- 'packages/utils'
- 'packages/styles'
- 'packages/hooks'
根目录package.json
{
"name": "test",
"version": "1.0.0",
"description": "text",
"type": "module",
"private": true,
"scripts": {
"pkg-a:dev": "pnpm -F @test/pkg-a run dev",
"pkg-a:build": "pnpm -F @test/pkg-a run build",
"pkg-b:dev": "pnpm -F @test/pkg-b run dev",
"pkg-b:build": "pnpm -F @test/pkg-b run build",
"lint": "eslint --color --fix --ext .ts,.tsx,.js,.jsx,.vue src"
},
...
}
公共子包:utils
公共子包:styles
公共子包:hooks
公共组件:components
依赖公共子包utils
子应用pkg-a
vite配置:合并根目录下的公共配置
import sharedConfig, { proxyV, getIPAddress } from '../../../vite.config.shared';
const sharedConfigInfo = sharedConfig();
return mergeConfig(sharedConfigInfo, {
...base,
...dev,
});
引用公共子包:
import { requiredSpaceValidator } from '@test/utils/util';
import { isNotEmptyObject } from '@test/utils/object';
import useListPage from '@test/hooks/useListPage';
import { FileUpload } from '@test/components';
import '@test/styles/common.scss';
子应用pkg-b
与pkg-a修改类似
安装依赖
子应用在package.json配置依赖时,只需要配置除根目录package.json以外的依赖
pnpm i
会在各个有package.json文件的目录生成node_modules
本地运行
在子应用目录执行
pnpm run dev
或者,在根目录执行
pnpm run pkg-a:dev
编译打包
在子应用目录执行
pnpm run build
或者,在根目录执行
pnpm run pkg-a:build
扩展
Turborepo 可以帮助我们更好的管理Monorepo项目, 凭借自身优秀的任务调度管理和增量构建缓存等等, 都可以帮助我们在未来解决monorepo目前存在的一些问题,进而提高我们的开发效率,以及提升整个项目在构建等方面的性能。
参考
juejin.cn/post/731640… juejin.cn/post/721031… segmentfault.com/a/119000003… wangtunan.github.io/blog/vueNex… juejin.cn/post/721588… juejin.cn/post/704399… www.jianshu.com/p/c10d0b8c5… juejin.cn/post/735754… juejin.cn/post/724381…