1. 之前在学习rust的时候,发现cargo中有一个工作空间(workspace)的概念。即有多个二进制library组成一个大的工作空间,我意识到这种模式和JS项目管理的Monorepos很类似,但是我之前也没有对Monorepos有太多的了解,所以专门找[视频](https://www.bilibili.com/video/BV1X34y1674y?p=3&spm_id_from=pageDriver)和[文章](https://nodejs.org/api/modules.html#all-together)学习了一下。

Monorepos介绍

Monorepos是一种项目管理的方式,想象一下我们通常的单体项目管理方式,在项目中我们会以组件的形式来对项目进行分区,比如一下UI组件,网络请求,一些帮助函数,业务逻辑等等,我们只是把他们当作一个项目中的一个部分而已。但是Monorepos的管理方式则认为,UI组件,网络请求这些也都是一个可以单独独立出来的项目,而我们这个整体的大项目是由这一个个小项目组合而成的,这就是Monorepos的基本思考方式,这种思考方式为当前的越来越复杂的项目的管理带来的新的优点,当然它也不是万能的,只是现有场景下的比较好的解决方案,接下来我来介绍其具体实现方式。

1. 创建工作空间-workspaces

本次我们通过yarn来创建工作空间(安装yarn1),当我们用yarn初始化好一个项目之后我们需要在项目中的package.json中配置开启工作空间。在package.json中增加workspaces这个属性,其值是一个数组,我们可以将我们项目中的小项目一个个的写进去,也可以像图片中这种方式将小项目聚集在packages文件夹,然后使用通配符进行选择。
image.png

2. 在workspaces中创建第一个项目

我们在workspaces中创建一个名为@shlack/types的项目,这种取名的方式是不是很眼熟,Babel和vue3的很多库都是以这种方式进行命名的@babel/xxx或者@vue/xxx,这种命名方式就代表了这个包是从属于某一个项目的。types文件夹中有一些ts代码和一些测试代码,只有一个ts解析器的依赖。当我们完成代码之后,在工作目录的根目录运行yarn,这个命令就会把packages中项目的依赖版本的版本号在根目录输出成一个名为yarn.lock的lockfile文件。但ts解析器的依赖是不会出现在工作空间中的package.json这个文件中的。因为ts解析器并不是整个工作空间的依赖,只是其中一个小项目的依赖,但yarn.lock还是会保存在工作空间中的,而ts解析器则会安装到types文件夹中的node_modules文件夹中。
image.png

3. 在工作空间的根目录对每一个项目进行公共配置-composite project

我们在packages中又创建了一个名为util的项目,它也是一个ts项目,所以types和util都有一个tsconfig.json文件。但是我们可以看到其实大部分的配置文件都是重复的,那是否我们可以在工作空间进行一个统一的配置,之后再在每一个项目中进行一个很薄的特异性配置呢? 我们要做的是在packages文件夹中创建一个tsconfig.setting.json文件,随后将项目中的tsconfig.json的公共部分取出来。不过要注意把include属性去掉,因为这个属性是指示源文件位置的属性,但是在Monorepos中每个包都会负责自己的源码位置的指示。
image.png
而我们在每一个项目中的tsconfig文件则就变成了这样很少的一个配置,为了证明构建依然可以执行,我们在types文件夹中运行 yarn build,可以看到项目是依然可以进行构建的,并且多出了一个tsconfig.tsbuildinfo的文件,用来储存ts构建信息。并且通过这个文价在下次更新之后的编译中也只会编译更新的部分。
image.png
我们可以看到在compilerOptions中多了一个属性composite。这个属性就是为了将packages中的项目进行组合。我们在types和utils中都将这个属性设置为true。随后我们在packages文件夹中创建一个tsconfig.json文件,在里面添加如下属性。随后在packages文件夹下运行tsc -d .或者yarn tsc -d .既可以看到types和utils同时进行了打包产生了dist文件夹。这种方式的好处就是,当A包更新了,我们不用对每一个依赖A包的包进行手动的依次更新,直接在最外层文件夹进行构建,其他的包也对修改的包进行了同步。并且他们之间虽然会有依赖关系,但每一个包中的dist文件夹不会包含其他包的内容,他们只是互相引用其dist文件中的内容。
image.png
除了tsconfgi以外。我们还可以通过类似的方法进行babel,eslint等配置。

4. 在工作空间安装依赖

如果一个工具,是所有的packages包都需要的,并且所有的包对这个工具的版本并不敏感,那我们可以把它安装到工作空间,而安装到工作空间的命令也很简单yarn add -WD xxx。所以我们如何决定一个包是安装到工作空间还是内部包中呢,答案就是这个包的功能会不会随着包的版本发生变化。我们以测试工具jest举例,在我们的观念里jest作为一个测试库理应是一个工作空间的库,但在工程上我们还是会将其在每一个内部库中单独安装不同的版本,在实际的工程中我们是不会一股脑的讲一个库在所有的项目都进行升级,因为其中可能会遇到特殊的版本不兼容这种问题。
首先在这之前我们先观察一下内部包的node_modules,以utils为例,里面只有一个名为jest的文件,文件内容的内容并不是其源代码,而是引用。我们再在命令行中进行打印,发现其中的jest链接的就是外部的node_modules, 所以我们可以知道在这种模式下其实只有一个版本的jest,那当我们把types中的jest的版本进行更改,看会发生什么。
image.png
我们可以看到这两个包中jest的版本是不同的,两者的node_module也不同,一个还是像上图的方式对最外部的node_module进行了引用,另一个则是在本地缓存了另一个版本的jest。通过这种方式让在各个包使用的库版本不同的时候能够最大化利用空间。
image.png
接下来我们就要使用上述的命令在工作空间安装一个包,那就是eslint因为所有的包都需要lint来检查代码的正确性,而这个工具本身是不会影响代码的,所以这是一个工作空间级别的库。我们还是像之前配置tsconfig和babelrc一样,在根目录先进行一个通用配置的.eslintrc,之后再在每一个项目中进行一个比较薄的.eslinrc。在运行中还发现了一个eslint的bug,详细请看这里
image.png

5. lerna

lerna是一个Monorepo的管理工具,这个库提供了一种机制,让开发者可以在工作空间外去运行每一个Monorepo内部的包的cli的能力。最简单的就是我们可以在工作空间进行全部包的test lint build等操作。也因如此lerna是一个工作空间的库,lerna在根目录需要一个单独的配置文件。

//lerna.json
{
  "packages": ["packages/*"], //描述你的工作空间的目录,如果你不想管理某些项目或者增加某些项目的时候可以在这里进行配置
  "npmClient": "yarn", //使用的包管理器 也可以是npm
  "version": "0.0.1", //版本控制 另一个值是 independent 当前的这种版本,当工作空间的版本升级了之后,里面所有的小项目都会跟着升级。当值改为independent时允许小项目进行单独的升级
  //可以不跟随工作空间的大版本一起升级,这种方式节省了很多不必要的升级
  "useWorkspaces": true,
  "nohoist": ["parcel-bundler"]
}

5.1 lerna link

如果Monorepo中的小项目之间有依赖关系,我们可以运行lerna link,我们就会看到在小项目中的node_modules中会出现依赖的文件,这其实还是一个版本控制的考虑。因为在最外层的node_modules中其实是存在packages中的项目,这是yarn将两个项目连接在一起,但是lerna会做出不一样的动作。我们可以看到,我们在utils模块添加了对types的依赖关系,运行yarn lerna link之后会在utils的node_modules中出现了types的代码。当types的代码升级之后,如果我们不重新运行link,那么utils依然会对之前的版本的types的依赖,因为代码存到了项目的本地中。同时添加link之后,我们在构建的时候lerna会选择最底层的依赖先进性构建,不断的向上。这样构建的好处显而易见。image.png

5.2 lerna run

lerna run允许同时运行多个包的script命令,我们看到下图我们运行了yarn lerna run build,这条命令会运行types包和utils包中的build命令。因为我们在utils中添加了types的依赖,所以我们看到是先进性build types包,再build utils包,是一个串行的程序。
image.png
随后我们把link去掉,再运行一次build命了,就可以看到这个是并行进行了构建。
image.png

5.3 其他命令

image.png
推荐 着重学习 run exec link bootstrap add这几个命令。
lerna exec 'mkdir etc'packages中每一个项目都会运行mkdir etc这个命名,所以每一个项目都会多一个etc文件夹。

6. scripty

在上一章中我们看到lerna可以运行每个项目中的script。但是我们看到在script中我们的脚本都是以字符串的形式,这种形式当内容一长了就会很容易出错,没有语法提示,没有lint所以我们可以通过scripty这个包来解决。
首先我们要在根目录新建一个scripts文件夹,在里面放入我们的sh脚本文件。我们的脚本分为两种一种是packages的一种workspace的。这两种方式都差不多,只不过应用的层级不同,这里先展示packages的,首先我们修改每一个项目的package.json里面的script属性,把每一个对应的值都换成scripty,在文件中在添加scripty的目标文件目录。这样的好处就是,当项目增加的时候,我们可以对脚本进行统一管理,修改等操作,并且减少了犯错的成本。(mac用户在运行之前要对脚本文件进行chmod +x操作)随后运行yarn lerna run test,我们可以看到这个脚本的运行,使用版本等所有状态都是相同的。同时,如果某些项目有一些特殊的需求,则可以维持原有的脚本形式。workspace的配置方式和packages的是相同的。image.png

7. Changelog

在公司中我们在git上提交代码都是需要一套规范的,并且通过commitlint进行限制,那么Monorepo也不例外,它也有一套自己的规范,并且在版本升级的时候还有更加明确的信息。首先我们要安装一些依赖,并且做一些提交规范的信息配置
yarn add -WD @commitlint/cli @commitlint/config-conventional @commitlint/config-lerna-scopes commitlint husky lerna-changelog
现在当我们进行代码提交的时候就需要明确自己更改的具体项目是那个,如果所示,我没有指明我具体更改的包,就不会提交成功,并且还会提示我有哪些包明是可选的。所以 我们需要在api的位置进行修改,才能提交成功。
image.png
我们进行了一个utils的修改提交。当项目的版本需要更新的时候 我们运行yarn lerna version会弹出选项,选择之后自动对你的项目的版本进行更新,我们看到所有的小项目都进行了更新,并且在每一个小项目中还会出现一个CHANGELOG.md文件对之前的提交进行汇总,可以说是很方便了。
image.png

8. Publish

我们现在可以把这个项目发布到npm上,但是这样做会污染npm,所以我们这里使用一个本地npm,叫做verdaccio,它可以在本地起一个类似于npm的包管理网站,随后在你的项目中在.npmrc中写registry="http://localhost:4873/",我之前实习的时候看到公司用的就是这个。我们在我们的项目中运行lerna exec 'yarn publish'输入每一个小项目的版本号,就可以把项目发布出去了。
image.png

9. 添加项目之间的依赖关系

我们在packages中添加两个项目data和ui。他们之间的依赖关系是这样的types,和util都是data和ui的依赖,而且data是ui的依赖。我们虽然可以之间引用,但是package.json中是没有体现的。如果我们想再package.json中体现,需要运行如下命令yarn lerna add @shlack/types --scope '@shlack/ui'就可以将依赖关系添加到package.json中了。随后再运行yarn使版本进行锁定,这样就会在高级的包中的node_modules中找到依赖的低级包的本地缓存。这样做的优点就是这个上级包不会随着低级包的更新而发生潜在的bug,缺点就是占用的内存会增多。

10. 项目API文档的建立

当一个Monorepo项目里面的内容多起来之后,API文档就必不可少,因为当很多人合作开发的时候要用其他包里的代码,只需要看一下API文档就可以了。而不需要在去找开发这个包的人去问。在工程上我们一般都是自动导出文档的。在Monorepo项目中我们也可以通过自动导出文档的方式来管理项目。首先需要下载几个包@microsoft/api-extractor``@microsoft/api-documenter。首先运行yarn api-extractor init在根目录就会出现一个名为api-extractor.json的配置文件,由于我们在每个项目中还有一些单独的配置,所以这个配置文件也需要像上文tsconfig.json一样,在根目录进行宏观配置,在项目目录进行微观配置。首先我们要在公共配置文件中打开几个配置。docModel(配置产生文件的路径) dtsRollup(配置API的公有或私有属性),随后在每一个单独的项目中配置如下文件。
image.png
随后像之前一样,在每一个项目中配置一个名为api-report的script脚本,再在script文件夹中添加相应的文件。随后运行yarn lerna exect 'mkdir etc'在每一个项目中新建一个etc文件夹后,运行yarn lerna run api-report。我们就会发现每一个项目的etc文件会出现一个api的报告。并且在最外层temp问价中出现api的json描述文件。

//api-report.sh
#!/usr/bin/env bash
echo "┏━━━ 🧩 API REPORT: $(pwd) ━━━━━━━━━━━━━━━━━━━━━"
yarn api-extractor run --local

image.png
而当团队中有人对API的参数类型或者API的返回值进行了修改,需要再次运行yarn lerna exec 'yarn lerna run build && yarn api-extractor run',这时就会自动对前后构建的报告进行比对,然后找出修改的地方,并且提示开发者如何操作才能解决报错,一般都是对文档进行修改。
image.png
最后就是创建文档。把每一个项目的API报告汇集在一起形成一个web文档。首先我们在script/workspace文件夹下添加api-docs.sh文件。该命令会重新构建项目,并提取API,随后根据github的主题来创建文档。运行后根目录会有一个文档文件夹。里面是所有文档链接在一起,并且有一个index.md的入口。

#!/usr/bin/env bash
echo "┏━━━ 📚 API DOCS: Extracting API surface ━━━━━━━━━━━━━━"
yarn clean
yarn tsc -b packages
yarn lerna run api-report;
echo "┏━━━ 📝 API DOCS: Generating Markdown Docs ━━━━━━━━━━━━"
GH_PAGES_CFG_EXISTS=$(test -f docs/_config.yml)
if [ $GH_PAGES_CFG_EXISTS ]
then
  echo "GitHub pages config file DETECTED"
  cp docs/_config.yml .
fi

yarn api-documenter markdown -i temp -o docs

if [ $GH_PAGES_CFG_EXISTS ]
then
  cp _config.yml docs/_config.yml
fi

image.png
把项目提交到github上面之后,我们就可以使用github的模版来浏览整个项目的API信息了。
image.png
此外我们还可以通过注释,手动的将不同的包的类型进行链接。这样在网页中就会在getTeamByid这个函数上就会有在 iTeam类型上就会有一个锚点指向iTeam的类型文档。

/**
 * Get a {@link @shlack/types#ITeam} by id //手动链接@shlack/types包里的iTeam类型
 * @param id 
 * @returns 
 */
export async function getTeamById(id: string): Promise<ITeam> {
  let cached = cachedTeamRecords[id];
  if (typeof cached === "undefined")
    cached = cachedTeamRecords[id] = apiCall(`teams/${id}`).then(
      (rawData: unknown) => {
        if (isTeam(rawData)) return rawData;
        throw new Error(
          `Unexpected value for team\n${JSON.stringify(rawData)}`
        );
      }
    );
  return await cached;
}

结束

这里面我们学习了很多,这些内容很适合新手对于工程化的理解。或许我们也可以写一个居于Monorepo的cli工具,具有完善的功能。