TypeScript Monorepo 终极指南
tl;dr
为什么选择 Monorepo?
Yarn 2 工作区
TypeScript 项目参考
ESLint 和 Prettier
笑话
Webpack 和 ESBuild
React/Next.js
AWS Lambda
基础设施和部署
后续步骤
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
在过去的三年里,我写了几篇关于如何设置 JavaScript 和 TypeScript Monorepo 的文章(#1、#2、#3、#4、#5、#6、#7),我当时觉得我已经完全弄明白了——但事实并非如此。
事实证明,由于各种原因,开发一个由多个独立模块组成的 JavaScript/TypeScript 项目极其困难。为了简化这个过程,我甚至创建了一个名为Goldstack 的小网站,它可以生成模块化的入门项目。
然而,我一直对我的解决方案不太满意——它们通常包含笨拙的变通方法,而且存在一些问题,导致它们无法扩展到更大的项目。现在,我相信我终于找到了一个解决方案,它最大限度地减少了变通方法,并且适用于各种规模的项目。
此解决方案包括:
- Yarn 2 工作区用于包管理
- 用于模块间依赖关系的TypeScript项目引用
- ESLint和Prettier用于代码检查和格式化
- Jest用于单元测试
- 用于打包的Webpack和ESBuild
- React / Next.js用于 UI 开发
- 用于后端开发的AWS Lambda
- 基于Terraform 的自定义工具,用于基础设施和部署
在本指南中,我将简要介绍每项挑战及其解决方案。
tl;dr
如果您只是想使用一个已经完全配置好的 TypeScript monorepo 来方便起见,请考虑使用https://goldstack.party/上的开源模板之一。
为什么选择 Monorepo?
在深入探讨具体实现之前,我想先简要介绍几种情况下单体仓库可能是搭建项目的好选择:
- 对于全栈应用:在同一个代码仓库中开发前端和后端代码,可以更轻松地创建端到端集成测试,并且允许在前端和后端之间定义和使用类型。对于更复杂的用例,能够在前端和后端重用相同的逻辑也很有用,例如用于验证。
- 对于大型应用程序:将这些大型应用程序拆分成多个包可以提高模块化程度,并有助于降低复杂性。降低复杂性的主要方法是在模块之间强制执行层级依赖模式(npm 依赖项不能循环引用)——这与普通 JavaScript 项目中每个文件都可以随意导入任何其他文件的自由模式截然不同。
- 对于无服务器应用程序:传统应用程序可以将所有应用程序逻辑打包并部署在一个大型软件包中,而无服务器应用程序通常以多个独立组件的形式部署,例如无服务器函数。这种部署模式非常适合单体仓库(monorepo),因为每个独立部署的组件都可以位于自己的软件包中,同时仍然可以轻松地在组件之间共享代码。
Yarn 2 工作区
Yarn 2 工作区为管理大型 JavaScript 项目中的包和依赖项提供了一种便捷的方式。Yarn 工作区支持创建如下类型的项目:
packages/
localPackageA/
package.json
...
localPackageB/
package.json
...
Yarn 可以运行一个简单的yarn add [localPackageName]命令,将一个本地包添加为另一个本地包的依赖项。
此外,Yarn 2('Berry')摒弃了node_modulesNode.js中常用的用于本地保存依赖项的文件夹。取而代之的是,所有本地包使用的依赖项都以zip文件的形式存储在一个特殊的.yarn/cache文件夹中。
这在单体仓库中尤其有用,因为多个本地包很可能使用相同的依赖项。通过在一个中心文件夹中声明这些依赖项,就不需要多次下载它们。
遗憾的是,使用 Yarn 2 工作区仍然存在一些挑战。最主要的是,这种方法会与任何依赖于直接从文件夹读取文件的包发生冲突。此外, Yarn 2 还不支持 ESM 模块,这node_modules也会带来一些问题。需要注意的是,可以通过定义不同的节点链接器来解决这个问题。
TypeScript 项目参考
TypeScript 项目引用的主要开发目的是为了解决大型 TypeScript 项目编译时间过长的问题。它允许将大型项目拆分成多个较小的模块,每个模块都可以单独编译。这也有助于开发更模块化的代码。
本质上,我们的项目不再只有一个tsconfig.json文件,而是会有多个文件,每个模块对应一个文件。为了使用项目引用,我们需要为 TypeScript 提供一些配置参数。
- 需要启用复合选项。这样 TypeScript 就只会编译已更改的模块。
- 应启用声明选项,以便在模块边界之间提供类型信息。
- 还应启用 declarationMap 选项。这将允许在不同项目之间进行代码导航。
- 启用增量选项可以通过缓存编译结果来加快编译速度。
- 应该在每个模块的 tsconfig.json 文件中定义outDir,以便将编译器输出分别存储在每个模块中。
此外,我们需要在tsconfig.json文件中添加references属性,该属性定义此模块所依赖的项目中的所有模块。
这样,项目中某个模块的 tsconfig.json 文件可能如下所示:
{
"compilerOptions": {
"composite": true,
"declaration": true,
"declarationMap": true,
"incremental": true,
"outDir": "./dist",
}
"references": [
{
"path": "../path-to-module"
},
]
}
在项目根目录下定义一个tsconfig.json文件也很有用,该文件包含对项目中所有模块的引用。这样就可以轻松地通过一个命令编译所有模块。
请注意,启用复合标志后,运行 TypeScript 编译器时应包含-build参数:
tsc --build
这种默认设置通常效果很好。但是,对于大型项目,像 VSCode 这样的代码编辑器可能会遇到性能问题。如果出现这种情况,请启用`disableSourceOfProjectReferenceRedirect`选项,这将阻止代码编辑器不断地重新编译依赖模块。不过请注意,启用此选项后,您需要确保 TypeScript 文件在更改时重新编译(例如,通过在监视模式下运行 TypeScript 编译器)。
关于 TypeScript 项目引用,目前的主要问题在于它们需要手动维护。使用 Yarn 工作区时,很容易推断出本地引用应该是什么,但 TypeScript 默认情况下不会这样做。为此,我编写了一个小工具,可以使 TypeScript 项目引用与 Yarn 工作区依赖项保持同步:神奇地更新 Yarn 工作区的 TypeScript 项目引用!
ESLint 和 Prettier
Prettier是一个非常棒的工具,可以用来保持项目格式的一致性。Prettier 对单体仓库(monorepo)尤其适用。只需.prettierrc在单体仓库的根目录创建一个配置文件,然后使用该配置文件运行 Prettier 即可。它会自动将配置应用到单体仓库中的所有包。
ESLint提供对 JavaScript 或 TypeScript 源代码的深度分析。值得庆幸的是,它像 Prettier 一样易于配置,尤其适用于单体仓库。我们可以.eslintrc.json在项目根目录定义一个配置文件,该配置文件将应用于单体仓库中的所有文件。
安装Prettier和ESLint VSCode 扩展后,单体仓库中的所有文件在 VSCode 中都能正常进行格式化和代码检查。只需配置Prettier 插件以使其与 ESLint 兼容(参见示例.eslintrc.json),即可实现此功能。否则,Prettier 和 ESLint 会相互干扰,导致糟糕的编辑体验。为了实现此功能,还需要在.vscode/settings.json配置文件中配置以下两项设置(参见settings.json):
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"files.eol": "\n",
"editor.formatOnSave": false
}
通常情况下,Prettier 和 ESLint 在单体仓库 (monorepo) 中运行良好。唯一潜在的问题是,如果单体仓库包含大量文件,运行 Prettier 或 ESLint 可能需要很长时间。在这种情况下,可以通过在本地包中添加引用项目根目录下 Prettier 和 ESLint 配置的script定义,将 Prettier 和 ESLint 配置为仅针对单体仓库中的特定包package.json运行。
笑话
Jest是一个强大的工具,用于在 JavaScript 或 TypeScript 项目中运行单元测试。然而,由于 JavaScript 生态系统的碎片化特性,在 Jest 中运行测试往往比预期要复杂一些。例如,在使用 TypeScript 和/或 React 时,我们需要确保源文件在运行测试之前被转译成 JavaScript。在使用 Yarn 工作区时,我们还需要确保 Jest 能够解析本地依赖项。
幸好,使用 TypeScript 和 TypeScript 项目引用简化了 Jest 的复杂配置,因为我们可以利用优秀的ts-jest Jest 转换器。我们只需将 ts-jest 指向tsconfig.json每个包对应的配置文件(参见示例jest.config.js)。由于我们将 TypeScript 配置为复合式和增量式,因此无需为要测试的包的依赖项重新编译 TypeScript,这显著缩短了单元测试的运行时间。ts-jest 还会确保所有错误消息都引用源 TypeScript 文件中的行号。
Webpack 和 ESBuild
在单体仓库中,使用打包工具进行部署至关重要。因为如果没有高效的打包,即使单个部署只包含部分源文件,我们也需要部署仓库中的所有代码。
与 Jest 类似,在配置为使用 TypeScript 项目引用的 monorepo 中使用Webpack非常简单。我们只需使用ts-loader加载器,一切都会自动运行。
同样,使用esbuild也非常简单。esbuild 默认支持 TypeScript,并且由于我们已经配置了 TypeScript 项目引用,它会自动解析所有本地引用。我们唯一需要额外配置的是使用插件,[@yarnpkg/esbuild-plugin-pnp](https://github.com/yarnpkg/berry/tree/master/packages/esbuild-plugin-pnp)以便 esbuild 可以从本地 Yarn 缓存解析外部依赖项。以下是一个用于打包 AWS Lambda 函数代码的示例脚本 ( build.ts ):
import { build } from 'esbuild';
import { pnpPlugin } from '@yarnpkg/esbuild-plugin-pnp';
build({
plugins: [pnpPlugin()],
bundle: true,
entryPoints: ['src/lambda.ts'],
external: ['aws-sdk'],
minify: true,
format: 'cjs',
target: 'node12.0',
sourcemap: true,
outfile: 'distLambda/lambda.js',
}).catch((e) => {
console.log('Build not successful', e.message);
process.exit(1);
});
React/Next.js
许多 JavaScript/TypeScript 项目都希望包含一些前端功能,但在 JavaScript 生态系统中,我们不幸地经常需要克服一些额外的障碍才能使不同的框架/库相互协同工作。
Next.js是一个非常强大的 React 开发框架,而且将其部署到 TypeScript monorepo 中也并不难。此外,由于 Next.js 原生支持 Yarn 2 工作区和 TypeScript 项目引用,我们在这个 monorepo 中几乎不需要进行任何配置。我们只需定义一个包含所有本地依赖项的tsconfig.json文件,Next.js 就会自动识别并应用这些依赖项。
我们需要对 Next.js 配置进行一些小小的调整,才能使其与所有本地依赖项兼容。为此,我们需要配置插件next-transpile-modules。
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/explicit-function-return-type */
const withPlugins = require('next-compose-plugins');
const getLocalPackages = require('./scripts/getLocalPackages');
const localPackages = getLocalPackages.getLocalPackages();
const withTM = require('next-transpile-modules')(localPackages);
const nextConfig = {
webpack: (config, options) => {
return config;
},
eslint: {
// ESLint managed on the workspace level
ignoreDuringBuilds: true,
},
images: {
disableStaticImages: true,
},
};
const config = withPlugins(
[
[withTM()],
],
nextConfig
);
module.exports = config;
next-transpile-modules需要我们向其提供所有本地依赖项的列表,例如["@myproject/package1", "@myproject/package2"]。与其手动维护此列表[next.config.js](https://github.com/goldstack/goldstack/blob/master/workspaces/templates/packages/app-nextjs-bootstrap/next.config.js),我们可以轻松编写一个小脚本,读取包的package.json并使用 Yarn cli 确定本地依赖项。
yarn workspaces list --json
请在此处查找完整的脚本:getLocalPackages.js。
AWS Lambda
AWS Lambda 非常适合从单体仓库部署后端应用程序代码。要开发 Lambda 函数的代码,需要考虑两件事:打包和本地测试。
如上所述,使用esbuild打包 monorepo 中的代码非常简单。我们只需要提供esbuild 的pnp 插件即可。打包 lambda 函数时,我们还需要确保使用 cjs 作为格式,并将编译目标设置为 Node 12 。
在这里可以找到一个完整的 esbuild 配置示例:build.ts。
开发、部署和测试 Node.js Lambda 函数的方法有很多。在我的参考模板中,我提供了一个使用Express.js 服务器的示例。但这并非部署 Lambda 函数的最佳方式,主要是因为这会导致部署一个 Lambda 函数来处理多个路由。使用函数部署后端最“无服务器”的方式是为不同的端点使用不同的函数。
然而,使用 Express.js 可以非常轻松地进行部署和本地开发,因此我选择它作为初始实现方案,但希望将来能够对其进行改进(参见#5和#10 )。为了使基于 Express.js 的 Lambda 函数能够进行本地测试,我们可以使用ts-node-dev包。这将允许在本地启动服务器,并在 monorepo 中的任何文件发生更改时自动重新加载它(参见package.json)。
"scripts": {
"watch": "PORT=3030 CORS=http://localhost:3000 GOLDSTACK\_DEPLOYMENT=local ts-node-dev ./src/local.ts"
},
基础设施和部署
目前大多数针对 JavaScript/TypeScript 单体仓库的解决方案都利用了常见的 JavaScript 工具、框架和库。遗憾的是,我没能找到一个符合我基础设施搭建和部署要求的框架。对我来说,能够使用Terraform至关重要,因为我认为它是以代码形式定义基础设施最“标准”的方式。几乎所有可以部署在任何主流云平台上的基础设施都可以用 Terraform 定义,而且有大量的示例和文档可供参考。相比之下,Serverless 框架或AWS SAM等替代方案更倾向于作为专用工具。Pulumi 也是一个不错的选择,但我目前还不确定它在基于 Terraform的基本基础设施定义之上提供的额外功能是否真的比原版 Terraform 更实用。
鉴于此,我编写了一系列轻量级脚本,允许使用 Terraform 在 AWS 中搭建基础设施,并使用AWS CLI或SDK执行部署。例如,要部署 Lambda 函数,只需定义一些 Terraform 文件(例如lambda.tf)。
resource "aws_lambda_function" "main" {
function_name = var.lambda_name
filename = data.archive_file.empty_lambda.output_path
handler = "lambda.handler"
runtime = "nodejs12.x"
memory_size = 2048
timeout = 900
role = aws_iam_role.lambda_exec.arn
lifecycle {
ignore_changes = [
filename,
]
}
environment {
variables = {
GOLDSTACK_DEPLOYMENT = var.name
CORS = var.cors
}
}
}
同时还附带用 TypeScript 编写的脚本,该脚本将使用 AWS CLI 部署 Lambda 函数(templateLambdaExpressDeploy.ts):
awsCli({
credentials: await getAWSUser(params.deployment.awsUser),
region: params.deployment.awsRegion,
command: `lambda update-function-code --function-name ${readTerraformStateVariable(
params.deploymentState,
'lambda_function_name'
)} --zip-file fileb://${targetArchive}`,
});
这样就可以使用简单的命令搭建基础设施并进行部署,例如(请参阅Goldstack 文档中的“基础设施命令和部署”):
yarn infra up prod yarn deploy prod
部署配置在goldstack.json配置文件中,这些配置文件会被转换为 Terraform 变量,用于启动基础设施,并根据需要被部署脚本调用。例如,这里展示的是 AWS Lambda 的goldstack.json文件。
{
"$schema": "./schemas/package.schema.json",
"name": "lambda-express-template",
"template": "lambda-express",
"templateVersion": "0.1.0",
"configuration": {},
"deployments": [
{
"name": "prod",
"awsRegion": "us-west-2",
"awsUser": "goldstack-dev",
"configuration": {
"lambdaName": "goldstack-test-lambda-express",
"apiDomain": "express-api.templates.dev.goldstack.party",
"hostedZoneDomain": "dev.goldstack.party",
"cors": "https://app-nextjs-bootstrap.templates.dev.goldstack.party"
},
"tfStateKey": "lambda-express-template-prod-8e944cec8ad5910f0d3d.tfstate"
}
]
}
请注意,即使不使用这些工具,您也可以使用 Goldstack 生成的参考模板和模板来进行基础设施和部署。只需不要使用脚本,而是用您偏好的方式来定义基础设施和部署即可。
后续步骤
虽然我在文章开头提到我对目前 TypeScript monorepo 模板的状态比较满意,但我仍然认为还有一些地方可以改进。主要来说,我认为 Yarn 2('Berry')的成熟度还不够理想。例如,如果能支持 ESM 就太好了,因为缺少 ESM 的支持导致我在 monorepo 中尝试使用 Svelte 时遇到了一些问题。不过,我认为 Yarn 团队在 Yarn 2 上所做的努力非常值得肯定,我也很乐意通过尝试在 monorepo 模板中实现 Yarn 2 的功能来支持他们。
另一个遗留的限制是,在更改本地包之间的依赖关系后,需要手动运行utils-typescript-references工具(以保持工作区依赖关系和 TypeScript 项目引用同步)。我想知道是否可以编写一个 Yarn 插件来实现同样的功能( TypeScript已经有类似的插件了)。
除此之外,我认为大部分改进都可以在模板项目的基础设施配置方面进行(参见问题#3、#5和#10)。我也确信,新版本的 Jest、Next.js、TypeScript 等很快就会破坏这个模板,因此肯定需要持续进行一些工作来保持该模板的正常运行。
虽然Goldstack 网站上生成的 monorepo 模板已被下载数百次,但目前在GitHub上的互动并不多。我推测这是因为这是一个相当庞大且复杂的项目,而我可能还没有成功地简化贡献流程。我将在未来努力改进,希望这能鼓励更多人参与到项目中来。
题图来源:Pixabay的Pete Linforth
原文发表于 http://maxrohde.com, 日期为 2021 年 11 月 20 日。
文章来源:https://dev.to/mxro/the-ultimate-guide-to-typescript-monorepos-5ap7

