ChelesteWang / FE-Review

前端知识复盘与整理
Apache License 2.0
33 stars 8 forks source link

Monorepo 相关杂谈 #33

Open ChelesteWang opened 3 years ago

ChelesteWang commented 3 years ago

https://segmentfault.com/a/1190000039077541 https://juejin.cn/post/6972139870231724045#heading-12 https://zhuanlan.zhihu.com/p/350317592

ChelesteWang commented 3 years ago

初始化新包的时候,版本号应该如何选择。 对于开发分支,包版本的命名规则应该如何,特别是多个开发分支存在,需要发不同分支的包的情况。 在不同的开发分支间进行切换,每次需要重新执行 lerna bootstrap 以完成包之间的链接(因为不同分支的依赖情况及子包数量不同) 同上,在切换到新的开发分支时,子包的编译顺序需要梳理,比如 A 包依赖 B 包,B 是新添加的,全局 build 的情况下,需要先指定编译 B 包输出 lib 目录,A 包编译才能找到依赖并通过编译。 如果使用别名,各个子包之间如何保证别名的引用是当前包范围 新增加一个包,要先发布初始版本至仓库,才能在其他子包找到该包的依赖

ChelesteWang commented 3 years ago

pnpm -r 和 pnpm -w

ChelesteWang commented 3 years ago

在最近的项目开发中,出现了一个令我困扰的状况。

我正在开发的项目 A,依赖了已经线上发布的项目 B,但是随着项目 A 的不断开发,又需要不时修改项目 B 的代码(这些修改暂时不必发布线上),如何能够在修改项目 B 代码后及时将改动后在项目 A 中同步? 在项目 A 发布上线后,如何以一种优雅的方式解决项目 A、B 版本升级后的版本同步问题?

经过一番调研,我发现解决这些问题的最佳方案便是本篇要介绍的 monorepo 策略。

什么是 monorepo 策略?

monorepo 是一种将多个项目代码存储在一个仓库里的软件开发策略("mono" 来源于希腊语 μόνος 意味单个的,而 "repo",显而易见地,是 repository 的缩写)。将不同的项目的代码放在同一个代码仓库中,这种「把鸡蛋放在同一个篮子里」的做法可能乍看之下有些奇怪,但实际上,这种代码管理方式有很多好处,无论是世界一流的互联网企业 Google,Facebook,还是社区知名的开源项目团队 Babel (如下图)都使用了 monorepo 策略管理他们的代码。

pic_99b0d4e1.png babel 使用 monorepo 策略管理代码

使用 monorepo 策略究竟会给代码管理者和程序开发者带来哪些好处? 我们又该如何在工作中尝试实践 monorepo 策略?这正是本文想要探讨的话题。希望通过我的一番介绍,您能够对 monorepo 策略有更完整的认知,文章中介绍的工具和思想可以切实帮助到您和您所在的团队。

monorepo 策略的优劣

通过 monorepo 策略组织代码,您代码仓库的目录结构看起来会是这样:

.
├── lerna.json
├── package.json
└── packages/ # 这里将存放所有子 repo 目录
    ├── project_1/
    │   ├── index.js
    │   ├── node_modules/
    │   └── package.json
    ├── project_2/
    │   ├── index.js
    │   ├── node_module/
    │   └── package.json
    ...

乍看起来,所谓的 monorepo 策略就只是将不同项目的目录汇集到一个目录之下,但实际上操作起来所要考虑的事情则远比看起来要复杂得多。通过分析使用 monorepo 策略的优劣,我们可以更直观的感受到这里面所隐晦涉及的知识点。

