为两个npm包建立一个简单的monorepo的教程

948 阅读7分钟

monorepo是一个用于管理多个项目的单一存储库。在这篇博文中,我们将探讨如何为两个npm包建立一个简单的monorepo。我们所需要的一切都已经内置于npm和TypeScript中。


什么是monorepo,为什么它有用?

每当我们不得不平行开发多个相互依赖的npm包时,我们有两个选择:

  1. 我们可以将这些包放在不同的存储库中,并将它们分别发布到npm。
  2. 我们可以把所有的包放在一个仓库里,然后从那里把它们发布到npm。

(2)的好处是更容易保持软件包的同步性。我们可以在同一时间安装和构建所有的包。而且,在Visual Studio Code中,我们可以在编辑时在不同的包之间跳转。

我对monorepo的使用情况:静态网站的生成

"Monorepo "听起来很花哨,但我对它的使用情况其实相对简单。我目前正在开发一个最小的静态网站生成器,它被称为_Stoa_。它分为两部分:

  • npm软件包@rauschma/stoa ,包含了该工具并发布到npm注册表。
  • npm软件包@rauschma/demo-blog ,只是一个(未公布的)目录,包含:
    • 博客本身是一个带有Markdown文件的目录。
    • 网站的外观是通过TypeScript(JSX和Preact)定义的。
    • 一个main 模块调用Stoa的命令行界面,并传递配置数据(包括用于渲染页面的JJSX视图)。

我的第一次失败尝试:本地路径安装

我开始通过所谓的开发 本地路径安装:

cd demo-blog/
npm install ../stoa/

之后,demo-blog/package.json ,有以下的依赖性:

{
  "name": "@rauschma/demo-blog",
  "dependencies": {
    "stoa": "file:../stoa",
    ···
  },
  ···
}

这种方法有几个优点:

  • 它很简单。没有任何额外的东西需要配置或安装。
  • 它比使用npm link 更加简单,后者涉及两个步骤,并导致全局性的变化。
  • demo-blog/node_modules ,有一个符号链接(symlink)到stoa/ ,这意味着,随着Stoa的发展,我们将看到来自demo-blog/ 的变化。(注意:yarn 不使用符号链接,它把依赖的文件复制过来)。

但它也有显著的缺点:

  • 按照现在demo-blog/package.json 的设置方式,它不能使用npm注册表中的Stoa版本。
  • 安装和构建必须为每个目录单独进行(而单版本只需一次)。
  • 由于符号链接,同时依赖stoademo-blog 的软件包不会被去掉冗余。这对某些软件包来说是致命的--例如,如果渲染函数和JSX组件来自不同的软件包,我们就不能使用React和Preact的钩子。

一个更好的解决方案:npm工作空间和TypeScript项目引用

在我尝试使用本地路径安装失败后,我为stoademo-blog 建立了一个monorepo 。

通过TypeScript制作ESM模块

之前的一篇博文中,我解释了如何通过TypeScript生产ESM模块。这也是我在monorepo中为这两个包所配置的。它的文件系统布局如下:

stoa-packages/
  stoa/
    package.json
    tsconfig.json
    ts/
      gen/
      client/
      test/
    dist/
  demo-blog/
    package.json
    tsconfig.json
    ts/
      gen/
      client/
    dist/

stoa/package.json 看起来像这样:

{
  "name": "@rauschma/stoa",
  "type": "module",
  "exports": {
    "./gen/*": "./dist/gen/*.js",
    "./client/*": "./dist/client/*.js"
  },
  "typesVersions": {
    "*": {
      "gen/*": [
        "dist/gen/*"
      ],
      "client/*": [
        "dist/client/*"
      ]
    }
  },
  "dependencies": {
    ···
  }
}

"type" 告诉 Node.js 将.js 文件解释为 ESM 模块(而不是 CommonJS 模块)。

"exports" 配置了JavaScript级别。这意味着,例如:

  • 文件stoa-packages/stoa/dist/gen/util/regexp-tools.js
  • 可以通过'@rauschma/stoa/gen/util/regexp-tools' 来导入。

换句话说,这个设置实现了两件事:

  • 我们不必在模块说明中提到目录'dist'
  • 我们不必在模块指定中提到文件名扩展名'.js'

"typesVersions" 可以确保 TypeScript 找到它需要的类型定义(.d.ts 文件)。

这就是demo-blog/package.json 中的内容。

{
  "name": "@rauschma/demo-blog",
  "type": "module",
  "dependencies": {
    "@rauschma/stoa": "*",
    ···
  },
  "scripts": {
    "all": "node ./dist/gen/main.js all"
  }
}

命令npm run all 是通过"scripts" 定义的,并通过 JavaScript 版本的demo-blog/ts/gen/main.ts 开始生成。后面的文件包含:

import { cli } from '@rauschma/stoa/gen/core/cli';

const projectDirPath = url.fileURLToPath(
  new url.URL('../../', import.meta.url));

cli({
  projectDirPath,
  ···
});

到目前为止,我们仍然没有进入monorepo领域。stoademo-blog 这两个包中的每一个都存在于它自己的(大部分是独立的)目录中。

