Monorepo
Monorepo 是管理项目代码的一个方式,指在一个项目仓库 (repo) 中管理多个模块/包 (package),不同于常见的每个模块建一个 repo。
目前有不少大型开源项目采用了这种方式,如 Babel ,React 等。
Why Monorepo
在大型的类库开发中,往往会形成一个生态,包含很多子包,例如react 生态下,就有react,react-dom,react-server 等众多npm 包。对于使用者来说,每一个包都是一个独立的npm 包,但对于包的开发者而言,这些包虽然被设计为相互独立的,但开发和测试的过程中,有着千丝万缕的关系。例如:
- 代码规范和代码风格等应当统一。
- A package 依赖B package 时,如果B 有升级,应当更新A的依赖文件,并且测试A 是否依然符合预期。
- A package 依赖B package 时,如果修改A 的某个函数,需要B 的支持,则需要同时修改B,如果A,B在不同的仓库,修改和调试会及其麻烦。
- …
基于以上的种种因素,如果开发的package 不是一个完全孤立的包,而是一些有依赖关系的package 簇,那么monorepo 将是你最终的归宿。
How Monorepo
Monorepo 本质上是一种理念在代码组织方式上的实践。所以在很多维度,都会有对应的一些最佳实践,例如第三方依赖管理、版本号管理、DevOps、测试等环节,都有不同的解决方案,在一篇文章中,无法涵盖如此多的内容。本文主要分享下,如何通过workspaces来组织package 之间的依赖,以及结合typescript 自动编译交叉依赖包,从而实现一个最小可用的Monorepo 方案。
示例结构
如下所示,我们构建一个只有x-cli 和x-core两个包的Monorepo,在x-cli 中,用到了x-core的函数,产生了一个依赖关系。
.
├── node_modules/
├── README.md
├── package-lock.json
├── package.json
├── packages
│ ├── x-cli
│ │ ├── lib
│ │ │ ├── cli.d.ts
│ │ │ ├── cli.js
│ │ │ ├── cli.js.map
│ │ │ ├── main.d.ts
│ │ │ ├── main.js
│ │ │ └── main.js.map
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── cli.ts
│ │ │ └── main.ts
│ │ └── tsconfig.json
│ └── x-core
│ ├── lib
│ │ ├── index.d.ts
│ │ ├── index.js
│ │ └── index.js.map
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ └── tsconfig.json
├── tsconfig.build.json
└── tsconfig.json
复制代码
在项目开发的初期,x-cli 和x-core 都还没有发布,x-cli 如何引用x-core 呢?
-
使用相对路径吗?x-cli 里如果使用相对路径引用x-core,在x-cli 发布后,相对路径不一致,会导致出错。
-
使用npm link?可以实现,但不够优雅。
目前针对这样的场景,业界已经有了成熟的解决方案,就是使用workspaces。yarn 最早支持了workspaces能力,所以在很多关于monorepo 的文章中,都推荐使用yarn 作为包管理工具。不过从npm 7开始,npm 也支持了workspaces,虽然功能没有yarn 强大,但已经基本够用。本文将沿用npm 作为包管理工具。
Workspaces
这是npm 7 中加入的一个新属性,workspaces 是一个可选字段,值为文件pattern 的数组。执行install 命令的时候,npm 会遍历所有匹配的地址,将下面的包都软连接到项目的顶级node_modules 文件夹下,具体的使用方法可以参考这篇文章。
在下面的例子当中,只要 ./packages
文件夹下的子文件夹包含一个package.json 文件,都会被视为工作区。
我们打开package.json
文件,增加workspaces字段:
/* package.json */
{
"name": "npm-ts-workspaces-example",
"private": true,
...
"workspaces": ["packages/*"]
}
复制代码
然后在根目录执行npm install
,执行完成之后,我们可以看到,根目录安装了所有的依赖,包括x-cli 和x-core 里面的依赖也被安装了,而x-cli 和x-core 本身也被作为依赖包,软连到了node_modules 下面。
至此,我们便可以在x-cli 中,像使用其他依赖包一样使用x-core 了。
交叉依赖
我把在packages 里面的包之间的互相引用,叫做交叉依赖。
运行时交叉依赖问题
在使用了workspaces 之后,解决了我们很多runtime时候的包引用问题。由于是用软连接引用的交叉依赖,所以无论是在编译还是在运行时,都可以引用到正确的dist 文件。我们可以分析一下:
- X-cli 引用了@example/x-core
import { awesomeFn } from "@example/x-core";
复制代码
- @example/x-core 软连接到了packages/x-core
@example/x-core@1.0.0 -> /Users/study/npm-ts-workspaces-example/packages/x-core
复制代码
- x-core 导出的文件是ts 构建产物lib/index.js
{
"name": "@example/x-core",
"main": "lib/index.js",
"types": "lib/index.d.ts"
}
复制代码
所以只需要x-core 的构建产物lib/index.js是正确的,整个依赖就是正确的。
编译时交叉依赖
虽然看上去挺美好,但如果你实践过,总会发现不满意的地方。
- 假如我修改了x-core,引入了一个不兼容的change,发包之前却忘记跑一下x-cli,很容易导致x-cli 出现bug。归根到底,x-cli虽然依赖了x-core,但却不知道x-core 何时有变更,在编译的过程中没有体现出依赖关系。
- 在调试过程中,对x-core 的修改无法及时同步到x-cli中。要么手动构建x-cli,十分原始;要么在x-cli中watch 整个x-core文件夹,一方面对性能消耗特别大,另一方面,随着交叉依赖的增多,这个办法将失控。
我们想象中的理想态,应该是:
当我在开发x-cli 的时候,有一个feature 必须修改x-core 的源码。这时我去改动了x-core 的源代码,x-cli 应当实时监听到变更,重新编译代码,在开发环境中实时生效。
编译交叉依赖
经过了一番查阅,在TypeScript里我们可以使用references和composite 配置来解决ts 交叉依赖编译的问题。
首先我们明确几个概念:
TypeScript project
一个文件夹下,只要有一个合法的tsconfig.json 文件,那么它就是一个TypeScript project。
references
references
接收一个对象的数组,每个引用的path
属性都可以指向到包含tsconfig.json
文件的目录或者直接指向到配置文件本身.
当你引用一个工程时,会发生下面的事:
- 导入引用工程中的模块实际加载的是它输出的声明文件(
.d.ts
)。 - 如果引用的工程生成一个
outFile
,那么这个输出文件的.d.ts
文件里的声明对于当前工程是可见的。 - 构建模式(后文)会根据需要自动地构建引用的工程。
当你拆分成多个工程后,会显著地加速类型检查和编译,减少编辑器的内存占用,还会改善程序在逻辑上进行分组。
composite
引用的工程必须启用新的composite
设置。 这个选项用于帮助TypeScript快速确定引用工程的输出文件位置。 若启用composite
标记则会发生如下变动:
- 对于
rootDir
设置,如果没有被显式指定,默认为包含tsconfig
文件的目录 - 所有的实现文件必须匹配到某个
include
模式或在files
数组里列出。如果违反了这个限制,tsc
会提示你哪些文件未指定。 - 必须开启
declaration
选项。
总结
- 包含tsconfig.json文件的目录是一个TypeScript project。
- references可以引用其他的TypeScript project,在引用的TypeScript project在构建时会自动构建。
- 被引用TypeScript project 要设置
composite
字段,表示允许引用。
实现
- 由于x-core 是被引用的包,我们在tsconfig.json 中增加配置:
/* tsconfig.json */
{
"compilerOptions": {
...
"composite": true
}
}
复制代码
- 在x-cli中增加依赖配置:
/* packages/x-cli/tsconfig.json */
{
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"references": [{ "path": "../x-core" }]
}
复制代码
-
编译。
在x-cli文件夹下执行tsc 进行编译,却出现了一个报错。
这个错误让我查了很久,最后发现一句话:
为了兼容已有的构建流程,
tsc
不会自动地构建依赖项,除非启用了--build
选项。
也就是说,虽然tsc 就是编译ts,但是加--build
选项 和不加--build
选项 效果还是不一样的,?♀️。
让我们重新试一下:
tsc --build --verbose
复制代码
可以看到,由于x-core 中的dist 文件不存在,tsc 自动先进行x-core 编译,然后在进行x-cli 编译,完全符合预期。
-
Watch。监听依赖变更,自动编译。
我们更希望,在开发x-cli 的时候,如果x-core 有所变更,x-cli能够及时侦测到,并自动编译。让我们试一试加上–watch 字段后是否能实现。
tsc --build --verbose --watch 复制代码
可以看到,当x-core 有更新的情况下,x-cli 的tsc 会自动编译,完全符合预期。