▐ monorepo 方案的优势

  1. 代码重用将变得非常容易:由于所有的项目代码都集中于一个代码仓库,我们将很容易抽离出各个项目共用的业务组件或工具,并通过 TypeScript,Lerna 或其他工具进行代码内引用;
  2. 依赖管理将变得非常简单:同理,由于项目之间的引用路径内化在同一个仓库之中,我们很容易追踪当某个项目的代码修改后,会影响到其他哪些项目。通过使用一些工具,我们将很容易地做到版本依赖管理和版本号自动升级;
  3. 代码重构将变得非常便捷:想想究竟是什么在阻止您进行代码重构,很多时候,原因来自于「不确定性」,您不确定对某个项目的修改是否对于其他项目而言是「致命的」,出于对未知的恐惧,您会倾向于不重构代码,这将导致整个项目代码的腐烂度会以惊人的速度增长。而在 monorepo 策略的指导下,您能够明确知道您的代码的影响范围,并且能够对被影响的项目可以进行统一的测试,这会鼓励您不断优化代码;
  4. 它倡导了一种开放,透明,共享的组织文化,这有利于开发者成长,代码质量的提升:在 monorepo 策略下,每个开发者都被鼓励去查看,修改他人的代码(只要有必要),同时,也会激起开发者维护代码,和编写单元测试的责任心(毕竟朋友来访之前,我们从不介意自己的房子究竟有多乱),这将会形成一种良性的技术氛围,从而保障整个组织的代码质量。

▐ monorepo 方案的劣势

  1. 项目粒度的权限管理变得非常复杂:无论是 Git 还是其他 VCS 系统,在支持 monorepo 策略中项目粒度的权限管理上都没有令人满意的方案,这意味着 A 部门的 a 项目若是不想被 B 部门的开发者看到就很难了。(好在我们可以将 monorepo 策略实践在「项目级」这个层次上,这才是我们这篇文章的主题,我们后面会再次明确它);
  2. 新员工的学习成本变高:不同于一个项目一个代码仓库这种模式下,组织新人只要熟悉特定代码仓库下的代码逻辑,在 monorepo 策略下,新人可能不得不花更多精力来理清各个代码仓库之间的相互逻辑,当然这个成本可以通过新人文档的方式来解决,但维护文档的新鲜又需要消耗额外的人力;
  3. 对于公司级别的 monorepo 策略而言,需要专门的 VFS 系统,自动重构工具的支持:设想一下 Google 这样的企业是如何将十亿行的代码存储在一个仓库之中的?开发人员每次拉取代码需要等待多久?各个项目代码之间又如何实现权限管理,敏捷发布?任何简单的策略乘以足够的规模量级都会产生一个奇迹(不管是好是坏),对于中小企业而言,如果没有像 Google,Facebook 这样雄厚的人力资源,把所有项目代码放在同一个仓库里这个美好的愿望就只能是个空中楼阁。

▐ 小结:如何取舍?

没错,软件开发领域从来没有「银弹」。monorepo 策略也并不完美,并且,我在实践中发现,要想完美在组织中运用 monorepo 策略,所需要的不仅是出色的编程技巧和耐心。团队日程,组织文化和个人影响力相互碰撞的最终结果才决定了想法最终是否能被实现。

但是请别灰心的太早,因为虽然让组织作出改变,统一施行 monorepo 策略困难重重,但这却并不意味着我们需要彻底跟 monorepo 策略说再见(否则我这篇文章就该到此为止了)。我们还可以把 monorepo 策略实践在「项目」这个级别,即从逻辑上确定项目与项目之间的关联性,然后把相关联的项目整合在同一个仓库下,通常情况下,我们不会有太多相互关联的项目,这意味着我们能够免费得到 monorepo 策略的所有好处,并且可以拒绝支付大型 monorepo 架构的利息。

本文的剩余篇幅就是对「项目级别 monorepo 实践」的一些总结,即使您最终没有选择 monorepo 策略组织您的代码,相信文章中提供的一些工程化工具或思路也一样会对您产生帮助。

monorepo 方案实践

▐ 锁定环境:Volta

Volta 是一个 JavaScript 工具管理器,它可以让我们轻松地在项目中锁定 node,npm 和 yarn 的版本。你只需在安装完 Volta 后,在项目的根目录中执行 volta pin 命令,那么无论您当前使用的 node 或 npm(yarn)版本是什么,volta 都会自动切换为您指定的版本。

因此,除了使用 Docker 和显示在文档中声明 node 和 npm(yarn)的版本之外,您就有了另一个锁定环境的强力工具。

