JavaScript和TypeScript中的单体

228 阅读23分钟

这是一个关于JavaScript/TypeScript中的Monorepos的综合教程------在前端应用中使用最先进的工具来实现这种架构。你将从本教程中了解到以下内容:

  • 什么是monorepo?
  • 如何构建一个monorepo架构?
  • 如何创建一个单例程序?
  • 哪些工具可用于monorepo?
  • 如何在monorepo中执行版本管理?
  • 如何为monorepo创建一个CI管道?
  • 如何运行与monorepo脱钩的应用程序?

我自己最近非常热衷于monorepo,因为它们有助于我作为自由开发者的工作和我的开源项目。当我开始在JavaScript/TypeScript中使用monorepos时,我必须说以这种方式组合应用程序和包感觉很自然。通过这个演练,我希望能把我在这个领域的经验传授给大家。

Lee Robinson致敬,他为我在这个领域的起步提供了巨大的灵感和帮助。另外还要感谢Shawn "Swyx" Wang,他让我知道了Turborepo,以及Wes BosScott Tolinski,他们在SyntaxFM上有一集关于Monorepos的节目

当本教程变得更加实用时,我们将使用React.js作为创建应用程序和共享包(UI组件)的框架选择,在这个monorepo中。然而,你也可以自由地使用你自己的框架(例如Angular或Vue)。

什么是Monorepo

单一项目是一个包含更小的项目的项目--而每个项目可以是任何东西,从单独的应用程序到可重用的包(如函数、组件)。合并项目的做法可以追溯到2000年初,当时它被称为共享代码库

monorepo这个名字源于mono(单一)和repo(仓库)这两个词。前者是不言自明的,后者来自于版本控制系统(如git),其中项目:仓库以n:n的关系(polyrepo)或n:1的关系(monorepo)托管。

通常,单体库被误认为是单体库。然而,在一个单体应用中,所有的小项目都被合并成一个大项目。相比之下,单体项目可以把它的小项目合并成多个项目。

monorepo monolith

单片机在谷歌等大公司使用的大规模代码库中很受欢迎。

  • "谷歌代码库包括大约10亿个文件,有大约3500万次提交的历史,横跨谷歌整个18年的存在。"[2016]
  • "谷歌的代码库是由来自世界各国几十个办公室的25000多名谷歌软件开发人员共享的。在一个典型的工作日里,他们向代码库提交了16,000项变更,另外24,000项变更由自动系统提交。"[2016]

然而,这些天来,monorepos成为流行的任何代码库,其中有多个应用程序与一组共享的(内部)包 ...

为什么使用Monorepo

对于一个大规模的代码库来说,使用monorepo有两大优势。首先,共享包可以在本地机器上的多个应用程序中使用,而不需要在线注册表(如npm)。开发者的经验在这里得到了极大的改善,因为所有东西都在同一个代码库中,不需要通过第三方更新依赖关系。当一个共享包被更新时,它会立即反映在所有依赖它的应用程序中。

第二,它改善了跨代码库的协作。在不同项目上工作的团队可以改进其他团队的代码库,而不需要在多个存储库上工作。它还提高了可访问性,而不必担心不同的设置,并引入了一个更灵活的跨团队的源代码所有权。另一个好处是在许多项目中对代码进行重构。

单子库的结构

一个monorepo可以包含多个应用程序(这里指的是:应用程序),而每个应用程序都可以访问共享的软件包集。请记住,这已经是一个有主见的monorepo结构。

- apps/--- app-one--- app-two- packages/--- package-one--- package-two--- package-three

一个包,只是一个文件夹,可以是任何东西,从UI组件(如框架特定的组件)到功能(如实用程序)再到配置(如ESLint,TypeScript)。

- apps/--- app-one--- app-two- packages/--- ui--- utilities--- eslint-config--- ts-config

一个包可以是另一个包的附属品。例如,ui包可以使用utilities包的函数,因此ui包依赖于utilities包。uiutilities包都可能使用其他**-config*包的配置。

