monorepo是一个用于管理多个项目的单一存储库。在这篇博文中,我们将探讨如何为两个npm包建立一个简单的monorepo。我们所需要的一切都已经内置于npm和TypeScript中。
什么是monorepo,为什么它有用?
每当我们不得不平行开发多个相互依赖的npm包时,我们有两个选择:
- 我们可以将这些包放在不同的存储库中,并将它们分别发布到npm。
- 我们可以把所有的包放在一个仓库里,然后从那里把它们发布到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版本。 - 安装和构建必须为每个目录单独进行(而单版本只需一次)。
- 由于符号链接,同时依赖
stoa和demo-blog的软件包不会被去掉冗余。这对某些软件包来说是致命的--例如,如果渲染函数和JSX组件来自不同的软件包,我们就不能使用React和Preact的钩子。
一个更好的解决方案:npm工作空间和TypeScript项目引用
在我尝试使用本地路径安装失败后,我为stoa 和demo-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领域。stoa 和demo-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
然后就会发生这种情况:
stoa和demo-blog的所有依赖被安装到stoa-packages/node_modules。stoa-packages/node_modules也包含指向stoa-packages/stoa/和stoa-packages/demo-blog/的符号链接。
stoa 和demo-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.jsonstoa-packages/stoa/tsconfig.ref.jsonstoa-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-blog 从stoa 导入的东西,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"中获得这些信息。