而且相较于 nvm,Volta 还具有一个诱人的特性:当您项目的 CLI 工具与全局 CLI 工具不一致时,Volta 可以做到在项目根目录下自动识别,切换到项目指定的版本,这一切都是由 Volta 默默做到的,开发者不必关心任何事情。

▐ 复用 packages:workspace

使用 monorepo 策略后,收益最大的两点是:

  1. 避免重复安装包,因此减少了磁盘空间的占用,并降低了构建时间;
  2. 内部代码可以彼此相互引用;

这两项好处全部都可以由一个成熟的包管理工具来完成,对前端开发而言,即是 yarn(1.0 以上)或 npm(7.0 以上)通过名为 workspaces 的特性实现的(⚠️ 注意:支持 workspaces 特性的 npm 目前依旧不是 LTS 版本)。

为了实现前面提到的两点收益,您需要在代码中做三件事:

  1. 调整目录结构,将相互关联的项目放置在同一个目录,推荐命名为 packages;
  2. 在项目根目录里的 package.json 文件中,设置 workspaces 属性,属性值为之前创建的目录;
  3. 同样,在 package.json 文件中,设置 private 属性为 true(为了避免我们误操作将仓库发布);

经过修改,您的项目目录看起来应该是这样:

.
├── package.json
└── packages/
    ├── @mono/project_1/ # 推荐使用 `@<项目名>/<子项目名>` 的方式命名
    │   ├── index.js
    │   └── package.json
    └── @mono/project_2/
        ├── index.js
        └── package.json

而当您在项目根目录中执行 npm install 或 yarn install后,您会发现在项目根目录中出现了 node_modules 目录,并且该目录不仅拥有所有子项目共用的 npm 包,还包含了我们的子项目。因此,我们可以在子项目中通过各种模块引入机制,像引入一般的 npm 模块一样引入其他子项目的代码。

请注意我们对子项目的命名,统一以 @<repo_name>/ 开头,这是一种社区最佳实践,不仅可以让用户更容易了解整个应用的架构,也方便您在项目中更快捷的找到所需的子项目。

至此,我们已经完成了 monorepo 策略的核心部分,实在是很容易不是吗?但是老话说「行百里者半九十」,距离优雅的搭建一个 monorepo 项目,我们还有一些路要走。

▐ 统一配置:合并同类项 - Eslint,Typescript 与 Babel

