Monorepo-多包单仓库的开发模式

15,973 阅读9分钟

1、引言

What Is a Monorepo?: Monorepo is a unified source code repository used by an organisation to host as much of its code as possible.

Monorepo是一种管理团队代码的方式,它摒弃了一个模块一个仓库的方式,而是尽可能地将所有的模块放在一个仓库进行管理。

在团队开发过程中,我们通常会为不同的项目建立多个仓库进行管理,但一旦各个项目间存在相互引用关系,我们每次修改就会产生很大的麻烦,我们以一个Package A包和它的依赖UI Package为例,它们的开发流应该是这样的:

mutilrepo的开发流

当然,在这一过程中还涉及到仓库权限、publish权限的管理,如果我们的Tom同学想测试Jerry同学正在开发中的UI Package是否符合要求,他必须得拿到一个测试包,npm install到本地才能看到这个效果;如果他还想边测试边console.log看看效果,他只能麻烦Jerry同学在代码里加一下重新发个包。

那么如果我们使用Monorepo模式开发,会有怎样的效果:

monorepo开发流

我们的Tom同学只要把Jerry同学正在开发的分支拉到本地,自己想怎么写就怎么写。

在开始使用前,先听听缺点

  • 随着项目的迭代,在Monorepo中每个package都会十分庞大,对构建工具是个不小的挑战。(目前已有的方案:例如 Google 的 Bazel, Facebook 的 Buck 和 Twitter 的 Pants,但它们都没有很好的支持JS打包)
  • 由于项目之间相互依赖,你必须时刻保证良好的代码结构,编译规范以及测试用例。
  • 除了对构建工具的挑战外,当项目达到一定规模时,IDE可能会面临崩溃。

当然,诸如BabelReactVue-next等等著名的开源项目都在使用Monorepo的方式进行源码管理,此时不上车更待何时。

Babel团队给出了他们的看法: Why is Babel a monorepo?

2、lerna & yarn workspace —— 成熟的Menorepo管理方案

2-1、“芜湖,起飞”——项目初始化

首先让我们使用lerna来初始化项目:

// Step 1
npm install -g lerna

// Step 2
git init learn-lerna

// Step 3
cd learn-lerna && lerna init

运行完之后,我们就看到在learn-lerna/文件夹下会生成如下结构:

其中lerna.jsonlerna的配置文件,packages/为项目的存储文件夹。基于对之后自动化构建的规划,我们在根目录下新建一个examples的文件夹,至于这样做的原因及好处,让我们慢慢往下看。

打开lerna.json,添加一些配置:

{
  // 定义各个项目存放的位置,这里我们新增一个examples/
	"packages": ["packages/*", "examples/*"],
  // 当前的版本号
  "version": "0.0.0",  
  /* 以下为新增 */
  // 执行命令的client,默认为npm,这里我们需要配置为yarn
	"npmClient": "yarn",
  // 是否使用workspace工作模式
	"useWorkspaces": true
}

对于lerna而言,它的主要功能是版本控制发布,因此它还需要配合npmyarngit一同使用。同时,lerna还支持固定模式(fixed/locked)以及独立模式(independent):

  • 固定模式(默认):所有的包共享一个版本号,在每次发布之后会将发布情况记录在根目录下的changelog
  • 独立模式:每个包各自管理版本

由于UI PackageUtils Package一般为独立发版的,所以我们需要手动将项目调整为独立模式(将配置中的version改成independent即可),如果想了解更多这两个模式的区别,可以参考lerna的官方文档固定模式和独立模式区别

尽管lerna也可以进行一些依赖的安装(其实其安装功能会交由yarnnpm处理),但由于它并不能执行构建、测试等任务,因此我们更希望它专注于版本控制与发布,并结合npmyarn来执行其他任务

接下来打开package.json,配置我们的工作区:

{
  "name": "root",
  "private": true,
  "devDependencies": {
    "lerna": "^3.22.0"
  },
  /*新增,定义工作区*/
  "workspaces": [
    "packages/*",
    "examples/*"
  ]
}

对于yarn而言,它除了作为包管理工具之外,在大型项目的依赖安装时间上会比npm更快,同时yarn在原生程度上就支持Monorepo的包管理模式,这也让我们能更流畅的进行依赖的安装(无论是本地还是远程)。

Monorepo工具的功能交集:红色部分的`yarn workspace`和`lerna`都可以作为`Monorepo`的管理工具,但他们都依赖`yarn`或`npm`

综上所述,我们推荐使用lerna作为版本发布与控制的管理工具,使用yarn+yarn workspace进行包管理、构建与测试工具,关于这一选择更多的好处,可以参见Why Lerna and Yarn Workspaces is a Perfect Match for Building Mono-Repos?

2-2、“上下左右BABA”——多包依赖的建立

接下来我们在packegs/中初始化ui以及utils项目,在examples/中初始化blog项目:

目录结构

然后就该我们的yarn workspace登场了,为各个包添加依赖:

  • 添加外部依赖

    // 为 blog 添加 react、react-dom 依赖
    yarn workspace blog add react react-dom --save
    
  • 添加本地依赖

    需要注意的是,在添加本地依赖时,我们需要在包后添加版本号,否则yarn会搜索远程注册表,而不是搜索工作区的包

    // 为 blog 添加 本地依赖,@zg/ui 为包名
    yarn workspace blog add @zg/ui@1.0.0 --save
    
  • 添加全局依赖

    // 为全局添加 babel dev依赖
    yarn add -W babel --dev
    