这些应用程序通常不会相互依赖,相反,它们只是选择了。如果包之间相互依赖,monorepo管道(见Monorepo工具)可以强制执行 "只有在utilities构建成功后才开始ui构建 "这样的场景。

monorepos pipeline

因为我们在这里说的是一个JavaScript/TypeScript monorepo,一个应用程序可以是一个JavaScript或TypeScript应用程序,而只有TypeScript应用程序会使用共享的ts-config包(或创建他们自己的配置或使用两者的混合)。

应用程序中的应用根本不需要使用共享。这是一种选择,他们可以选择使用他们内部的UI组件、函数和配置的实现。然而,如果应用中的应用程序决定使用包中的一个包作为依赖,他们必须在他们的package.json文件中定义它。

{  "dependencies": {    "ui": "*",    "utilities": "*",    "eslint-config": "*"  },}

应用中的应用程序是他们自己的实体,因此可以是任何东西,从SSR应用程序(如Next.js)到CSR应用程序(如CRA/Vite)。

继续阅读。网络应用程序101

换句话说:应用程序中的应用不知道自己是monorepo中的repo,它们只是定义了依赖关系。monorepo(见monorepos中的工作空间)决定了依赖是来自monorepo(默认)还是来自注册表(backback,例如npm注册表)。

反过来说,这意味着一个应用程序也可以在不属于monorepo的情况下使用。唯一的要求是它的所有依赖项(这里:uiutilitieseslint-config)都发布在npm这样的注册表上,因为当作为一个独立的应用程序使用时,就不再有共享依赖项的monorepo了(见monorepos的版本管理)。

如何创建一个Monorepo

在所有这些关于monorepos的理论学习之后,我们将通过一个monorepo的例子作为概念证明。因此,我们将用React应用*(app*)创建一个单体,这些应用使用一组共享的组件/配置*(包*)。然而,没有一个工具是与React绑定的,所以你可以把它改编成你自己选择的框架(例如Angular或Vue)。

不过我们不会从头开始创建一个monorepo,因为这将涉及到太多的步骤,会使整个主题难以理解。相反,我们将使用一个初始单体项目。在使用它的时候,我将一步一步地指导你完成所有的实施细节。

首先,将monorepo启动程序克隆到你的本地机器上。

git clone git@github.com:bigstair-monorepo/monorepo.git

我们在这里使用yarn作为npm的替代品,不仅是为了安装依赖,也是为了以后使用所谓的工作空间。在下一节(见Monorepos中的工作空间),你将了解工作空间以及与yarn工作空间相对应的其他工作空间工具。现在,导航到软件仓库,用yarn安装所有的依赖项。

cd monorepoyarn install

虽然稍后会解释其他部分,但我们现在将专注于monorepo的以下内容。

- apps/--- docs- packages/--- bigstair-core--- bigstair-map--- eslint-config-bigstair--- ts-config-bigstair

monorepo带有一个 "内置 "的应用程序,名为docs,用于文档。以后我们会在文档旁边集成实际的应用程序(见monorepos中的工作空间)。

此外,还有四个--而两个包是共享的UI组件(这里:bigstair-corebigstair - map),两个包是共享配置(这里:eslint-config-bigstairts-config-bigstair)。

我们在这里处理的是一个叫做bigstair的假公司,这在后面会变得很重要(见Monorepos的版本管理)。现在,只要想一想bigstair的命名,这可能会使它更容易接近。此外,我们不会把重点放在ESLint和TypeScript的配置上。你可以在后面看看它们是如何在应用程序中被重用的,但对我们来说重要的是实际的应用程序和实际的共享包。

- apps/--- docs- packages/--- core--- map

对于这两个,想象一下任何应该在我们的应用程序中消耗的JavaScript/TypeScript代码。例如,核心包可以有像按钮、下拉菜单和对话框这样的基础UI组件,而地图包可以有一个可重复使用但更复杂的地图组件。从应用程序目录的角度来看,这些独立的包就像解决不同问题的库。毕竟,这只表明包文件夹可以像应用程序文件夹一样垂直扩展。


