Monorepo(pnpm)在Ptengine中的实践
引言
经历数载,Ptengine项目已由原来的单一应用发展到现在的包含多个子应用的大型应用,虽然经历了数次重构,由单项目拆分为多个模块、多个仓库的模式,但仍然存在一些问题,如模块之间引用的问题,模块依赖版本不能保证及时统一(如Ptengine各个子项目的node版本不统一),项目之间模块复用难,各个项目的各种配置都各自维护,难于保证统一等等,这也是大部分项目普通存在的问题,这种情况下急需一种新的代码管理方式来解决这一问题,在此情况下,Monorepo应运而生。
概念
什么是 Monorepo?
Monorepo(单一代码库)是一种代码管理策略,它将多个项目或模块的代码存放在一个版本控制系统的单个仓库中。这种方法有助于简化跨项目的代码共享、依赖管理、构建和部署流程。Monorepo支持更好的团队协作和代码一致性,因为所有相关项目的代码都集中在一个地方,便于维护和更新。大型公司如 Google、Facebook 和 Microsoft 等都采用了 Monorepo 来管理他们庞大的代码库。
Monorepo 的优点包括:
- 代码可见性:在一个仓库中可以很容易看到整个代码库的变化趋势,有利于团队协作。
- 依赖管理:相同版本的依赖可以提升到顶层只安装一次,节省磁盘空间。
- 代码权限:虽然多个项目代码都在一个仓库中,但可以实现项目粒度的权限管控。
- 开发迭代:多个项目都在一个仓库中,便于进行代码重构和复用。
- 工程配置:多项目在一个仓库,工程配置一致,有助于保持代码质量标准和风格一致性。
- 构建部署: Monorepo 工具可以配置依赖项目的构建优先级,实现一次命令完成所有的部署。
Monorepo 的前世今生
- 单仓库巨石应用 一般由简单单项目发展而来,随着业务不断发展,代码越来越多,越来越复杂,导致的问题也越来越多, 如构建效率低下,内部耦合严重。
- 多仓库多模块应用 针对以上问题,将整个项目拆分为多个模块,多个git仓库,以解决模块之间耦合的问题。
- 单仓库多模块应用 但以上多仓库多模块应用带来了新的问题,可能有几十个小模块被几个应用都有引用,如果每个模块都使用单独的仓库,又大大增加了管理成本,并且多个仓库版本依赖要及时更新,增加管理成本。所以又产生了一种趋势,将多个项目集成到一个仓库下,模块代码复用,这就是 单仓库多模块应用(Monorepo)。
什么是pnpm?
Monorepo只是一种概念,而 pnpm则是它的一种实现
pnpm(performant npm)是一个包管理器,它提供了比 npm 或 yarn 更高的性能和效率。 pnpm 通过硬链接和符号链接来节省磁盘空间,并创建一个非扁平的 node_modules
目录,这样可以防止不必要的包版本冲突。 pnpm 支持 Monorepo,允许在单个仓库中管理多个包,这使得它在处理大型项目时更加高效。
pnpm 的特点包括:
- 速度快:在某些场景下比 npm/yarn 快了大约两倍。
- 节约磁盘空间:所有文件均链接自单一存储位置,减少了重复数据。
- 支持 Monorepo:内置了对单个源码仓库中包含多个软件包的支持。
- 安全性高:创建的
node_modules
并非扁平结构,代码不能对任意软件包进行访问。
这些特性使得 pnpm 成为一个高效且节省资源的包管理工具,特别适合于需要处理多个项目和大量依赖的场景。
如何避免冲突
因为是在现有项目上进行改造,同时还有其他小伙伴们在做其他功能开发,而这次引入 pnpm 会变更 src
目录路径,由原来的 src
变更为 apps/base/src
,所以代码合并时会有极大的可能会和其他小伙伴们产生大量的代码冲突(即使使用 git mv
),我仿佛看到了上线前夕一群人围着我熬夜处理代码冲突的情景。。。关键是冲突双方的代码还不是我写的。。。
那么问题来了,如何能保证尽可能少的减少代码冲突,减少对其他开发童鞋的影响,同时也保证自己的开发时间也在正常的迭代范围内(总不能等所有童鞋把所有工作做完代码提交后再开始做吧?)。
因为其他童鞋开发主要是在 src
目录中进行的功能开发,所以可能引发起的冲突主要是 src目录变更这部分操作,所以,比较统筹的方法是用git合理拆分或合并提交,把这部分可能冲突的操作抽离为一个commit,严格保证此commit的功能单一性,其他操作则按正常提交,自己测试通过后,等迭代后期其他童鞋测试通过后,只需在测试通过之后的基础之上手动重装执行下 src
目录变更操作,然后再将其他操作的提交 cherry-pick
过来。
梳理清流程至关重要
具体步骤流程
- 创建根目录
apps
用于存放各项目, 并将原项目文件转移到apps
中
apps/
├── base # 原ptengine-frontend项目根目录
│ ├── package.json
│ ├── src
│ ├── tsconfig.json
│ └── ...
└── ...
apps/
├── base # 原ptengine-frontend项目根目录
│ ├── package.json
│ ├── src
│ ├── tsconfig.json
│ └── ...
└── ...
- 创建新的根目录
packages.json
{
"name": "ptengine-frontend",
"version": "1.0.0",
"description": "",
"main": "index.js"
...
}
{
"name": "ptengine-frontend",
"version": "1.0.0",
"description": "",
"main": "index.js"
...
}
- 创建
packages
目录,用于存放公共库,如ui库 - 创建
internal
目录,用于存放内部配置相关模块(如ts
,commitlint
配置) - 启动单仓库功能需要创建工作空间文件
pnpm-workspace.yaml
,设置受 pnpm workspace管理的路径
packages:
- "apps/*"
- "packages/*"
- "internal/*"
packages:
- "apps/*"
- "packages/*"
- "internal/*"
- 完善
internal
内各模块(commitlint-config
,eslint-config
,ts-config
,cz-config
,prettier-config
) 模块过多,为减少篇幅,在此仅以ts-config
为例ts-config
模板结构
internal/ts-config/
├── base.json
└── package.json
internal/ts-config/
├── base.json
└── package.json
在internal/ts-config/package.json
中, 设置好 name
,以便供其他模块引用
{
"name": "ts-config",
...
}
{
"name": "ts-config",
...
}
internal/ts-config/base.json
为公共配置
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"lib": ["esnext", "dom"],
"plugins": [{ "name": "@vuedx/typescript-plugin-vue" }]
}
}
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"lib": ["esnext", "dom"],
"plugins": [{ "name": "@vuedx/typescript-plugin-vue" }]
}
}
其他可项目可以在各自的 tsconfig.json
中通过 extends
参数来继承本文件,同时也可以扩展自己的配置
{
"extends": "ts-config/base",
...
}
{
"extends": "ts-config/base",
...
}
注意使用前在引用方的 package.json
中将 ts-config
引入,使用 workspace
协议,如下:
{
"devDependencies": {
"ts-config": "workspace:^",
...
},
...
}
{
"devDependencies": {
"ts-config": "workspace:^",
...
},
...
}
- 完善
packages
内模块(将ui库转移到packages
内)
packages
├── ui
│ ├── index.js
│ └── package.json
└── ...
packages
├── ui
│ ├── index.js
│ └── package.json
└── ...
package.json
增加快捷启动命令
{
"scripts": {
"dev:all": "pnpm run --parallel --no-stream -r dev",
"dev:base": "pnpm --filter pt-base dev",
...
},
...
}
{
"scripts": {
"dev:all": "pnpm run --parallel --no-stream -r dev",
"dev:base": "pnpm --filter pt-base dev",
...
},
...
}
package.json
指定 node版本
{
...
"engines": {
"node": "20.x"
}
}
{
...
"engines": {
"node": "20.x"
}
}
.nvmrc
指定 node版本为20
20
20
中途临时测试先和其他个别童鞋合并并提交到develop产生了大量冲突,总结原因:
develop
分支和 master
长期分离已久, develop
分支上包含了大量 master
未有的各种测试代码
针对以上原因,重新对git提交拆分整理,并对 develop
环境强制更新为 master
代码(此举要避开其他童鞋提测),然后再次将整理后的分支与 develop
分支合并时,效果有些“过了头”,居然没有一个冲突,都不用上图中的第一步人工操作了。。
总项目结构:
.
├── apps # 项目文件夹
│ ├── base # 项目1(原ptengine-frontend项目)
│ │ ├── README.md
│ │ ├── index.html
│ │ ├── package.json
│ │ └── ...
│ ├── experience # 项目2
│ │ └── package.json
│ └── ...
├── internal # 一些内部配置
│ ├── ts-config
│ │ ├── index.js
│ │ └── package.json
│ └── ...
├── package.json
├── packages # 公共包
│ ├── ui # UI组件库(占位)
│ │ ├── index.js
│ │ ├── package.json
│ │ └── ...
│ └── ...
├── pnpm-workspace.yaml # pnpm工作区定义文件
└── ...
.
├── apps # 项目文件夹
│ ├── base # 项目1(原ptengine-frontend项目)
│ │ ├── README.md
│ │ ├── index.html
│ │ ├── package.json
│ │ └── ...
│ ├── experience # 项目2
│ │ └── package.json
│ └── ...
├── internal # 一些内部配置
│ ├── ts-config
│ │ ├── index.js
│ │ └── package.json
│ └── ...
├── package.json
├── packages # 公共包
│ ├── ui # UI组件库(占位)
│ │ ├── index.js
│ │ ├── package.json
│ │ └── ...
│ └── ...
├── pnpm-workspace.yaml # pnpm工作区定义文件
└── ...
启动流程
- 安装 pnpm
npm install -g pnpm
npm install -g pnpm
- 首次初始化
pnpm install
pnpm install
可以看包安装速度明显提升(得益于 pnpm store
缓存机制,第一次安装包时会从网上下载,然后缓存到 pnpm store
中,下次安装同版本时则直接从 pnpm store
本地缓存中获取)
- 启动项目base
pnpm dev:base
pnpm dev:base
技术参考文献
总结
本次升级还是比较顺利的,最后上线也很顺利,考虑的问题主要是如何减少对其他童鞋的影响,避免代码冲突,把可能出现的冲突扼杀在摇篮里,出现技术性的问题也不多,由此可见pnpm目前做的已经比较成熟了,希望本文对于对monorepo、pnpm还在纠结观望的您能有所帮助!