至此,我们想要的,支持交叉依赖的monorepo开发工作流搭建完成了。
优化
增量编译
在监听模式下,如果每次有变更,就会引起相关的TypeScript project 整个重新编译,很多时候不仅被必要,还会导致电脑CUP 和内存占用过度卡顿问题。我们可以开启TypeScript 的增量编译能力,开启后,第一次编译会生成一个tsconfig.tsbuildinfo 文件,记录了每个ts文件的signature信息,后续再有更新,就只会更新变动的文件,大大提升编译效率。
{
"compilerOptions": {
"incremental": true
}
}
复制代码
配置共用
Monorepo 本身要解决的问题之一,就是复用整个仓库里面的各种各样的配置文件,包括eslint、tsconfig等,一方面减少冗余,另一方面消除不一致,保证整个仓库的代码风格一致。
这里我们可以把x-cli和x-core 里面的tsconfig文件中的公共配置,提升到项目的根目录,每个子项目只是去继承根目录的配置,就能很好的保证配置项的唯一性。
/* /tsconfig-base.json */
{
"compilerOptions": {
"incremental": true,
"target": "es2019",
"module": "commonjs",
"declaration": true,
"sourceMap": true,
"composite": true,
"strict": true,
"moduleResolution": "node",
"baseUrl": "./packages",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
复制代码
/* /packages/x-cli/tsconfig.json */
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"references": [{ "path": "../x-core" }]
}
复制代码
结语
在开发jsonschema生态的npm 包过程中,我们遇到了多包交叉引用、交叉编译、配置不一致、版本号、第三方依赖的相关问题,也在逐渐的探索Monorepo 的解决方案,在近期的尝试过程中,已经获得了不错的收益。对于有类似场景的同学,十分值得尝试。同时,在整个monorepo的工作流中,我们依然还有很多带优化的空间,欢迎大家多多分享和交流。
参考文档
加入我们
字节搜索&技术中台前端团队期待你的加入
这里有今日头条、抖音、西瓜视频、火山小视频、TopBuzz、皮皮虾、教育、安全、游戏等等多个业务线的机会任你挑选。
内推直通车:pengchaoyang@bytedance.com
内推码:【RWQMBNH】
标题格式:校招/实习/社招-姓名-意向岗位-城市
查询页面:job.toutiao.com/s/eC53ysY
职位详情:
- 参与公司搜索业务研发工作Hybrid/Wap/Web;
- 参与搜索云平台建设,提供全链路配置、中台化、内容引入;
- 参与高并发SSR服务建设和运维,深入搜索性能优化与稳定性建设;
- 参与搜索微前端体系建设,研发工具链打造DevOps解决方案;
- 参与可视化编辑器等工具研发,利用技术手段提升搜索研发效率;
- 参与搜索Node.js基础功能抽象,研发基础库和OpenAPI。