作为本节的结束,运行以下命令来运行app/docs应用程序。我们将在后面讨论(见Monorepo工具)为什么这个命令允许我们首先在apps文件夹中启动一个嵌套的应用程序。

yarn dev

你应该看到一个显示核心包和地图包的组件的故事书。在这种情况下,为了简单起见,这些组件只是按钮(而不是地图)。如果你检查核心包和地图包的源代码,你应该能找到这些组件的实现。

import * as React from 'react';
export interface ButtonProps {  children: React.ReactNode;}
export function Button(props: ButtonProps) {  return <button>{props.children}</button>;}
Button.displayName = 'Button';

此外,这两个包的package.json文件都定义了一个name 属性,在docs应用程序的package.json中被定义为依赖项。

"dependencies": {  "@bigstair/core": "*",  "@bigstair/map": "*",  "react": "18.0.0",  "react-dom": "18.0.0"},

如果这两个包都可以通过npm注册表获得,那么docs应用程序就可以从那里安装它。然而,如前所述,由于我们是在一个带有工作空间的monorepo设置中工作(见monorepos中的工作空间),docs应用程序的package.json文件首先检查这些包是否存在于monorepo中,然后再使用npm注册表作为退路。

最后,检查docs应用程序的实现细节。在那里你会看到,它导入了像第三方库一样的包,尽管它们是monorepo中的包。

import { Button } from '@bigstair/core';

这再次证明了这样一个事实:应用程序中的应用并不知道它在monorepo中扮演了一个角色(见孵化)。如果它不在monorepo中(见孵化),它就会直接从npm注册表中安装依赖项。

单元库中的工作空间

在我们的例子中,monorepo由多个应用程序/包组成,它们一起工作。在后台,一个叫做workspaces的工具使我们能够创建一个文件夹结构,应用程序可以使用作为依赖。在我们的案例中,我们使用yarn workspaces来完成我们的目标。也有一些替代品,如npm workspacespnpm workspaces

一个yarn工作空间在顶层package.json文件中的定义是这样的。

"workspaces": [  "packages/*",  "apps/*"],

由于我们已经预计到我们有多个应用程序,我们可以直接指向文件夹路径,并使用通配符作为子路径。这样一来,app/packages中的每一个文件夹,只要有package.json文件就会被选中。现在,如果应用程序想从包中包含一个包,它只需在自己的package. json文件中使用该包的name 属性作为依赖(正如我们之前看到的)。请注意,在这一点上,拥有应用程序的结构已经有了意见。


在实践中,它是关于多个应用程序可以选择本地作为依赖关系。然而,到目前为止,我们只使用了docs应用程序,它使用了我们monorepo的。此外,docs应用程序只是用来编写这些包的文档。我们想要的是使用这些共享包的实际应用。

monorepos workspaces

导航到apps文件夹中,我们将克隆两个新的应用程序到monorepo中。之后,再导航回来,安装所有新的依赖项。

cd appsgit clone git@github.com:bigstair-monorepo/app-vite-js.gitgit clone git@github.com:bigstair-monorepo/app-vite-ts.gitcd ..yarn install

这里需要安装所有的依赖项,因为有两件事。

  • 首先,应用程序中的新应用程序需要安装它们的所有依赖项--包括它们定义为依赖项的软件包
  • 第二,随着两个新的嵌套工作区的加入,应用程序软件包之间可能存在新的依赖关系,需要解决这些问题,以便让所有工作区一起工作。

现在,当你用yarn dev 启动所有的应用程序时,你应该看到故事书出现了,此外还有两个新的React应用程序,它们使用软件包中的Button组件。


两个克隆的应用程序都是用Vite引导的React应用程序。最初的模板唯一的变化是它在package.json中的依赖关系,它将我们工作区的定义为第三方。

"dependencies": {  "@bigstair/core": "*",  "@bigstair/map": "*",  ...}

之后,他们就像我们之前在文档中做的一样,使用共享组件。

import { Button } from '@bigstair/core';

因为我们是在一个monorepo设置中工作的,更具体地说,是在工作区设置中工作的,它首先实现了项目(这里:应用程序)之间的这种联系,这些依赖在从npm这样的注册表安装它们之前从工作区查找。