删除操作,只需要将add改为remove即可。

经过这些操作后,我们可以发现所有的依赖并没有分别安装在各个项目中,而是将node_modules统一放在根目录下管理,有效避免了依赖重复安装的问题;而我们在本地的依赖则是通过符号链接对应到packages/中的各个项目:

多包依赖

2-3、“将进酒,杯莫停”——构建工具的加入

构建与编译

对于babel的安装与配置,目前网上已经有各种成熟的配置,这里就不再赘述。而对于Monorepo项目而言,我们其实可以利用babeloverrides属性为每个项目进行个性化的配置:

// .babelrc
{
  "presets": [
    "@babel/preset-env",
    "@babel/preset-react"
  ],
  // 为每个项目进行个性化配置
  "overrides": [
    {
      "test": ["packages/ui"],
      "plugins": [
        ["import", {
          "libraryName": "antd",
        	"libraryDirectory": "es",
        	"style": "css"
        }]
      ]
    },
    {
      "test": ["packages/utils"],
      "plugins": [
        ["module-resolver", {
        	"alias": { "~": "./src/scripts" }
      	}]
      ]
    }
  ]
}

在前面我们卖了个关子,建立了两个工作区packages/examples/。相信有同学已经看出了端倪,我们在packages/中存放的是uiuitls,都是作为静态库进行输出的,而在examples/中,我们则是一个SPA应用blog。由于它们被设计存放在不同的工作区,我们可以针对这一分离利用不同工具进行构建。针对SPA应用,我们需要能支持热模块替换(HMR)的工具来协助我们开发,因此我们可以采用webpack进行打包;而对于静态库的开发,我们选择打包速度更快、压缩体积更小的rollupjs进行构建。

关于更多rollupwebpack的区别,可以戳一戳这个链接:Rollup:下一代ES模块打包工具

对于rollupjswebpack的配置,这里也不在赘述,大家可以自行google学习。(不过rollupjs的打包分析比webpack好看多了,更为直观清晰)

Rollup的构建分析

而对于脚本的书写位置,lernayarn workspaces给出的建议是存放在根目录的scripts/文件夹中:

脚本的存放位置,统一在根目录下管理

2-4、“Mission Complete”——版本更新与发布

  • 版本更新——lerna version

    lerna version 可以轻松的帮助我们管理packages/中各个包的版本提交记录,同时会在每个包目录下自动生成changelog

    首先我们要做的是按照常规提交规范进行git commit,当然这里更推荐使用git cz进行格式化提交,提交完成之后执行如下命令:

    // 根据commit提交信息自动生成package版本
    lerna version --conventional-commits
    

    接下来,lerna会根据我们的提交记录,按照如下规则进行转换:

    提交类型 对应版本号转换(版本号为MAJOR.MINOR.PATCH
    fix PATCH发行版 + 1
    feat MINOR发行版 + 1
    BREAKING CHANGE 无论什么类型,都会转换为MAJOR发行版 + 1

    lerna vserion

    需要注意的是:

    1. 对于lerna而言,不论是设定独立模式(independent)还是固定模式(fixed/locked),当某个包发生变化时,其他依赖该包的package都会更新其version。两种模式的不同点在于其MAJOR发行版的号码是否统一。

    2. BREAKING CHANGE需要书写在commitbody或者footer的开头部分中,当你执行git commit时,应该按照下面的格式:

      <type>(<scope>): <subject>
      // 空一行
      <body>
      // 空一行
      <footer>
      

      如果使用git cz提交,可以按照提示自动选择是否有BREAKING CHANGE

  • 发布——lerna publish

    经过了上面的操作,我们已经把修改推送到了远程仓库中,终于,到了这激动人心的时刻。

    当然,版权意识不能少,我们需要先选择一个开源许可证,并将对应的LICENSE安装在项目的根目录下。

    然后,如果你发布的scope package,即@xxx/some-package格式的包,需要在每个包的package.json中加入publishConfig

    {
      "name": "@xxx/some-package",
      "version": "2.0.0",
      "main": "index.js",
      "license": "MIT",
      /*以下为新增*/
      "publishConfig": {
        //指定范围包的访问权限 不设置public,就付钱升级npm账号吧!
    		"access": "public",
    		//指定发布的目录 可以配合.npmignore
    		"directory": ""
    	}
    }
    

    最后的最后,需要先使用npm login进行登录,或者用npm whoami判断自己是否登录成功,如果没有问题的话,请庄严的敲下如下的命令:

    lerna publish from-package
    

    注意:如果你使用了scope,请务必保证你的组织(organizations)已经在NPM中注册,否则你可能会遇到如下的403错误:

    lerna publish error

    如果注册好了,让我们再执行一下publish指令:

    版本发布

    这样我们就成功将工作区内的所有包发布到NPM上啦。

    大功告成!🎇🎉🎈🎈🎈🎉🎇

3、总结

Monorepo的概念确实为我们的开发提供了一种新的思路,当优劣所得,还需各位开发者大大斟酌损益。其实对于本文一开始的例子,也可以在Multirepo的基础上通过npm-link来解决,只不过不像lerna + yarn workspaces这样优雅,既有流畅的构建调试流程,又有自动化的changelog与版本更新发布。