一个_工作区_就是npm所说的monorepo。一个目录中的子目录是npm包。我们通过给package.json ,把stoa-packages/ 变成一个工作区。

stoa-packages/
  package.json
  node_modules/
    @rauschma/
      stoa -> ../../stoa
      demo-blog -> ../../demo-blog
  stoa/
  demo-blog/

stoa-packages/package.json 看起来像这样:

{
  "name": "stoa-packages",
  "workspaces": [
    "stoa",
    "demo-blog"
  ]
}

不幸的是,npm对 "工作空间 "这个词进行了超载。npm工作空间中的包也被称为工作空间。

现在我们可以做了:

cd stoa-packages/
npm install

然后就会发生这种情况:

  • stoademo-blog 的所有依赖被安装到stoa-packages/node_modules
  • stoa-packages/node_modules 也包含指向stoa-packages/stoa/stoa-packages/demo-blog/ 的符号链接。

stoademo-blog 没有自己的node_modules 目录。然而,当它们导入模块时,Node.j会在文件树中更高的下一个node_modules 中寻找它们。node_modules 中的符号链接使demo-blog 能够从@rauschma/stoa 中导入。

我们实现了什么?

  • 重复的包不再是一个问题,因为所有的包依赖都被安装在同一个node_modules
  • demo-blog 可以导入stoa ,就好像前者是一个独立的目录,而后者是一个发布的包。
  • 我们可以使用一个命令来安装所有的依赖项。
  • 我们可以在多个工作区运行npm命令(详情)。
  • demo-blog 会自动看到我们在stoa 中的所有改动。

我们仍然需要通过TypeScript分别编译这两个包。我们可以通过以下方式解决这个问题 项目引用,这是TypeScript对monorepo的称呼。我们需要创建三个文件:

  • stoa-packages/tsconfig.json
  • stoa-packages/stoa/tsconfig.ref.json
  • stoa-packages/demo-blog/tsconfig.ref.json

文件系统布局现在看起来像这样:

stoa-packages/
  tsconfig.json
  stoa/
    tsconfig.json
    tsconfig.ref.json
    ts/
      gen/
      client/
      test/
    dist/
  demo-blog/
    tsconfig.json
    tsconfig.ref.json
    ts/
      gen/
      client/
    dist/

这是stoa-packages/tsconfig.json

{
  "files": [],
  "references": [
    {
      "path": "./stoa/tsconfig.ref.json"
    },
    {
      "path": "./demo-blog/tsconfig.ref.json"
    },
  ],
}

正常的stoa-packages/stoa/tsconfig.json (我们在独立模式下需要它)包含。

这个tsconfig.json 有一个同级别的tsconfig.ref.json ,由于在stoa-packages/tsconfig.json 中的项目引用,它是必需的。

{
  "extends": "./tsconfig.json",
  "include": ["ts/**/*"],
  "compilerOptions": {
    "composite": true,
  },
}

让我们检查一下这些属性:

  • "extends" 让我们向独立的tsconfig.json 添加我们需要的属性,以使项目引用发挥作用。唉,我们不能把它们添加到tsconfig.json 本身,因为这样它就不能在独立模式下工作了。
  • "include" 是项目引用所需要的。
  • compilerOptions.composite 必须是项目引用的true

我们实现了什么?我们现在可以使用单个命令来清理、构建、监视(等)所有的软件包。

例如,我们可以将这些脚本添加到stoa-packages/package.json

{
  ···
  "scripts": {
    "clean": "tsc --build --clean",
    "build": "tsc --build",
    "watch": "tsc --build --watch"
  },
  ···
}

另一个好处是,我们可以点击(Mac:cmd-click,Windows:ctrl-click)demo-blogstoa 导入的东西,Visual Studio Code将跳转到原始源代码--而不是.d.ts 文件(详情)。

提示:当Visual Studio Code没有看到另一个包中的变化时,该怎么办?

有时,我们在一个包中做了一个改动,而Visual Studio Code在另一个依赖它的包中没有看到这个改动。发生这种情况时,我们可以做两件事:

  • 我们可以执行命令 "TypeScript:重新启动TS服务器"(详情)。
  • 打开相关的.d.ts 文件通常也有帮助。

剩下一步:发布

我没有告诉你如何将stoa-packages/stoa 发布到npm,以及如何将stoa-packages/demo-blog 变成可下载的存档,但这是相对容易实现的。

结论

我们已经看到了我们如何通过只使用npm和TypeScript中已经内置的东西来建立一个非常简单的monorepo。这使得并行开发多个软件包变得更加容易。

我设法保留了独立编译包demo-blog 的能力。在我遇到的其他TypeScript项目参考设置中,我还没有看到这一点。

三个愿望

我对这个设置非常满意,但仍有三个与TypeScript有关的愿望:

  • 如果我在Visual Studio Code中进行重构,所做的改变只影响一个包。如果所有的包都被改变,那就更好了。
  • 有了npm工作区,当我把一个包添加到工作区时,我就不必再改变它。唉,对于TypeScript项目引用来说,这不是真的,我必须添加一个tsconfig
  • 我希望我不必把"typesVersions" 添加到package.json ,以使"exports" 与TypeScript一起工作。我希望TypeScript在未来能够从"exports" 中获得这些信息。