monorepos dependencies

正如你所看到的,任何JavaScript或TypeScript应用程序都可以通过这种方式在apps文件夹中启动。继续创建你自己的应用程序,将软件包定义为依赖项,yarn install ,并使用软件包工作区的共享组件。


在这一点上,你已经看到了顶层目录中的全局package.json文件以及appspackage中每个项目的本地package.json文件。顶层的package.json文件除了定义工作空间外,还定义了全局的依赖关系(如eslint、prettier),可以在每个嵌套的工作空间中使用。相比之下,嵌套的package.json文件只定义实际项目中需要的依赖项。

Monorepo工具

你已经见证了工作空间是如何让我们创建一个单一的po结构的。然而,虽然工作空间使开发人员能够将单版本中的项目相互连接起来,但专门的单版本工具会带来更好的开发体验。在打字的时候,你已经看到了其中一个DX的改进。

yarn dev

从顶层文件夹中执行这个命令,可以启动monorepo中所有的项目,这些项目在其package.json 文件中都有一个dev 脚本。其他几个命令也是如此。

yarn lintyarn buildyarn clean

如果你检查顶层的package.json文件,你会看到一堆总体性的脚本。

"scripts": {  "dev": "turbo run dev",  "lint": "turbo run lint",  "build": "turbo run build",  "clean": "turbo run clean",  ...},"devDependencies": {  ...  "turbo": "latest"}

一个叫做Turborepo的monorepo工具允许我们定义这些脚本。替代的monorepo工具有LernaNx。Turborepo有几种配置,允许你并行(默认)、按顺序或过滤地执行其嵌套工作区的脚本。

"scripts": {  "dev": "turbo run dev --filter=\"docs\"",  ...},

此外,你可以创建一个turbo.json文件(自己打开),为所有的脚本定义一个monorepo管道。例如,如果一个包在工作区有另一个包作为依赖,那么可以在构建脚本的管道中定义,前一个包必须等待后一个包的构建。

monorepos pipeline

最后但同样重要的是,Turborepo具有先进的文件缓存功能,可以在本地(默认)和远程工作。你可以在任何时候选择退出本地缓存。你可以在这里查看Turborepo的文档,因为本攻略在这里没有更多细节。

单孢子的文档

因为许多monorepos的应用程序都会访问一组共享的软件包,所以有一个专门用于文档的应用程序也能访问这些软件包,这已经是一个完美的架构。

monorepos documentation

我们最初设置的monorepo已经有了一个docs应用程序,它使用Storybook来记录所有包的UI组件。然而,如果共享包不是UI组件,你可能希望有其他工具来记录概念、用法或API。

从这个 "最小的monorepo架构"(它带有共享包、共享包的文档,以及通过在文档中重复使用包来证明monorepo架构的工作原理),人们可以通过添加更多的应用程序或包来扩展这个结构,正如我们在Monorepos的工作空间部分所做的那样。

单孢子系统与Git中的多孢子系统

如果没有什么反对意见的话,我们可以在一个Git仓库中托管一个单库和它的所有工作空间。毕竟这是对单项目的主要定义。然而,一旦一个单例规模扩大到多个工作空间,也许(!)有必要(见例子:单例作为孵化器)将单例分离成多个 Git 仓库。这就是我们在单子库演练中对应用程序(除了文档)所做的。

可能有很多方法可以将一个单版本的Git仓库转移到多个Git仓库中--本质上是创建一个伪装成单版本的多版本。在我们的例子中,我们只是使用了一个顶层的*.gitignore文件,它忽略了两个嵌套的工作空间,这些应用*应该有自己的专用Git仓库。

monorepos git

然而,这样一来,我们总是在所有工作空间(这里指:应用程序)的最新版本上工作,因为当把所有嵌套的工作空间克隆到monorepo或作为独立的应用程序时,它们只是使用最新的代码。接下来我们在考虑版本问题时,会绕过这个缺陷。

使用单体的版本管理