您一定同意,编写代码要遵循 DRY 原则(Don't Repeat Yourself 的缩写)。那么,理所当然地,我们应该尽量避免在多个子项目中放置重复的 eslintrc,tsconfig 等配置文件。幸运的是,Babel,Eslint 和 Typescript 都提供了相应的功能让我们减少自我重复。

我们可以在 packages 目录中放置 tsconfig.settting.json 文件,并在文件中定义通用的 ts 配置,然后,在每个子项目中,我们可以通过 extends 属性,引入通用配置,并设置 compilerOptions.composite 的值为 true,理想情况下,子项目中的 tsconfig 文件应该仅包含下述内容:

{
  "extends": "../tsconfig.setting.json", // 继承 packages 目录下通用配置
  "compilerOptions": {
    "composite": true, // 用于帮助 TypeScript 快速确定引用工程的输出文件位置
    "outDir": "dist",
    "rootDir": "src"
  },
  "include": ["src"]
}

对于 Eslint 配置文件,我们也可以如法炮制,这样定义子项目的 .eslintrc 文件内容:

{
  "extends": "../../.eslintrc", // 注意这里的不同
  "parserOptions": {
    "project": "tsconfig.json"
  }
}

注意到了吗,对于通用的 eslint 配置,我们并没有将其放置在 packages 目录中,而是放在整个项目的根目录下,这样做是因为一些编辑器插件只会在项目根目录寻找 .eslintrc 文件,因此为了我们的项目能够保持良好的「开发环境一致性」,请务必将通用配置文件放置在项目的根目录中。

Babel 配置文件合并的方式与 TypeScript 如出一辙,甚至更加简单,我们只需在子项目中的 .babelrc 文件中这样声明即可:

{
  "extends": "../.babelrc"
}

当一切准备就绪后,我们的项目目录应该大致呈如下所示的结构:

.
├── package.json
├── .eslintrc
└── packages/
    │   ├── tsconfig.settings.json
    │   ├── .babelrc
    ├── @mono/project_1/
    │   ├── index.js
    │   ├── .eslintrc
    │   ├── .babelrc
    │   ├── tsconfig.json
    │   └── package.json
    └───@mono/project_2/
        ├── index.js
        ├── .eslintrc
        ├── .babelrc
        ├── tsconfig.json
        └── package.json

▐ 统一命令脚本:scripty

在上一步中,我们尽可能的将所有配置文件进行抽象,从而精简了代码,并提高了整个项目的一致性。我们的整个仓库也因此有了「更浓郁的 monorepo 风味 ☕️」。但如果仔细审视我们的整个工程文件,还有一处存在着明显的瑕疵和一些恼人的坏味道,当您仔细审视您的众多 package.json 文件时,您就知道我在说什么了 -- scripts 脚本。

如果您的子项目足够多,您可能会发现,每个 package.json 文件中的 scripts 属性都大同小异,并且一些 scripts 充斥着各种 Linux 语法,例如管道操作符,重定向或目录生成。重复带来低效,复杂则使人难以理解,这都是需要我们解决的问题。

这里给出的解决方案是,使用 scripty 管理您的脚本命令,简单来说,scripty 允许您将脚本命令定义在文件中,并在 package.json 文件中直接通过文件名来引用。这使我们可以实现如下目的:

  1. 子项目间复用脚本命令;
  2. 像写代码一样编写脚本命令,无论它有多复杂,而在调用时,像调用函数一样调用;

通过使用 scripty 管理我们的 monorepo 应用,目录结构看起来将会是这样:

.
├── package.json
├── .eslintrc
├── scirpts/ # 这里存放所有的脚本
│   │   ├── packages/ # 包级别脚本
│   │   │   ├── build.sh
│   │   │   └── test.sh
│   └───└── workspaces/ # 全局脚本
│           ├── build.sh
│           └── test.sh
└── packages/
    │   ├── tsconfig.settings.json
    │   ├── .babelrc
    ├── @mono/project_1/
    │   ├── index.js
    │   ├── .eslintrc
    │   ├── .babelrc
    │   ├── tsconfig.json
    │   └── package.json
    └── @mono/project_2/
        ├── index.js
        ├── .eslintrc
        ├── .babelrc
        ├── tsconfig.json
        └── package.json

注意,我们脚本分为两类「package 级别」与「workspace 级别」,并且分别放在两个文件夹内。这样做的好处在于,我们既可以在项目根目录执行全局脚本,也可以针对单个项目执行特定的脚本。

通过使用 scripty,子项目的 package.json 文件中的 scripts 属性将变得非常精简:

{
  ...
  "scripts": {
    "test": "scripty",
    "lint": "scripty",
    "build": "scripty"
  },
  "scripty": {
    "path": "../../scripts/packages" // 注意这里我们指定了 scripty 的路径
  },
  ...
}

大功告成! 至此,我们尽己所能地删除了整个项目中的重复代码,让整个项目变得干净,清爽并且有极强的复用性。

pic_1e94afcd.png

小贴士:

别忘了使用 chmod -R u+x scripts 命令使所有的 shell 脚本具备可执行权限,也千万别忘了把这条贴士写在您的 README.md 文件中!

▐ 统一包管理:Lerna

pic_1c1e43a8.png (图片来源:https://github.com/lerna/lerna

我有时会感慨自己的灵感匮乏,怎么就想不到 Lerna 这样既有神话色彩又能自我释义的好名字。您可以大胆想象,九头龙的每只龙头都在帮您管理着一个子项目,而您只需要骑在龙身上发号施令的场景,这基本上就是我们使用 Lerna 时的直观感受。

这也是为什么当我们提起 monorepo 策略,就几乎不得不提到 Lerna 的原因了,它的确提供了一种非常便捷的方式供我们管理 monorepo 项目。当子项目越多时,Lerna 就越能显示其威力。

当多个子项目放在一个代码仓库,并且子项目之间又相互依赖时,我们面临的棘手问题有两个:

  1. 如果我们需要在多个子目录执行相同的命令,我们需要手动进入各个目录,并执行命令;
  2. 当一个子项目更新后,我们只能手动追踪依赖该项目的其他子项目,并升级其版本。

通过使用 Lerna,这些棘手的问题都将不复存在。

当在项目根目录使用 npx lerna init 初始化后,我们的根目录会新增一个 lerna.json 文件,默认内容为:

{
  "packages": ["packages/*"],
  "version": "0.0.0"
}

让我们稍稍改动这个文件,使其变为:

{
  "packages": ["packages/*"],
  "npmClient": "yarn",
  "version": "independent",
  "useWorkspaces": true,
}

可以注意到,我们显示声明了我们的包客户端(npmClient)为 yarn,并且让 Lerna 追踪我们 workspaces 设置的目录,这样我们就依旧保留了之前 workspaces 的所有特性(子项目引用和通用包提升)。

除此之外一个有趣的改动在于我们将 version 属性指定为一个关键字 independent,这将告诉 lerna 应该将每个子项目的版本号看作是相互独立的。当某个子项目代码更新后,运行 lerna publish 时,Lerna 将监听到代码变化的子项目并以交互式 CLI 方式让开发者决定需要升级的版本号,关联的子项目版本号不会自动升级,反之,当我们填入固定的版本号时,则任一子项目的代码变动,都会导致所有子项目的版本号基于当前指定的版本号升级。

Lerna 提供了很多 CLI 命令以满足我们的各种需求,但根据 2/8 法则,您应该首先关注以下这些命令:

# 向 @mono/project2 和 @mono/project3 中添加 @mono/project1
lerna add @mono/project1 '@mono/project{2,3}'

除了上面介绍到的常用命令外,Lerna 还提供了一些参数满足我们更灵活的需求,例如:

pic_7786d0a6.png

看到这里,您可能想要亲自体验一把使用 Lerna 管理/发布 monorepo 项目的感觉。可是很快您会发现,将示例代码发布到真实世界的 npm 仓库并非一个好主意,这多少有些令人沮丧,但是别担心,您可以使用 Verdaccio 在本地创建一个 npm 仓库作为代理,然后尽情体验 Lerna 的种种强大之处。

安装运行 Verdaccio 非常简单,您只需运行:

npm install --global verdaccio

在全局安装 Verdaccio 应用,然后在 shell 中输入:

verdaccio

即可通过 localhost:4837 访问您的本地代理 npm 仓库,别忘了在您的项目根目录创建 .npmrc 文件,并在文件中将 npm 仓库地址改写为您的本地代理地址:

registry="http://localhost:4873/"

大功告成 !每当您执行 lerna publish 时,子项目所构建成的 package 将会发布在本地 npm 仓库中,而当您执行 lerna bootstrap 时,Verdaccio 将会放行,让您成功从远程 npm 仓库中拉取相应的代码。

▐ 格式化 commit 信息

至此,我们已经掌握了组织一个项目级 monorepo 仓库的所有前沿技巧,最后,让我们看看最后一个可以优化的地方:代码提交时,约束 commit 信息。

一个 monorepo 仓库可能被不同的开发者提交不同子项目的代码,如果没有规范化的 commit 信息,在故障排查或版本回滚时毫无意外会遭遇灾难。因此,千万不要小看 commit 信息格式化的重要性。(当然,同样重要的还有代码注释!)

为了我们能够一目了然的追踪每次代码变更的信息,我们使用 commitlint 工具作为格式化 commit 信息的不二之选。

顾名思义,commitlint 可以帮助我们检查提交的 commit 信息,它强制约束我们的 commit 信息必须在开头附加指定类型,用于标示本次提交的大致意图,支持的类型关键字有:

我强烈建议您遵循该规范编写您的 commit 信息,不要偷懒,坚持下去,您的 git 日志将会显得整齐,有条理,富有表现力,同时,您也会收到同行的交口称赞,人人都会以和您这样优雅的工程师合作为荣。

除了限定 commit 信息类型外,commitlint 还支持(虽然不是必须的)显示指定我们本次提交所对应的子项目名称。假如我们有一个名为 @mono/project1 的子项目,我们针对该项目提交的 commit 信息可以写为:

git commit -m "feat(project1): add a attractive button" # 注意,我们省略了 @mono 的项目前缀

毫无疑问,这将会使我们的 commit 信息更具表现力。我们可以通过下面的命令安装 commitlint 以及周边依赖:

npm i -D @commitlint/cli @commitlint/config-conventional @commitlint/config-lerna-scopes commitlint husky lerna-changelog

注意到了吗?我偷偷安装了 husky,它能够帮助我们在提交 commit 信息时自动运行 commitlint 进行检查,但在这之前,我们需要再在根目录下的 package.json 文件里加点料,像这样:

{
 ...
 "husky": {
    "hooks": {
      "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
    }
  }
 ...
}

为了能够让 commitlint 感知我们的子项目名称,我们还需在项目根目录中增加 commitlint.config.js 文件,并设置文件内容为:

module.exports = {
  extends: [
    "@commitlint/config-conventional",
    "@commitlint/config-lerna-scopes",
  ],
};

至此,我们统一并规范化了 monorepo 项目的 commit 信息,终于整个 monorepo 工程化的最后一块拼图被我们拼上了!

(顺便一提,您可以通过在命令行执行 echo "build(project1): change something" | npx commitlint 命令即可验证您的 commit 信息是否通过 commitlint 的检查。)

如何从 multirepo 迁移至使用 monorepo 策略?

至此,我们学会了如何采用 monorepo 策略组织项目代码的最佳实践,或许您已经开始跃跃欲试想要尝试前文提到的种种技巧。从 0 搭建一个 monorepo 项目,当然没问题!可是如果要基于已有的项目,将其转化为一个使用 monorepo 策略的项目呢?

还记得吗?成百里者半九十,您还有一些坑要踩。不过好在您在这里还能够得到我的帮助,不必客气!

或许您注意到了,Lerna 为我们提供了 lerna import 命令,用来将我们已有的包导入到 monorepo 仓库,并且还会保留该仓库的所有 commit 信息。然而实际上,该命令仅支持导入本地项目,并且不支持导入项目的分支和标签 。

那么如果我们想要导入远程仓库,或是要获取某个分支或标签该怎么做呢?答案是使用 tomono,其内容是一个 shell 脚本。

使用 tomono 导入远程仓库,您所需要做的只有两件事:

  1. 创建一个包含所有需要导入 repo 地址的文本文件;
  2. 执行 shell 命令:cat repos.txt | ~/tomono/tomono.sh(这里我们假定您的文本文件名为 repos.txt,且您将 tomono 下载在用户根目录;

repo 文件内容示例如下:

// 1. Git仓库地址  2. 子项目名称  3. 迁移后的路径
git@github.com/backend.git @mono/backend packages/backend
git@github.com/frontend.git @mono/frontend packages/frontend
git@github.com/mobile.git @mono/mobile packages/mobile

至此,我们也掌握了将现有项目迁移至 monorepo 项目的方法。到这时候,您已绝非再是 monorepo 界的门外汉!恭喜您 !!

小结

在本篇文章中,我们共同了解了「什么是 monorepo 策略」以及「monorepo 策略的优劣」,并且一起学习实践了 monorepo 策略的一些最佳实践。您一定也意识到,即使您的工作场景暂时无法实践 monorepo 策略,阅读本篇文章所学习到的种种方法,工具和思想也可以运用到您当下的工作之中。

当然,本文所介绍的这些方法和思想总有过时的一天,并且社区也从未停止对更好地实践 monorepo 策略的探索,说不定您过一阵子就会有更好的想法 ,填补某个领域的空白。希望到时候您也能总结出一篇文章,为 JavaScript 社区贡献一份力量。到时候请千万别忘了回到我的评论区留言,让我分享您的成就。关于 monorepo 这个主题,我就暂且带您探索到这里,后会有期:)

参考文献

  1. JavaScript and TypeScript Monorepos
  2. Why you should use a single repository for all your company’s projects
  3. Advantages of monorepos
  4. lerna管理前端packages的最佳实践
  5. 基于lerna和yarn workspace的monorepo工作流
  6. Monorepos in the Wild
  7. Monorepos: Please don’t!
  8. Monorepo: please do!
  9. Introduction to Lerna
  10. monorepo 迁移实践

扩展阅读

  1. 介绍实践 monorepo 生态:awesome-monorepo
  2. 一篇介绍 Google 如何将数十亿代码通过 monorepo 方式组织的论文:Why Google Stores Billions of Lines of Code in a Single Repository
  3. 一篇针对 Google 的调研报告,详尽地分析了 monorepo 的优劣:Advantages and Disadvantages of a Monolithic Repository
ChelesteWang commented 3 years ago

引子 某天,有位同学 小 B 从一大早就眉头紧缩,午休过后,当大家还在睡眼朦胧之时,他突然拍案而起:“你们这个 common-A 包是不是有黑科技,为什么改 tsconfig 没有用的!”

这个时候,common-A 包的维护者小 A,默不作声的跑到小 B 的身后小声说道:“你点开你业务包的打包工具配置,是不是把 common-A 包加到 include 里面去了?”

小 B 啪啪两下在 VSCode 中打开了 ****.config.js ,里面赫然写着

{ tsLoader: (opts, { addIncludes }) => { addIncludes([/(packages|@monorepo_workspace)/]); }, } 小 B 依旧不解:“这有什么问题吗?”

小 A 摸摸 小 B 的头说道:“你的业务包是源码引用了 common-A 包,那 common-A 包的 tsconfig 当然不生效啦。”

哪里出了问题? Monorepo 给开发者提供的一大便利之一就是 —— 抽象公共包不用发版,在 repo 内就能引用。这项便利极大的刺激了团队内对于 common package 落地与迭代的积极性。

但是在业务包中引用 common 内的逻辑时,普遍采取 alias 与 loader 添加 include 将 common package 内的代码作为业务源码一同给到打包工具,一同编译,视作业务内代码,而非一个正常的 package。

在 monorepo 全 TS 场景的情况下,This works, but with hidden problems。

Package.json 被无视了 Common 包内定义的入口实际上是不生效的,业务包能够无视包入口引用任意一段 common 包内的逻辑,这给 common 包的维护带来了一定的困难。

TSConfig.json 被无视了 在 TS 的情况下,common 包自带的 tsconfig.json 中的配置将被无视,而是使用了业务包的相关配置。common 包需要适配所有业务包的 tsconfig 而非维护一个自洽的 tsconfig。

Phantom Dependency Common 包内引用的依赖是仅在 common 包内声明的,业务包使用时并不会去二次声明该依赖。但作为源码打包,实际上存在隐式依赖与依赖版本不确定的问题。

总的来说,在直接引用源码的情况下,common 包不再是一个包,而仅仅是一个文件夹,其中的 package.json 与 tsconfig.json 都仅仅是在自嗨,没有任何用处。

有解决方案吗? 我们的目的是将 common TS 包变成一个像在 npm 发布的包一样在业务包中被使用,真实的开发场景中我们往往还会关注以下几点:

因为现存的业务包较多,新的方案需要对原有业务包的改动较少(但不包括入口生效导致的代码变动)。 需要同时适配 node 项目与 web 项目。 Dev 时最好支持 common 包的改动即使生效,不需要额外的手动步骤。 其实解决方案有很多种,在与同学脑暴的过程中出现过无数天马行空的方案,但是大多数方案都存在 hack 过多或者开发成本过高的问题,综合下来可行性较高的只有两种依赖 git hook 自动编译或者使用 ProjectReferences。

自动编译 - w/ Git Hook 这项方案曾在隔壁组真实的试行过,即在 Git pull hook 中添加所有 common 包编译的脚本。

开发者在每次 git pull 的时候自动触发编译,将所有 common 包在本地编译一次。这对于只开发业务包的开发者来说,基本满足了日常需求

但是在 common 包与业务包同时开发的场景下,往往需要开两个 terminal 同时运行编译,而且业务包的 dev 进程很难感知到 common 包发生的变化。这就需要开发者频繁的手动重启业务包的 dev 进程,十分影响效率。

ProjectReferences TypeScript 在 3.0 中引入了新特性 Project References。 为较为细分的 TS 项目提供了细粒度 tsc 的能力。从 TS 的官方文档看,这项功能本意是为了满足同一个项目下对细分小模块进行独立编译提效例如单例测试的场景。从我们的视角来说,这很惊喜的满足了 monorepo 下 common TS 包的自动编译功能。 包含处理多个 tsconfig.json 链路依赖的能力。当 tsconfigA 中有 projectReferences 字段时,tsc 会先编译 projectReferences 中指向的 tsconfig,再最终编译 tsconfigA,同时也支持链路依赖,如 tsconfigA -> tsconfigB -> tsconfigC。 TSLoader 也支持了 ProjectReferences TSLoader 也从 5.2.0❤️ 开始支持了 projectReferences 能力,并在后续的几个迭代中显著的提升了其性能。基于 Webpack 的 TS 项目在使用 TSLoader 时,TSLoader 将会识别 tsconfig 中的 projectReferences 并将其交给 TSInstance 一并编译。 我们可以发现,在使用 ProjectReferences 的情况下,无论是一个需要 tsc 编译的 node server 项目或者是一个需要 webpack 打包的 web 项目,都可以被很好的支持。

如何实现呢? 首先需要确保 common 包本身的配置正确

Common 包与业务包的 tsconfig 需要符合 TS 的相关要求。common 包可以根据不同的使用情况配置两套 tsconfig,如 tsconfig.es.json + tsconfig.lib.json。 在 common 包的 package.json 中配置好一个正常的包应有的入口属性。 // pacakge.json { "name": "@monorepo_workspace/common-a", "version": "1.0.0", "description": "一个common包", "sideEffects": false, "exports": { ".": { "import": "./es/index.js", "require": "./lib/index.js" } }, "main": "./lib/index.js", "module": "./es/index.js", "typings": "./es/index.d.ts" } 在业务包中调整一些配置 打包工具中删除相关 include,打开 tsloader,并打开 projectReferences。 // some js config { tsLoader: (config) => { config.projectReferences = true; config.compilerOptions = undefined; } } 这里多说一句,这里之所以将 compilerOptions 设置为 undefined 是因为某些框架会默认配置一些 compilerOptions,这些在 tsloader config 中的 compilerOptions 将会覆盖 projectReferences 的包的 tsconfig,会引发一些奇怪的问题,所以这里设置 undefined 用来覆盖默认配置。 package.json 的依赖中确保 common 包的声明,且包管理工具能帮正确以包的形式找到 common 包。 tsconfig.json 的 projectReferences 中配置好对应要找的 common 包的 tsconfig 路径。 // tsconfig.json { "references": [ { "path": "../../common/common-a/tsconfig.es.json" }, { "path": "../../common/common-b/tsconfig.json" } ] } Path 可以写具体 tsconfig.json 的地址,也可以写包的路径,会自动读取文件夹路径下的 tsconfig.json 配置结束,去 Run Dev 一下,你就会看到在跑业务代码前,projectReferences 中的 common 包会被 TS build 一遍,然后在真正的打包过程中,你的打包工具终于把 common 包作为一个 REAL 包去对待。

是否可以更进一步? 上述 projectReferences 的方案已经在我们的 monorepo 中落地并跑了一段时间了,总体的评价还是不错的,而且组内的同学也能很快的理解并能够自己改造自己项目去使用 projectReferences。

但是“懒”是程序员的本质,在 tsconfig 中添加两行 references 也是一项额外的负担。

理论上在 ts-loader 之前加一个 webpack 插件或者是在 ts-loader 中提供 autoReference 的能力,是可以满足将本地包自动视作 projectReference 的。这个 idea 还在我们讨论的初期,如果有同学有兴趣,欢迎私聊共建。

未完待续 除了解决 common 包的引用问题,monorepo 内经历过诸如 node 部署,yarn.lock review 地狱,resolution 过多等问题,敬请期待我们的总结分享。

欢迎关注「 字节前端 ByteFE 」

简历投递联系邮箱「 tech@bytedance.com 」

ChelesteWang commented 2 years ago

lerna 解决了什么问题