monorepo+pnpm工程实践

1,198 阅读9分钟

背景

随着前端项目和团队规模的不断扩大,如何高效地管理多个相关的项目成为了一个需要解决的重要问题。传统的多个代码仓库(multi-repo)方式已经显现出它的劣势,特别是在项目之间共享代码和依赖管理时。为了解决这些问题,Monorepo(单一代码库)方式应运而生。而结合使用 pnpm,可以进一步提升开发效率和资源利用率。

monorepo简介

monorepo是一种项目开发与管理的策略模式,它代表“单一代码仓库(Monolithic Repository)”。

monorepo模式中,所有相关的项目和组件都被存储在一个统一的代码仓库中,而不是分散在多个独立的代码仓库中,这些项目之间还可能会有依赖关系。

优点

  • 代码复用
  • 统一代码规范
  • 统一依赖管理
  • 统一构建和部署流程,降低了配置和维护多个项目所需的工作量
  • 提高团队协作,方便代码检索

缺点

  • Monorepo 可能随着时间推移变得庞大和复杂,导致构建时间增长和管理困难,git clone、pull 的成本增加。
  • 权限管理问题:项目粒度的权限管理较为困难,容易产生非owner管理者的改动风险。

项目管理模式历程

image.png

image.png

MonolithicMultirepoMonorepo
代码复用
代码规范
版本控制
工程配置复用
部署流程复用
依赖管理
开发调试效率
构建时长
代码耦合
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 项目管理工具中是一个非常重要的概念。它允许开发人员在一个单一的仓库中管理多个包或项目,从而极大地优化了跨包管理和依赖管理。npmyarn 和 pnpm 都支持工作区的概念,但各自的实现方式有所不同。

Workspace 的基本概念

  • 多包管理:Workspace 允许在一个仓库中管理多个包,这些包之间可以互相依赖,并且可以在本地进行快速开发和测试。
  • 依赖关系:可以将某个包设为其他包的依赖,而不需要发布到 npm registry。Workspace 会自动处理这些依赖关系,确保它们在开发环境中正常工作。
  • 统一的依赖管理:工作区可以统一管理所有包的依赖,从而保证一致性并节省磁盘空间。
  • 简化的开发体验:通过工作区,可以在一个地方运行脚本、构建和测试所有包,而不需要进入每个包的目录逐个执行命令。

包管理工具通过以下方式实现 workspace 的支持

  1. 代码结构组织:在 Monorepo 中,不同的项目或模块通常位于同一个代码库的不同目录中。包管理工具通过识别并管理这些目录结构,可以将它们作为独立的项目或模块进行操作。
  2. 共享依赖:Monorepo 中的不同项目或模块可以共享相同的依赖项。包管理工具可以通过在根目录中维护一个共享的依赖项列表,以确保这些依赖项在所有项目或模块中都可用。
  3. 交叉引用:在 Monorepo 中,不同项目或模块之间可能存在相互引用的情况。包管理工具需要处理这些交叉引用,以确保正确解析和构建项目之间的依赖关系。
  4. 版本管理:Monorepo 中的不同项目或模块可能具有不同的版本。包管理工具需要能够管理和跟踪这些版本,并确保正确地安装和使用适当的版本。
  5. 构建和测试:包管理工具需要支持在 Monorepo 中进行增量构建和测试。这意味着只有发生更改的项目或模块会重新构建和测试,而不需要重新构建和测试整个代码库。

pnpm 中的 workspace

pnpm 的 workspace 是通过一个配置文件 pnpm-workspace.yaml 来定义的,该文件指定了工作区包含的所有包。

pnpm 的 workspace 功能利用符号链接和严谨的依赖管理机制,为大型项目的单仓库管理提供了强有力的支持

基于 pnpm 包管理器的 monorepo 基本使用

pnpm 并不是通过目录名称,而是通过目录下 package.json 文件的 name 字段来识别仓库内的包与模块的。

根目录-中枢管理操作

执行一些全局操作,安装一些共有的依赖,每个子模块都能访问根目录的依赖,适合把 TypeScriptViteeslint 等公共开发依赖装在这里

  • 安装项目公共开发依赖,声明在根目录的 package.json - devDependencies 中。-w 选项代表在 monorepo 模式下的根目录进行操作。
// 安装
pnpm install -wD xxx
// 卸载
pnpm uninstall -w xxx
  • 执行根目录的 package.json 中的脚本
pnpm run xxx

子包管理操作

在 workspace 模式下,pnpm 主要通过 --filter 选项过滤子模块,实现对各个工作空间进行精细化操作的目的。

  1. 为指定模块安装外部依赖。
// 为 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整合到一个项目,公共部分抽离成子包

改造后目录

image.png

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

image.png

公共子包:styles

image.png

公共子包:hooks

image.png

公共组件:components

依赖公共子包utils image.png

image.png

子应用pkg-a

image.png

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…