在最近的项目开发中,出现了一个令我困扰的情况。我正在开发的项目 A,依赖了已经线上发布的项目 B,可是随着项目 A 的不断开发,又须要不时修改项目 B 的代码(这些修改暂时没必要发布线上),如何可以在修改项目 B 代码后及时将改动后在项目 A 中同步? 在项目 A 发布上线后,如何以一种优雅的方式解决项目 A,B 版本升级后的版本同步问题? 通过一番调研,我发现解决这些问题的最佳方案即是本篇要介绍的 monorepo 策略。前端
monorepo 是一种将多个项目代码存储在一个仓库里的软件开发策略("mono" 来源于希腊语 μόνος 意味单个的,而 "repo",显而易见地,是 repository 的缩写)。将不一样的项目的代码放在同一个代码仓库中,这种「把鸡蛋放在同一个篮子里」的作法可能乍看之下有些奇怪,但实际上,这种代码管理方式有不少好处,不管是世界一流的互联网企业 Google,Facebook,仍是社区知名的开源项目团队 Babel (以下图)都使用了 monorepo 策略管理他们的代码。node
<p style="text-align: center; color: #999;">babel 使用 monorepo 策略管理代码</p>git
使用 monorepo 策略究竟会给代码管理者和程序开发者带来哪些好处? 咱们又该如何在工做中尝试实践 monorepo 策略?这正是本文想要探讨的话题。但愿经过个人一番介绍,您可以对 monorepo 策略有更完整的认知,文章中介绍的工具和思想能够切实帮助到您和您所在的团队。github
经过 monorepo 策略组织代码,您代码仓库的目录结构看起来会是这样:shell
. ├── lerna.json ├── package.json └── packages/ # 这里将存放全部子 repo 目录 ├── project_1/ │ ├── index.js │ ├── node_modules/ │ └── package.json ├── project_2/ │ ├── index.js │ ├── node_module/ │ └── package.json ...
乍看起来,所谓的 monorepo 策略就只是将不一样项目的目录聚集到一个目录之下,但实际上操做起来所要考虑的事情则远比看起来要复杂得多。经过分析使用 monorepo 策略的优劣,咱们能够更直观的感觉到这里面所隐晦涉及的知识点。npm
没错,软件开发领域历来没有「银弹」。monorepo 策略也并不完美,而且,我在实践中发现,要想完美在组织中运用 monorepo 策略,所须要的不只是出色的编程技巧和耐心。团队日程,组织文化和我的影响力相互碰撞的最终结果才决定了想法最终是否能被实现。编程
可是请别灰心的太早,由于虽然让组织做出改变,统一施行 monorepo 策略困难重重,但这却并不意味着咱们须要完全跟 monorepo 策略说再见(不然我这篇文章就该到此为止了)。咱们还能够把 monorepo 策略实践在「项目」这个级别,即从逻辑上肯定项目与项目之间的关联性,而后把相关联的项目整合在同一个仓库下,一般状况下,咱们不会有太多相互关联的项目,这意味着咱们可以免费获得 monorepo 策略的全部好处,而且能够拒绝支付大型 monorepo 架构的利息。json
本文的剩余篇幅就是对「项目级别 monorepo 实践」的一些总结,即便您最终没有选择 monorepo 策略组织您的代码,相信文章中提供的一些工程化工具或思路也同样会对您产生帮助。bootstrap
Volta 是一个 JavaScript 工具管理器,它可让咱们轻松地在项目中锁定 node,npm 和 yarn 的版本。你只需在安装完 Volta 后,在项目的根目录中执行 volta pin 命令,那么不管您当前使用的 node 或 npm(yarn)版本是什么,volta 都会自动切换为您指定的版本。babel
所以,除了使用 Docker 和显示在文档中声明 node 和 npm(yarn)的版本以外,您就有了另外一个锁定环境的强力工具。
并且相较于 nvm,Volta 还具备一个诱人的特性:当您项目的 CLI 工具与全局 CLI 工具不一致时,Volta 能够作到在项目根目录下自动识别,切换到项目指定的版本,这一切都是由 Volta 默默作到的,开发者没必要关心任何事情。
使用 monorepo 策略后,收益最大的两点是:
这两项好处所有均可以由一个成熟的包管理工具来完成,对前端开发而言,便是 yarn(1.0 以上)或 npm(7.0 以上)经过名为 workspaces 的特性实现的(⚠️ 注意,支持 workspaces 特性的 npm 目前依旧不是 TLS 版本)。
为了实现前面提到的两点收益,您须要在代码中作三件事:
通过修改,您的项目目录看起来应该是这样:
. ├── package.json └── packages/ ├── @mono/project_1/ # 推荐使用 `@<项目名>/<子项目名>` 的方式命名 │ ├── index.js │ └── package.json └── @mono/project_2/ ├── index.js └── package.json
而当您在项目根目录中执行 npm install 或 yarn install 后,您会发如今项目根目录中出现了 node_modules 目录,而且该目录不只拥有全部子项目共用的 npm 包,还包含了咱们的子项目。所以,咱们能够在子项目中经过各类模块引入机制,像引入通常的 npm 模块同样引入其余子项目的代码。
请注意咱们对子项目的命名,统一以 @<repo_name>/ 开头,这是一种社区最佳实践,不只可让用户更容易了解整个应用的架构,也方便您在项目中更快捷的找到所需的子项目。
至此,咱们已经完成了 monorepo 策略的核心部分,实在是很容易不是吗?可是老话说「行百里者半九十」,距离优雅的搭建一个 monorepo 项目,咱们还有一些路要走。
您必定赞成,编写代码要遵循 DRY 原则(Don't Repeat Yourself 的缩写)。那么,理所固然地,咱们应该尽可能避免在多个子项目中放置重复的 eslintrc,tsconfig 等配置文件。幸运的是,Babel,Eslint 和 Typescript 都提供了相应的功能让咱们减小自我重复。
咱们能够在 packages 目录中放置 tsconfig.settting.json 文件,并在文件中定义通用的 ts 配置,而后,在每一个子项目中,咱们能够经过 extends 属性,引入通用配置,并设置 compilerOptions.composite 的值为 true,理想状况下,子项目中的 tsconfig 文件应该仅包含下述内容:
{ "extends": "../tsconfig.setting.json", // 继承 packages 目录下通用配置 "compilerOptions": { "composite": true, // 用于帮助 TypeScript 快速肯定引用工程的输出文件位置 "outDir": "dist", "rootDir": "src" }, "include": ["src"] }
对于 Eslint 配置文件,咱们也能够如法炮制,这样定义子项目的 .eslintrc 文件内容:
{ "extends": "../../.eslintrc", // 注意这里的不一样 "parserOptions": { "project": "tsconfig.json" } }
注意到了吗,对于通用的 eslint 配置,咱们并无将其放置在 packages 目录中,而是放在整个项目的根目录下,这样作是由于一些编辑器插件只会在项目根目录寻找 .eslintrc 文件,所以为了咱们的项目可以保持良好的「开发环境一致性」,请务必将通用配置文件放置在项目的根目录中。
Babel 配置文件合并的方式与 TypeScript 一模一样,甚至更加简单,咱们只需在子项目中的 .babelrc 文件中这样声明便可:
{ "extends": "../.babelrc" }
当一切准备就绪后,咱们的项目目录应该大体呈以下所示的结构:
. ├── package.json ├── .eslintrc └── packages/ │ ├── tsconfig.settings.json │ ├── .babelrc ├── @mono/project_1/ │ ├── index.js │ ├── .eslintrc │ ├── .babelrc │ ├── tsconfig.json │ └── package.json └───@mono/project_2/ ├── index.js ├── .eslintrc ├── .babelrc ├── tsconfig.json └── package.json
在上一步中,咱们尽量的将全部配置文件进行抽象,从而精简了代码,并提升了整个项目的一致性。咱们的整个仓库也所以有了「更浓郁的 monorepo 风味 ☕️」。但若是仔细审视咱们的整个工程文件,还有一处存在着明显的瑕疵和一些恼人的坏味道,当您仔细审视您的众多 package.json 文件时,您就知道我在说什么了 -- scripts 脚本。
若是您的子项目足够多,您可能会发现,每一个 package.json 文件中的 scripts 属性都大同小异,而且一些 scripts 充斥着各类 Linux 语法,例如管道操做符,重定向或目录生成。重复带来低效,复杂则令人难以理解,这都是须要咱们解决的问题。
这里给出的解决方案是,使用 scripty 管理您的脚本命令,简单来讲,scripty 容许您将脚本命令定义在文件中,并在 package.json 文件中直接经过文件名来引用。这使咱们能够实现以下目的:
经过使用 scripty 管理咱们的 monorepo 应用,目录结构看起来将会是这样:
. ├── package.json ├── .eslintrc ├── scirpts/ # 这里存放全部的脚本 │ │ ├── packages/ # 包级别脚本 │ │ │ ├── build.sh │ │ │ └── test.sh │ └───└── workspaces/ # 全局脚本 │ ├── build.sh │ └── test.sh └── packages/ │ ├── tsconfig.settings.json │ ├── .babelrc ├── @mono/project_1/ │ ├── index.js │ ├── .eslintrc │ ├── .babelrc │ ├── tsconfig.json │ └── package.json └── @mono/project_2/ ├── index.js ├── .eslintrc ├── .babelrc ├── tsconfig.json └── package.json
注意,咱们脚本分为两类「package 级别」与「workspace 级别」,而且分别放在两个文件夹内。这样作的好处在于,咱们既能够在项目根目录执行全局脚本,也能够针对单个项目执行特定的脚本。
经过使用 scripty,子项目的 package.json 文件中的 scripts 属性将变得很是精简:
{ ... "scripts": { "test": "scripty", "lint": "scripty", "build": "scripty" }, "scripty": { "path": "../../scripts/packages" // 注意这里咱们指定了 scripty 的路径 }, ... }
大功告成!???? 至此,咱们尽己所能地删除了整个项目中的重复代码,让整个项目变得干净,清爽而且有极强的复用性。
???? 小贴士:
别忘了使用 chmod -R u+x scripts 命令使全部的 shell 脚本具有可执行权限,也千万别忘了把这条贴士写在您的 README.md 文件中!
<p style="text-align: center; color: #999;">图片来源:https://github.com/lerna/lerna</p>
我有时会感慨本身的灵感匮乏,怎么就想不到 Lerna 这样既有神话色彩又能自我释义的好名字。您能够大胆想象,九头龙的每只龙头都在帮您管理着一个子项目,而您只须要骑在龙身上发号施令的场景,这基本上就是咱们使用 Lerna 时的直观感觉。
这也是为何当咱们提起 monorepo 策略,就几乎不得不提到 Lerna 的缘由了,它的确提供了一种很是便捷的方式供咱们管理 monorepo 项目。当子项目越多时,Lerna 就越能显示其威力。
当多个子项目放在一个代码仓库,而且子项目之间又相互依赖时,咱们面临的棘手问题有两个:
经过使用 Lerna,这些棘手的问题都将不复存在。
当在项目根目录使用 npx lerna init 初始化后,咱们的根目录会新增一个 lerna.json 文件,默认内容为:
{ "packages": ["packages/*"], "version": "0.0.0" }
让咱们稍稍改动这个文件,使其变为:
{ "packages": ["packages/*"], "npmClient": "yarn", "version": "independent", "useWorkspaces": true, }
能够注意到,咱们显示声明了咱们的包客户端(npmClient)为 yarn,而且让 Lerna 追踪咱们 workspaces 设置的目录,这样咱们就依旧保留了以前 workspaces 的全部特性(子项目引用和通用包提高)。
除此以外一个有趣的改动在于咱们将 version 属性指定为一个关键字 independent,这将告诉 lerna 应该将每一个子项目的版本号看做是相互独立的。当某个子项目代码更新后,运行 lerna publish 时,Lerna 将监听到代码变化的子项目并以交互式 CLI 方式让开发者决定须要升级的版本号,关联的子项目版本号不会自动升级,反之,当咱们填入固定的版本号时,则任一子项目的代码变更,都会致使全部子项目的版本号基于当前指定的版本号升级。
Lerna 提供了不少 CLI 命令以知足咱们的各类需求,但根据 2/8 法则,您应该首先关注如下这些命令:
# 向 @mono/project2 和 @mono/project3 中添加 @mono/project1 lerna add @mono/project1 '@mono/project{2,3}'
除了上面介绍到的经常使用命令外,Lerna 还提供了一些参数知足咱们更灵活的需求,例如:
看到这里,您可能想要亲自体验一把使用 Lerna 管理/发布 monorepo 项目的感受。但是很快您会发现,将示例代码发布到真实世界的 npm 仓库并不是一个好主意,这多少有些使人沮丧,可是别担忧,您可使用 Verdaccio 在本地建立一个 npm 仓库做为代理,而后尽情体验 Lerna 的种种强大之处。
安装运行 Verdaccio 很是简单,您只需运行:
npm install --global verdaccio
在全局安装 Verdaccio 应用,而后在 shell 中输入:
verdaccio
便可经过 localhost:4837 访问您的本地代理 npm 仓库,别忘了在您的项目根目录建立 .npmrc 文件,并在文件中将 npm 仓库地址改写为您的本地代理地址:
registry="http://localhost:4873/"
大功告成 ????!每当您执行 lerna publish 时,子项目所构建成的 package 将会发布在本地 npm 仓库中,而当您执行 lerna bootstrap 时,Verdaccio 将会放行,让您成功从远程 npm 仓库中拉取相应的代码。
至此,咱们已经掌握了组织一个项目级 monorepo 仓库的全部前沿技巧,最后,让咱们看看最后一个能够优化的地方:代码提交时,约束 commit 信息。
一个 monorepo 仓库可能被不一样的开发者提交不一样子项目的代码,若是没有规范化的 commit 信息,在故障排查或版本回滚时毫无心外会遭遇灾难。所以,千万不要小看 commit 信息格式化的重要性(固然,一样重要的还有代码注释!)。
为了咱们可以一目了然的追踪每次代码变动的信息,咱们使用 commitlint 工具做为格式化 commit 信息的不二之选。
顾名思义,commitlint 能够帮助咱们检查提交的 commit 信息,它强制约束咱们的 commit 信息必须在开头附加指定类型,用于标示本次提交的大体意图,支持的类型关键字有:
我强烈建议您遵循该规范编写您的 commit 信息,不要偷懒,坚持下去,您的 git 日志将会显得整齐,有条理,富有表现力,同时,您也会收到同行的交口称赞,人人都会以和您这样优雅的工程师合做为荣。
除了限定 commit 信息类型外,commitlint 还支持(虽然不是必须的)显示指定咱们本次提交所对应的子项目名称。假如咱们有一个名为 @mono/project1 的子项目,咱们针对该项目提交的 commit 信息能够写为:
git commit -m "feat(project1): add a attractive button" # 注意,咱们省略了 @mono 的项目前缀
毫无疑问,这将会使咱们的 commit 信息更具表现力。
咱们能够经过下面的命令安装 commitlint 以及周边依赖:
npm i -D @commitlint/cli @commitlint/config-conventional @commitlint/config-lerna-scopes commitlint husky lerna-changelog
注意到了吗?我偷偷安装了 husky,它可以帮助咱们在提交 commit 信息时自动运行 commitlint 进行检查,但在这以前,咱们须要再在根目录下的 package.json 文件里加点料,像这样:
{ ... "husky": { "hooks": { "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" } } ... }
为了可以让 commitlint 感知咱们的子项目名称,咱们还需在项目根目录中增长 commitlint.config.js 文件,并设置文件内容为:
module.exports = { extends: [ "@commitlint/config-conventional", "@commitlint/config-lerna-scopes", ], };
至此,咱们统一并规范化了 monorepo 项目的 commit 信息,终于整个 monorepo 工程化的最后一块拼图被咱们拼上了!
(顺便一提,您能够经过在命令行执行 echo "build(project1): change something" | npx commitlint 命令便可验证您的 commit 信息是否经过 commitlint 的检查。)
至此,咱们学会了如何采用 monorepo 策略组织项目代码的最佳实践,或许您已经开始跃跃欲试想要尝试前文提到的种种技巧。从 0 搭建一个 monorepo 项目,固然没问题!但是若是要基于已有的项目,将其转化为一个使用 monorepo 策略的项目呢?
还记得吗?成百里者半九十,您还有一些坑要踩。不过好在您在这里还可以获得个人帮助,没必要客气!
或许您注意到了,Lerna 为咱们提供了 lerna import 命令,用来将咱们已有的包导入到 monorepo 仓库,而且还会保留该仓库的全部 commit 信息。然而实际上,该命令仅支持导入本地项目,而且不支持导入项目的分支和标签 ????。
那么若是咱们想要导入远程仓库,或是要获取某个分支或标签该怎么作呢?答案是使用 tomono,其内容是一个 shell 脚本。
使用 tomono 导入远程仓库,您所须要作的只有两件事:
repo 文件内容示例以下:
// 1. Git仓库地址 2. 子项目名称 3. 迁移后的路径 git@github.com/backend.git @mono/backend packages/backend git@github.com/frontend.git @mono/frontend packages/frontend git@github.com/mobile.git @mono/mobile packages/mobile
至此,咱们也掌握了将现有项目迁移至 monorepo 项目的方法。到这时候,您已绝非再是 monorepo 界的门外汉!
恭喜您 !!????
在本篇文章中,咱们共同了解了「什么是 monorepo 策略」以及「monorepo 策略的优劣」,而且一块儿学习实践了 monorepo 策略的一些最佳实践。您必定也意识到,即便您的工做场景暂时没法实践 monorepo 策略,阅读本篇文章所学习到的种种方法,工具和思想也能够运用到您当下的工做之中。
固然,本文所介绍的这些方法和思想总有过期的一天,而且社区也从未中止对更好地实践 monorepo 策略的探索,说不定您过一阵子就会有更好的想法 ,填补某个领域的空白。但愿到时候您也能总结出一篇文章,为 JavaScript 社区贡献一份力量。到时候请千万别忘了回到个人评论区留言,让我分享您的成就。
关于 monorepo 这个主题,我就暂且带您探索到这里,后会有期:)
阿里巴巴淘系用户增加团队正在如饥似渴的寻找志同道合的伙伴,若是您准备好迎接适度的挑战,在让更多人喜欢手淘的同时,也让本身快速成长,欢迎您发送简历至个人邮箱:kongtang.lb@alibaba-inc.com,我十分期待收到您的讯息。