1. 背景
随着部门业务不断迭代,沉淀了大量的业务组件库。初期业务系统不复杂时,组件库通常都是 Multirepo 方式组织(即一个组件库一个仓库),随着业务复杂度不断提升,模块数量飞速增长,Multirepo 虽然从业务逻辑上解耦,但也增加了工程管理难度:
- 依赖关系不清晰。组件库下游被哪些业务工程依赖难以确定,回归容易遗漏
- 配置无法共享。每个代码仓库都需要一些通用的工程化能力配置,例如 build/test/lint/ci 等,多个仓库的配置无法同步,导致配置存在不一致问题,造成潜在隐患,对工程的优化非常不利(例如很多老工程还在用废弃的 NPM 镜像)
- 依赖管理复杂。组件库往往有大量公共依赖,如果 Multirepo 方式,每个工程的
node_modules
下都会重复安装,占用大量存储空间,而且存在版本不一致问题,无法同步升级 - 本地开发调试困难。基于 Multirepo 方式一个业务工程往往需要通过
yarn link
关联另一个组件库进行开发,由于不在一个根目录下,Webpack 等打包工具对peerDependencies
寻址会存在问题,给本地开发增加难度 - 代码共享流程复杂。组件库代码修改后,需要手动到每个业务工程中升级版本,再打包构建,效率较低。而且部门内部共建项目越来越多,研发资源共享、跨团队组件库复用的问题变得越来越重要
2. 为什么基于 Monorepo 搭建业务组件库
有些同学可能会疑惑,原先代码都在一个仓库,后来为了解耦、单一职责,把各模块拆分到不同仓库,现在 Monorepo 又开始把模块整合到一个仓库,这样意义在哪里?
这里要特别说明的是,Monorepo 绝不是简单地将代码搬到一个仓库(即不等于 Monolith)。在 Monorepo 中,每个子模块仍然是独立的,有独立的版本,可以独立发包。但是与 Mulitrepo 不同,Monorepo 中的子模块可以代码共享,可以最大程度复用依赖、复用工作流、复用基础配置,最重要的是 Monorepo 的 build、test 都是增量的,可以让 CI 构建更快。这些都是 Multirepo 无法做到的。
首先 Monorepo 作为开源趋势之一,与 TypeScript、PNPM 一样火爆,知名开源项目都在逐步迁移到 Monorepo,例如 Vue3、Vite、Umi、Element Plus 等等。基于 Monorepo 还可以结合各种自动化工具提升开发效率。
其次 Monorepo 作为一种通用的管理方式,并不局限于前端,在其他语言也有涉及,例如 Go 1.18 引入 的 workspace 特性,允许在一个仓库管理多个包,改善了本地第三方库调试的问题。
此外 Monorepo 还具有以下优点。
1) 便于代码维护、管理
云课堂很多存量项目都是基于 Multirepo,业务工程和依赖库分开维护,一部分依赖库通过部门内部 NPM 私有源实现代码共享,还有少部分没有通过 NPM,而是直接在 package.json
中引用仓库地址(通过 git tag 区分版本)实现下载依赖,例如:
json
{
"dependencies": {
"log-sdk": "git+https://getRep:ozKCoV9qVWbiWMC-Vz5h@g.hz.netease.com/ykt-adult-front/log-sdk.git#0.0.5",
"poseidon-web": "git+https://getRep:ozKCoV9qVWbiWMC-Vz5h@g.hz.netease.com/ykt-adult-front/poseidon-web.git#v1.0.9"
}
}
与 React、Antd 等开源项目不同,业务组件库需要频繁更新,这种做法导致依赖关系不清晰,维护组件库的同学,很可能不清楚下游被哪些业务工程依赖,增加了组件库维护成本,而且回归容易遗漏。此外在这种模式下,组件库的发包流程也非常繁琐,每次业务工程上线之前,需要先到每个组件库打包构建,然后 npm publish 或者打 tag,然后到每个依赖该库的业务工程升级版本、打包构建,整体复杂度随工程数量增加呈现 O(M*N)
增长,极大增加了人力和时间成本。
在 Monorepo 模式下,组件库和业务工程都在一个仓库,通过一种特殊的 Workspace protocol 互相引用:
json
{
"dependencies": {
"shared-utils": "workspace:*"
}
}
这种模式下,依赖关系变得更清晰,而且组件库和业务工程的依赖不再通过 NPM,而是直接依赖仓库的版本。当修改组件库代码之后,业务工程自然就依赖了最新版本的组件库。借助 Turborepo 等增量记忆化框架,Monorepo 可以做到更快的增量构建,在某个工程依赖更新之后,自动构建该工程,并且不做重复构建;此外能够以类似于瀑布方式同时异步执行多个任务,优化任务编排效率。
2) 组织形式灵活,支持多种目录结构
在 Turborepo 官网有一个案例,假设原先有 web
、docs
、app
三个业务工程的 Multirepo 目录结构如下:
bash
web (repo 1)
├─ package.json
docs (repo 2)
├─ package.json
app (repo 3)
├─ package.json
若将其改造为 Monorepo 项目,目录结构如下:
bash
my-monorepo
├─ apps
│ ├─ app
│ │ └─ package.json
│ ├─ docs
│ │ └─ package.json
│ └─ web
│ └─ package.json
└─ package.json
这些子模块没有共享依赖,意味着存在很多重复代码,可以将公共依赖抽提,作为 internal package:
bash
my-monorepo
├─ apps
│ ├─ app
│ │ └─ package.json
│ ├─ docs
│ │ └─ package.json
│ └─ web
│ └─ package.json
├─ packages
│ └─ shared
│ └─ package.json
└─ package.json
如果使用 PNPM 作为包管理工具(其他包管理工具也类似),上面这种目录结构对应的 pnpm-workspace.yaml
配置如下:
yaml
packages:
- "apps/*"
- "packages/*"
实际上,PNPM Monorepo 组织形式比较灵活,支持多种目录结构,满足各种工程化需求。例如开源项目 UMI 的配置如下:
yaml
packages:
- "packages/*"
- "examples/*"
- "libs/*"
- "codemod"
- "did-you-know"
- "scripts"
从上面的配置可以看出,Monorepo 中的"包"并不局限于业务工程、公共组件库,甚至也可以包括文档网站、通用工程化脚本等等。在传统 NPM 的模式下,创建"包"的成本比较昂贵,需要频繁发包操作、管理各工程引用的版本,但是在 Monorepo 模式下,创建"包"的成本非常低,任何可复用的模块都可以提取为单独的"包"。
在 umijs/father
项目中,甚至 workspace root 也可作为一个子模块:
yaml
packages:
- "./"
- "examples/*"
- "boilerplate"
- "tests/fixtures/build/classic-jsx"
- "tests/fixtures/build/automatic-jsx"
3) 优化开发流程、提升开发效率
云课堂很多存量的业务组件库,本地调试都非常不便,甚至有一些本地都无法启动,需要接入业务工程看效果,每次修改代码,都需要重新打包、将构建产物提交到代码仓库,在业务工程重新 yarn install
、yarn build
看效果,开发效率非常低。
有些同学可能会说,本地开发关联另一个组件库,可以用 yarn link
呀。yarn link
确实可以解决一部分问题,通过创建 symlink,打包工具在 node_modules
下查找该模块的时候,会自动链接到对应的文件目录,实现本地调试。
但是这种方式仅适用于不涉及 peer / shared dependencies 的情况,如果遇到 peerDependencies
,会导致打包工具依赖寻址出错,最终无法正常打包。例如当前目录的 node_modules
下没有该依赖,应该去上一层目录的 node_modules
下查找,但是由于通过 symlink 链接到文件目录,所以上一层已经不是打包工程所在的目录了。这种情况在业务组件库中非常常见,因为业务组件库存在很多共享的依赖,例如 react
、antd
、classnames
等等,这些依赖在组件库发包的时候不能被打包,而是直接 import
宿主环境依赖(打包进去轻则造成模块冗余,重则破坏单例模式,例如 React 多实例会报错)。
本地开发关联子模块的问题,确实也有一些解决方案,例如通过 Webpack 的 resolve.modules
配置,修改打包器默认寻址逻辑,对于第三方库,一律都到指定的目录下搜索:
js
module.exports = {
//...
resolve: {
// 对于第三方库,一律都在打包工程所在目录的 node_modules 中搜索
modules: [path.resolve(__dirname, 'node_modules')],
},
};
这样能解决调试问题,但是需要修改 Webpack 配置,而且 resolve.modules
配置并不是很常用,一定程度上增加了心智负担。另外,按上面这样配置,修改文件保存不一定能触发打包工具增量编译,还需要修改 watch 监听范围。有没有零配置的解决方案呢?有,Monorepo 天然支持组件库关联调试,对代码共享非常友好。
在 Monorepo 管理的工程中,子模块如何互相引用。以 PNPM 为例,我们可以先执行下面的命令,将需要引用的模块安装到项目根目录:
bash
$ pnpm add @study/common-ykt-header -w
@study/common-ykt-header
是package.json
中的包名,-w
代表 Workspace Root,启用 Monorepo 之后需要指定依赖安装位置
安装之后,直接 import
包名就行,而且打包也很正常。值得一提的是,这里的"安装",其实也是一种软连接,前面提到的的 yarn link
是链接不同的仓库,这边链接的是同一仓库下的模块。有同学会问,为啥这里不会遇到 peerDependencies
寻址出错问题?这里有个前提:共享的依赖,也必须安装在 Workspace Root 统一管理(既可以解决依赖重复安装问题,又解决了版本不一致问题)。这样打包工具在解析子模块依赖的时候,都会到 Workspace Root 下的 node_modules
搜索。
4) Monorepo 的缺点
Monorepo 虽然解决了 Multirepo 很多问题,但是也存在一些缺点:
- 权限管理问题。由于代码都在一个仓库,传统的权限管理机制不能用了,容易产生非 owner 改动的风险
- 代码管理。代码全在一个仓库,如果项目比较大(几个G),用 Git 管理会存在问题,git clone、安装依赖也会比较耗时,编辑器也会比较卡顿
对于第一个问题,社区已经有相应的解决方案了,例如 GitHub 有一个 CODEOWNERS,可以将权限粒度细分到目录级别,提交 PR 需要所有涉及到权限目录的 owner 审核,例如:
bash
apps/app-a/* @susan
apps/app-b/* @bob
但是该方案并没有解决代码可视隔离问题,非 owner 仍然可以看到代码,相信后续代码托管平台会不断完善。
对于第二个问题,社区暂时没有可用的解决方案(微软发布了 GVFS,用于管理大型仓库的可扩展 Git 版本,可以部分解决该问题)。但是考虑到现在开源项目都在不断往 Monorepo 迁移(包括很多大型项目),而且现在业务中也没有特别巨大的工程,整体规模可控,没有理由影响 Monorepo 的落地。
3. Monorepo 方案技术选型
在实际场景落地 Monorepo,是需要有一套完整的工程化体系来保障的。关注 Monorepo 的同学,可能都看过这篇文章 monorepo.tools,其中列举了一些主流的 Monorepo 工具:
- Bazel (by Google)
- Lage (by Microsoft)
- Lerna
- Nx (by Nrwl)
- Rush (by Microsoft)
- Turborepo (by Vercel)
1) Lerna vs Turborepo
Monorepo 首先需要解决 NPM scripts 执行问题。在 Multirepo 中,通常会用 &&
直接串行执行,或者 concurrently
、npm-run-all
等工具实现简单并行任务,但是 Monorepo 由于每个包都有独立的任务,原先的任务执行机制不能用了,需要引入新的工具。
其中 Lerna 可能被很多同学所熟知。作为一个非常老牌的 Monorepo 工具,Lerna 最初由 Babel 团队创建,后来经历了一段波折,现在由 Nrwl 团队维护(Nx 背后的公司)。Lerna 本身是一个相对简单的工具,主要目的就是实现发包自动化,可以用来批量运行每个子模块的 NPM scripts、管理包的版本以及发包,借助 Nx 还可以实现构建任务缓存、分布式缓存、分布式任务执行等等。虽然 Lerna 也有 Pipeline 的概念,但是整体比较简单,并不适用于子模块代码共享的场景(无法解决模块之间互相引用的问题,例如更新某个组件库的代码,所有依赖该库的业务工程需要重新构建)。
Turborepo 可以认为是 Nx 和 Lerna 的结合体,专注于 package,但在工作空间中提供了和 NX 类似的功能,例如增量构建、并行执行、远程缓存和任务管道。有些同学可能对 Turborepo 比较陌生,但是 Vercel 应该都比较熟悉,挖了很多社区大佬,拥有一堆轮子,Turborepo 就是 Vercel 旗下的项目(前不久跟 Next.js 13 一起发布的还有 Turbopack),大家可能已经忘了他是一个部署平台了,哈哈。由于 Turborepo 基于 Rust 开发,任务执行效率相比 Lerna 有很大提升,另一方面 Turborepo 可以优化任务编排效率,尽可能让任务并行运行,充分利用 CPU 资源。
假设有 A、B、C 三个子模块,B 是组件库,A 和 C 是业务工程,每个子模块都有 lint
、build
、test
三个任务,这里就存在一个任务执行的先后关系,即需要先 build
B,然后才能 build
A 和 C。按照 lerna 的执行流程,虽然每个模块之间的任务都是并行的,但是模块内部的任务却是串行的,这就导致 build
B 的时候,其他模块的工作流都处于空闲状态,造成一些性能浪费;此外 lint
和 test
并不冲突,可以并行执行。相比之下,Turborepo 在每个模块都可以并行执行任务,并且这些任务可以共享,例如在 build
B 的时候,可以先执行 A 和 C 的 lint
和 test
,高度优化任务编排效率。
随着代码体量变大,基础包变多,依赖关系错综复杂,Lerna 的构建效率会越来越低,如果要靠人力去理清楚构建链路上的顺序再进行优化,难度非常大,而且会产生指数级的维护困难。Turborepo 就是这个拓扑依赖构建的最优解,只需要提供最少的配置,可以自动帮我们收集依赖关系,然后生成一个最优构建链路,按照这个最优策略去并发构建,极大提升构建效率。
2) PNPM vs Yarn workspace
Monorepo 项目还需解决依赖管理、子模块之间互相引用问题。很多主流包管理工具(npm、yarn)都支持 workspace,但是这里推荐 PNPM。
PNPM 被誉为最先进的包管理工具,其中两个最突出的优点就是:节省磁盘空间、提升安装速度。我们知道,npm、yarn 有一个全局缓存,所有安装过的包都会在这个缓存中,下次安装依赖的时候,如果缓存命中,就会从全局缓存中复制到项目根目录的 node_modules
中,这就导致全局会存在多个副本。PNPM 引入了另一套依赖管理策略:内容寻址存储 。该策略会将包安装在系统的全局 store 中,依赖的每个版本只会在系统中安装一次。在引用项目 node_modules
的依赖时,会通过硬链接与符号链接在全局 store 中找到这个文件。为了实现此过程,node_modules
下会多出 .pnpm
目录,而且是非扁平化结构。同时,由于链接的优势,pnpm 的安装速度在大多数场景都比 npm 和 yarn 快 2 倍,节省的磁盘空间也更多。
这套全新的机制设计地十分巧妙,不仅兼容 node 的依赖解析,同时也解决了 npm、yarn 令人诟病的历史遗留问题:幽灵依赖和依赖分身问题。我们知道,在 npm@v3 之前,node_modules
是嵌套结构,会出现大量重复装包问题,而且嵌套层级过深,导致文件路径过长。npm@v3 / yarn 开始用扁平化的方式安装依赖,将子依赖提升(hoist)到根目录,不会造成大量包的重复安装,依赖的层级也不会太深,解决了依赖地狱问题,但也形成了新的问题。由于使用扁平化方式安装,没有在 package.json
里声明的包竟然也可以在项目中使用了,这就是幽灵依赖(Phantom dependencies)。此外,扁平化安装并没有完全解决依赖重复安装问题,安装同一个包的不同版本,只会提升一次,第二次会嵌套安装。例如先安装 1.0 版本,会提升到根目录,再安装该包的 2.0 版本,由于根目录已经存在 1.0 版本,2.0 会嵌套安装,如果有两个包都依赖 2.0 版本,则会造成 2.0 版本安装两次,这就是分身依赖(Doppelgangers)问题。相比之下,PNPM 是非扁平化结构,只有直接依赖会平铺在 node_modules
下,子依赖不会被提升,不会产生幽灵依赖;相同的依赖只会在全局 store 中安装一次。项目中的都是源文件的副本,几乎不占用任何空间,没有了依赖分身。
但是 PNPM 也存在一些弊端:
- PNPM 依赖软连接,在不支持软连接的环境中无法使用(例如 React Native)
- 有时候需要在 Webpack 配置引用
node_modules
中的路径,直接path.resolve(__dirname, "./node_modules/xxx")
拼接路径会有问题 - 由于依赖源文件安装在全局 store,调试依赖或者
patch-package
打补丁也不太方便
对于第一个问题,Metro 打包不支持解析软连接,但是社区已经有解决方案了,例如微软开发的 @rnx-kit/metro-resolver-symlinks
可以让 Metro 解析软连接。第二个问题,手动拼接 node_modules
路径是一种不规范的做法,正确做法应该用 Node 提供的 require.resolve()
API 通过 Node 依赖解析规则查找模块路径。第三个问题,PNPM 官方提供了 patch 命令,支持打补丁或者调试。
如果还没有用过 PNPM,推荐看一下这篇文章:
3) Changesets vs Rush
虽然 Monorepo 内部子模块可以非常方便实现代码共享,但有时也需要通过 NPM 包形式共享给外部的工程。
在 Multirepo 中,包管理非常简单,可以手动维护 package.json
版本,也可以用 release-it
之类的工具实现自动化发包。但是在 Monorepo 下,包管理是一个非常复杂的工作,一方面包的数量增加了,需要大量重复操作;另一方面需要理清楚模块之间的依赖关系,当某个包依赖的包发生 upgrade 之后,也需要升级该包的版本。遗憾的是 pnpm 没有提供内置的解决方案,但是官方荐了两个开源的版本控制工具:
- changesets
- rush
这两种工具都可用,star 数也都比较接近。这里推荐用 Changesets,很多知名开源项目都在用(甚至包括 PNPM),文档更加清晰,上手容易,关键还非常有仪式感。Changesets 即变更集,主要负责管理包的版本、生成 CHANGELOG,为 monorepo 项目设计(下面会详细介绍)。
这里要说明的是,Monorepo 的很多工具不是必须的,主要还是看场景。例如 Vue 生态的很多 Monorepo 项目,都是尤大自己写的构建、发布脚本。
4. 从零到一搭建 Monorepo 项目
现在有一些开箱即用的 CLI 工具,用于快速创建 Monorepo 项目,例如 create-turbo
:
bash
$ pnpm create turbo@latest
需要 Node 16 以上版本
执行完成之后,就可以得到一个 demo 工程,包含 packages/ui
组件库共享模块,以及 apps/web
业务工程和 apps/docs
文档工程。
这个工程本身比较简单,而且如果要应用到实际业务场景,还有大量配置细节需要完善。因此我们这边通过手动搭建一个业务组件库,以便更好地理解 Monorepo 项目。
1) 安装 PNPM & 工程初始化
由于我们使用 pnpm 管理 Monorepo,首先全局安装 pnpm。注意 pnpm@v7 需要 Node v14 +,毕竟现在 Node V18 已经是 LTS 版本了,个人建议如果还在用 Node v14 或更低版本,尽快升级到 v16 或者 v18。
bash
$ npm i -g pnpm
接下来就是初始化仓库,这一步不用多说:
bash
$ mkdir my-monorepo && cd my-monorepo
# 初始化 package.json
$ pnpm init
接下来在工程根目录下建一个 packages
目录,在里面创建 header
和 footer
两个工程,分别进到两个目录下,执行 pnpm init
分别进行初始化,修改 package.json
中的 name
字段为 @study/common-ykt-header
、@study/common-ykt-footer
。
这一步比较关键,后续构建、发包都需要用到这个包名。
@study
是提前创建好的 scope,如果没有的话需要先创建
在工程根目录建一个 pnpm-workspace.yaml
,用于启用 workspace :
yaml
packages:
- "packages/*"
- "scripts"
由于工程根目录 package.json
不需要发包,需要配置 "private": true
。
接下来在工程根目录,也就是 Workspace Root,安装一些全局依赖:
bash
$ pnpm add vite @vitejs/plugin-react vitest -Dw
$ pnpm add typescript -Dw
$ pnpm add react react-dom -w
$ pnpm add @types/react @types/react-dom -Dw
如果启用 workspace,用 PNPM 安装依赖必须指定安装的位置。-w
是 --workspace-root
的别名,即安装到工程根目录,作为所有子模块的公共依赖。也可以用 -r
递归给每个子模块安装,或者用 --filter <package_name>
给指定子模块安装。-D
是 --save-dev
的别名,即安装依赖到 devDependencies
节点下,不指定参数默认安装到 dependencies
节点。
在 workspace 模式下,PNPM 默认会共享所有子模块的 lockfile,所有子模块的依赖会安装在同一个
node_modules
里面,再通过 symlink 软连接到每一个子模块的node_modules
下,好处是即使-r
递归安装,也不会造成依赖重复安装,确保单例,同时提升了安装速度
如果模块之间需要互相引用,可以直接安装对应的包名:
bash
$ pnpm add @study/common-ykt-header --filter=@study/common-ykt-footer
查看 package.json
可以看到多个一个依赖:
json
{
"name": "@study/common-ykt-footer",
"version": "1.0.0",
"dependencies": {
"@study/common-ykt-header": "workspace:^1.0.0"
}
}
通过 PNPM 提供的 Workspace Protocol,可以很方便地实现子模块互相引用。在开发的时候,推荐 workspace:*
,这样可以确保依赖的是最新版本,不用手动修改。当我们用 pnpm publish
发包的时候,PNPM 会将 workspace:
替换为实际的版本。
还有一点需要注意,虽然我们工程里面用了 PNPM,考虑到多人协作因素,有些同事可能还是习惯性用 npm install
或者 yarn install
安装依赖,这不仅导致项目根目录出现多个 lockfile,在 PNPM workspace 模式无法兼容,整个工程很可能跑不起来。我们可以用一个库 only-allow
去限制包管理器,当用了其他包管理器,会直接抛异常退出进程:
json
{
"scripts": {
"preinstall": "npx only-allow pnpm"
}
}
配置后执行 yarn install
效果如下:
bash
$ yarn install
Use "pnpm install" for installation in this project.
If you don't have pnpm, install it via "npm i -g pnpm".
For more details, go to https://pnpm.js.org/
此外,就算用对了包管理器,包管理器版本、Node 版本还存在不一致问题。为了确保多人协作版本一致性,我们可以在 package.json
中添加 engines
字段,PNPM 在安装依赖之前,会检查用户机器上的包管理器和 Node 版本与 engines
配置是否一致,如果不一致则抛异常退出安装流程:
json
{
"engines": {
"node": ">=14",
"pnpm": ">=7"
}
}
我们将 engines.pnpm
改为 >=8
,尝试执行 pnpm install
效果如下:
bash
$ pnpm install
ERR_PNPM_UNSUPPORTED_ENGINE Unsupported environment (bad pnpm and/or Node.js version)
Your pnpm version is incompatible with "D:\workbranch\dev\common-nav-header".
Expected version: >=8
Got: 7.17.1
This is happening because the package's manifest has an engines.pnpm field specified.
To fix this issue, install the required pnpm version globally.
To install the latest version of pnpm, run "pnpm i -g pnpm".
To check your pnpm version, run "pnpm -v".
还有一个要关注的是 .npmrc
配置。PNPM 扩展了该配置,添加了很多配置项,有一些我们需要关注:
yaml
# 设置用于发包的 NPM 私服地址,后面发包会用到
@study:registry=https://registry.npmmirror.com/
# 自动安装任何缺少的 peerDeps,默认值:false
# 适用于组件库开发,无需手动安装宿主环境依赖
auto-install-peers=true
# peerDeps 缺失时安装依赖不报错,默认值:false
# 注意在 v7.0.0 和 v7.13.5 之间的版本为 true
strict-peer-dependencies=false
# PNPM 默认不会执行自定义 pre/post 脚本
enable-pre-post-scripts=true
# 提升所有依赖到根目录,不建议启用
shamefully-hoist=true
其中 auto-install-peers
可以便于组件库本地调试。我们知道,yarn
、pnpm
默认不会安装 peerDependencies
,本地调试一般会把这些依赖在 devDependencies
里面也复制一份,这样本地开发也能安装了,而且不影响发包(用户装包的时候,devDependencies
是忽略的)。但是这样会比较麻烦,而且 package.json
依赖会比较乱。添加 auto-install-peers
配置就不需要我们操心了,PNPM 会自动安装。
另一个需要关注的是 shamefully-hoist
。这个配置在 PNPM 官网有一段说明:
By default, pnpm creates a semistrict node_modules, meaning dependencies have access to undeclared dependencies but modules outside of node_modules do not. With this layout, most of the packages in the ecosystem work with no issues. However, if some tooling only works when the hoisted dependencies are in the root of node_modules, you can set this to true to hoist them for you.
默认情况下,pnpm 创建半严格的 node_modules
,这意味着在第三方库中可以访问未声明的依赖,但 node_modules
之外的模块不能访问。通过这种设置,大多数包都可以正常工作。但是,如果某些工具包需要将所有的依赖提升到位于 node_modules
的根目录中才起作用时,则可以将设置 shamefully-hoist = true
来提升所有的依赖。
2) 工程化配置
安装 Vite 是因为我们选用 Vite 作为构建工具,当然也可以选用 Rollup、father、Webpack,这里选用 Vite 是因为自带 DevServer,比较轻量,与测试工具 Vitest 集成非常方便。之前我们说过,Monorepo 可以实现配置复用,确保每个子模块的配置一致性。我们可以在项目根目录建一个 vite.config.base.ts
,定义公共打包配置:
ts
import path from "path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import theme from "./theme";
const NODE_ENV = process.env.NODE_ENV || "development";
const isEnvProduction = NODE_ENV === "production";
const isEnvDevelopment = NODE_ENV === "development";
const workDir = process.cwd();
// https://vitejs.dev/config/
export default defineConfig({
css: {
preprocessorOptions: {
less: {
javascriptEnabled: true,
modifyVars: theme,
},
},
},
plugins: [
react({
jsxRuntime: "classic",
}),
],
test: {
environment: "jsdom",
setupFiles: path.resolve(__dirname, "./vitest-setup.ts"),
},
define: {
__DEV__: process.env.NODE_ENV !== "production",
},
build: {
...(isEnvProduction && { target: "es2015", cssTarget: "chrome61" }),
...(isEnvDevelopment && { minify: false }),
outDir: "dist",
lib: {
formats: ["cjs", "es"],
entry: path.resolve(workDir, "./src/index.tsx"),
// UMD 格式的包名
name: "MyLib",
fileName: (format) => `index.${format}.js`,
},
rollupOptions: {
external: ["react", "react-dom", "classnames"],
output: {
// CSS、图片等资源的文件名
assetFileNames: "[name].[ext]",
chunkFileNames: "[name].js",
},
},
},
});
然后我们在每个子模块目录下添加 vite.config.ts
。由于 vite.config.ts
不支持继承,但是 Vite 提供了 mergeConfig
方法,可以用于合并配置(类似 webpack-merge
):
ts
import path from "path";
import { defineConfig, mergeConfig } from "vite";
import baseConfig from "../vite.config.base";
// https://vitejs.dev/config/
const config = defineConfig({
server: {
// open: true,
// port: 8066,
hmr: true,
},
resolve: {
alias: {
"@packages": path.resolve(__dirname, "../packages"),
},
},
});
export default mergeConfig(baseConfig, config);
有同学会问,为啥 Vite 可以用 TS 配置?一方面是 Vite 本身支持,另一方面本人希望仓库里面 TS 代码看起来多一些。如果你用了 TS 配置,Vite 内部会用一个叫
jsx
的 TypeScript Runtime 去执行。但是如果用 TS 配置文件,生成 TS 类型定义会有坑,下面会讲
细心的同学可能会关注到,vite.config.base.ts
里面有一个 test
配置项,没错,这里就是 Vitest 的配置,可以与 Vite 公用同一份配置。Vitest 是一种新的测试工具,比 jest 等工具速度更快,而且还有热更新功能,可以以 watch 模式运行,监听代码修改,然后只重新运行与修改代码有关的测试用例,非常高效。但是本文并不涉及 Vitest 相关内容,感兴趣的同学可以自己去官网了解:
接下来我们在每个子模块的 package.json
中添加几个 NPM script:
json
{
"scripts": {
"build": "vite build --mode production",
"test": "vitest"
}
}
这样一来,每个子模块都有自己的打包配置和 NPM scripts,但是如果跑到每个子模块下分别执行 NPM scripts 效率太低,我们可以在项目根目录的 package.json
中添加命令,用来批量执行脚本:
json
{
"scripts": {
"build": "pnpm -r --parallel --filter=./packages/* run build",
"test": "pnpm -r --parallel --filter=./packages/* run test"
}
}
这里我们暂时用 PNPM 给我们提供的
-r
参数递归执行 NPM scripts。注意这样会存在一些问题,下面我们会用 Turborepo 实现更高效的任务编排
安装 typescript 是因为我们搭建的是 TS 项目。这里 typescript 我看到有两种安装方式,一种是直接安装到 Workspace Root,另一种是用 -r
参数递归给每个子模块安装,这两种都可以,这里用的是第一种方案。
TS 项目里面都需要有 tsconfig.json
配置,可以用 tsc --init
命令初始化一份 tsconfig.json
。该配置可以支持继承写法。我们可以在项目根目录定义 tsconfig.base.json
:
json
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"jsx": "react-jsx"
},
"exclude": [
"**/node_modules",
"**/examples",
"**/dist",
"**/fixtures",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.e2e.ts",
"**/templates"
]
}
然后在项目根目录,以及每个子模块都创建一份 tsconfig.json
,继承该配置:
json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"declarationDir": "./typing"
},
"include": ["./src"]
}
这边用了 tsc
进行类型检查和生成 .d.ts
声明文件。接下来在每个子模块的 package.json
添加两个 NPM scripts:
json
{
"scripts": {
"build": "vite build --mode production",
"build:type": "tsc --emitDeclarationOnly",
"typecheck": "tsc --noEmit",
"test": "vitest"
}
}
还是一样,在项目根目录 package.json
添加 NPM scripts 用于批量执行脚本:
json
{
"scripts": {
"build": "pnpm -r --parallel --filter=./packages/* run build",
"build:type": "pnpm -r --parallel --filter=./packages/* run build:type",
"typecheck": "pnpm -r --parallel --filter=./packages/* run typecheck",
"test": "pnpm -r --parallel --filter=./packages/* run test"
}
}
项目中通常还会涉及到 ESLint 和 Prettier 配置。Prettier 本人习惯直接在根目录丢一份,提供给 VS Code 编辑器,在保存代码的时候自动格式化就行。ESLint 有时需要通过命令去做格式化,例如 CI 构建的时候,需要做 lint,或者本地 git hook 也需要 lint。ESLint 配置同样支持继承,我们可以自定义 eslint-config-custom
,然后在每个子模块添加 .eslintrc.cjs
配置如下:
js
module.exports = {
root: true,
extends: ["custom"],
};
有同学会问,为啥配置文件后缀叫
.cjs
,这是因为本人在项目根目录的package.json
启用了"type": "module"
,即启用 ES Module,这种情况下加载 ESLint 配置会有问题,因此手动添加.cjs
后缀即声明该文件为 CJS 模块
3) Turborepo 实现任务编排
前面我们用了 PNPM 自带的 -r
参数实现批量执行命令,但是这个做法存在一些问题。例如有些任务有逻辑上的先后关系,必须串行;另一方面,-r
过于简单粗暴,有些模块明明没有修改代码,任务还是全量执行,影响 CI 构建效率。
🚀Turborepo:发布当月就激增 3.8k Star,这款超神的新兴 Monorepo 方案,你不打算尝试下吗
相关工具链:
- PNPM Monorepo
- Turborepo
- Changesets
- Vite 3.x + Vitest
- TypeScript
发包流程
本地开发实现约定式路由
5. 未来展望
现在部门内部有一些工程其实是伪 Monorepo,只是用了 Monorepo 的目录结构,但是所有依赖全部都安装在 Workspace Root,还是按照原先 Multirepo 方式维护。主要原因还是很多同学对 Monorepo 工作流不太熟悉,推广 Monorepo 还是任重道远。