应用版本,尤其是对单版本中的共享,最终可能会在包管理器(如npm注册表)中上线,并不像预期的那样简单。有多种挑战,比如可以相互依赖,有不止一个包需要关注,包是包中的嵌套文件夹,每个包都要有自己的更新日志和发布过程。

在monorepo设置中,软件包表现得像依赖关系,因为应用程序从工作区设置中使用它们(而不是注册表)。然而,如果一个应用程序不想在工作空间中使用某个包的最新版本,它可以为其定义一个更具体的版本。

"dependencies": {  "@bigstair/core": "1.0.0",  "@bigstair/map": "1.0.0",  ...}

在这种情况下,如果工作区中的包的版本与指定的版本不同,安装脚本将不会使用工作区的包,而是使用注册表。因此,我们需要一种方法,在开发monorepo的同时为软件包创建版本、更新日志和发行版。


changesets项目是一个流行的工具,用于管理多包仓库(如monorepo)中多个包的版本。我们的monorepo设置已经配备了在顶层package.json文件中定义的改变集和脚本的安装。我们将逐步完成这些变化集脚本的工作。

"scripts": {  ...  "changeset-create": "changeset",  "changeset-apply": "changeset version",  "release": "turbo run build && changeset publish"},

版本化包将包括将它们发布到一个注册表(例如npm)。如果你想跟着走,你需要执行以下步骤作为前提。

  • npm上创建一个允许你发布软件包的组织
  • 在命令行上登录npm
  • 在源代码中到处使用你的组织名称而不是bigstair
  • yarn install && yarn dev 来验证一切都按预期工作。

在我们对一个包进行版本控制之前,还有一个前提条件。我们需要先改变我们的一个包。进入其中一个UI包,修改组件的源代码。之后,我们的任务是让这个变化反映在新的版本中,并发布到npm。

monorepos versioning

  • 首先,运行yarn changeset-create ,它使你能够为已更改的包创建一个变化日志。提示会引导你选择一个包(使用空格键),选择semver增量(major、minor、patch),并编写实际的变更日志。如果你事后用git status 检查你的版本库,除了新创建的更新日志文件外,你还会看到改变的源代码。如果软件包之间相互依赖,链接的软件包也会在之后得到一个版本升级。

  • 其次,如果更新日志文件没有问题,运行yarn changeset-apply ,它将更新日志和版本应用于实际的软件包。你可以用git statusgit diff 再次检查是否一切如意。

  • 第三,如果一切正常,继续用yarn release 将更新后的软件包发布到npm。发布之后,在npm上验证你的新版本是否被发布到那里。

基本上,这就是在你的本地机器上对你的包进行版本管理的一切。下一节将更进一步,使用持续集成进行版本管理(2)和发布(3)步骤。

使用Monorepos的持续集成

单孢子的持续集成(CI)的复杂性取决于在GitHub这样的版本控制平台上有多少个仓库被管理。在我们的例子中,所有的都在同一个仓库里(在这里它们是monorepo本身的一部分)。因此,我们只需要关心这一个仓库的CI,因为在这一节中,所有的都是关于软件包的发布。

这个例子中的monorepo已经使用了GitHub Actions进行CI。打开*.github/workflows.release.yml*文件,它为 GitHub Action 提供了以下内容。

name: Release
on:  push:    branches:      - main
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:  release:    name: Release    runs-on: ubuntu-latest    steps:      - name: Checkout Repository        uses: actions/checkout@v2        with:          fetch-depth: 0
      - name: Setup Node.js 16.x        uses: actions/setup-node@v2        with:          node-version: 16.x
      - name: Install Dependencies        run: yarn install
      - name: Create Release Pull Request or Publish to npm        id: changesets        uses: changesets/action@v1        with:          publish: yarn release        env:          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

注意:如果这个工作流程应该在你自己的 GitHub 仓库上运行,你必须在 npm 上创建一个NPM_TOKEN ,并将其作为 GitHub 的仓库秘密。此外,你还需要为你的组织/仓库启用 "Allow GitHub Actions to create and approve pull requests"。

monorepos continuous integration

现在,再次修改其中一个包中的一个组件。之后,使用yarn changeset-create ,在本地创建一个变更日志(和隐含的semver版本)。接下来,把你所有的改动(源代码改动+更新日志)推送到GitHub。从那里,带有GitHub动作的CI会接管你的monorepo的软件包。如果CI成功了,它就会创建一个新的PR,包含增加的版本和变更记录。一旦这个PR被合并,CI就会再次运行并发布软件包到npm。

单体架构

Monorepos最近变得越来越流行,因为它允许你把你的源代码分成多个应用程序/包(有意见的monorepo结构),同时仍然能够在一个地方管理所有的东西。首先,工作空间是建立单程序的第一个推动因素。在我们的案例中,我们一直在使用yarn工作空间,但npm和pnpm也有工作空间。

第二个促成因素是总体性的monorepo工具,它们允许人们以更方便的方式在全球范围内运行脚本,在monorepo中协调脚本(例如Turborepo中的管道),或者在本地/远程缓存已执行的脚本。Turborepo是这个领域的一个热门竞争者。Lerna和Nx是它的两个替代品。

如果在Git中使用单库,我们可以选择将一个仓库拆分成多个仓库(polyrepo伪装成单库)。在我们的方案中,我们一直在使用一个直接的*.gitignore*文件。然而,这个问题可能还有其他解决办法。

在版本管理的情况下,Changesets是一个流行的工具,用于为单版本创建更新日志、版本和发布。它是单版本空间中语义发布的替代方案。

总之,Workspaces、Turborepo和Changesets是创建、管理和扩展JavaScript/TypeScript单版本的完美单版本工具组合。

例子。作为孵化器的单引擎

在我最近的工作中,作为一个自由的前端开发员,我必须为一家公司建立一个单版本。该公司是一家软件公司,为其他公司开发应用程序。多年来,他们在内部开发了一些软件包(例如UI组件)。

**monorepo的目标是:**能够为客户并排开发应用程序,同时能够使用具有良好DX的共享包。

它提到了伟大的DX,因为这是使用monorepo的重要一点(见为什么使用monorepo)。与其从npm安装软件包,不如在monorepo的范围内改变它们,并看到变化反映在应用程序中。否则,当调整一个UI库时,我们将不得不经历整个发布+安装周期。

monorepo incubator

为公司孵化和孵化一个应用程序的过程分为两个连续的部分,我将在下面探讨。

**孵化。**当新客户加入monorepo时,我们/他们通过git创建一个仓库,我们从那里将其克隆到我们的monorepo。在那里,我们可以从monorepo中选择共享包作为依赖。客户可以在任何时候作为独立的项目克隆仓库(无需依赖monorepo),同时能够从注册表中安装所有的依赖项,因为共享包有强制版本。

**孵化:**一旦客户得到脱机,我们会在他们项目的package.json中给所有的依赖项设置一个最终版本。从那时起,他们就有责任升级这些软件包。因此,如果客户决定升级其中一个软件包,我们会自动生成内部软件包的更新日志。

Monorepo常见问题

  • 前台和后台都有Monorepos吗当使用前端和后端时,它们通常是通过API松散耦合的。然而,在很多方面,前台和后台仍然可以使用共享包(类型安全的API接口,实用函数,服务器端组件)。所以,完全可以有一个CSR React应用,一个SSR React应用(例如Next.js),和一个Express应用并排在一起。

  • Monorepos和Monoliths是一样的吗它们不一样。一个monorepo可以产生一个单体应用,然而更有可能是并排的应用,它们共享公司的领域或一组包,但不是一个独立的应用(单体的定义)。最后,只需要有在多个项目中共享代码的要求。

  • 有针对微前端的单片机吗我在这里没有任何可以参考的东西,但是,绝对有可能在一个monorepo中并排创建一个面向用户的应用程序和一个面向管理员的应用程序,由开发者决定是否将两者缝合起来作为一个单体,或者是否在不同的(子)域中作为独立的应用程序(例如my-application.com和admin.my-application.com)。