为何要使用 Monorepo
在实际项目中,为了服务同一种需求可能需要管理不同类型的应用,在开发或打包时需要不同的配置,但应用之间也需要不同程度地复用一些共同的业务逻辑与 UI 组件代码。
根据功能我们可以将可复用的代码抽离为不同的 package 包,独立编译、测试和发布到私有 npm 仓库。Package 包在应用中可以通过 import 语法引用和复用。
同时,也可以根据需要在 package 内部共享一些通用的 hook、util 函数等。
在构建时,可以使用统一的构建配置和工具链。每个 package 不需要自己配置构建脚本,全部继承自主项目。
为了更好地复用这些共同代码,使用 Monorepo 的项目结构是最合适的。
打包是个问题
由于 Monorepo 的项目存在复杂的内部依赖关系,打包流程会变得更加复杂。
主应用在导入内部包时,需要的其实是这些包已经构建打包后的产出文件。所以在打包主应用之前,必须先打包内部的所有依赖包。而其中一些依赖包如果还依赖了其他内部包,那么这些被依赖的包也必须先打包。可以看出,这需要进行层层的串行打包,非常耗时。
这种预构建的要求,不仅体现在正式打包上,在本地开发时也存在。如果没有确保所有内部依赖包已经预构建,启动本地服务时就会报各种找不到模块的错误。
相比于传统项目,这种复杂的层层打包流程势必会严重拖慢 Monorepo 项目的初次构建速度,无论是在本地环境还是CI/CD的流水线构建中。解决打包流程的低效是 Monorepo 项目急需解决的痛点。
解决办法
大部分情况下在开发环境或打包时,应用都不需要使用打包后的资源,直接引用依赖包源码就能跳过依赖打包阶段。
package.json 文件提供了 exports 字段作为自定义导出规则,即路径映射,具体可以参考这篇文章介绍:一文彻底搞懂package.json中的exports, main, module, type
那么我们只需要统一内部包的规范,在各自 package.json 中添加一条指向源码的 exports 规则,并在内部应用引用时,使用包指向的源码路径即可:
// @my/ui package.json
{
"exports": {
".": {
"default": "./dist/xxxxxx.js",
"import": "./dist/xxxxxx.js",
"require": "./dist/xxxxxx.umd.cjs"
},
"./source": {
"default": "./index.ts"
}
}
}
// @my/app index.ts
// ...
import ui from @my/ui/source
app.use(ui).mount('#app');
假如不想对应用的代码有侵入式的修改,还可以用拦截的手段,
修改 tsconfig path
如果项目使用 TypeScript 可以直接修改 tsconfig 的 compilerOptions path 将 package 包指向源码中的入口文件;
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@my/ui": ["../packages/ui/index.ts"],
"@my/tools": ["../packages/tools/index.ts"],
// ...
},
}
拦截并修改包路径
修改 tsconfig 文件,相对繁琐,每次新增都需要手动在 tsconfig 中配置一遍,维护成本比较高。
如果项目中使用 vite 或者 rollup,我们可以利用其强大的插件功能来拦截包的名称并将其重定向。
vite 与 rollup 为插件提供了 resolveId 的方法来拦截包名称:
// vite.config.js
import { defineConfig } from 'vite'
import monorepoPlugin from './monorepoPlugin'
export default defineConfig({
plugins: [
monorepoPlugin()
]
})
// monorepoPlugin.js
export default function monorepoPlugin() {
return {
name: 'monorepo-plugin',
resolveId(id) {
if (id.startsWith('@my/ui')) {
return id.replace('@my/ui', '../packages/ui')
}
}
}
}
对每个包分别定义不同的路径,维护成本还是有点高,而且不同的应用可能相对被依赖包的路径不一样。结合 exports 映射,我们可以优化一下 monorepoPlugin.js 文件:
// monorepoPlugin.js
export default function monorepoPlugin() {
return {
name: 'monorepo-plugin',
resolveId(id) {
if (id.startsWith('@my/')) {
return id + '/source';
}
}
}
}
这样每次访问 @my/ui
就会被重定向到 @my/ui/source
并经由包管理器指向包源码的 ./index.ts
;
.pnpmfile.cjs 钩子重定向
项目中如果使用了 pnpm 包管理器,可以使用 .pnpmfile.cjs 的钩子去修改对内部包的 package.json 文件,具体可以在这里了解使用方法。
在 monorepo 的根目录下定义 .pnpmfile.cjs
文件并填入以下代码,原理与 vite 的 resolveId 一样,通过重定向包名到源码:
function readPackage(pkg, context) {
if (pkg.name.startsWith('@my/')) {
pkg.exports = {
...pkg.exports,
'/source': './index.ts'
}
}
return pkg;
}
module.exports = {
hooks: {
readPackage
}
}
总结
在解决 Monorepo 打包流程低效的问题时,我们可以采取上面介绍的这几种方法:
- 在内部包的
package.json
文件中添加一个指向源码入口的字段将入口映射到源码,在内部应用引用时使用指向源码的入口。 - 修改
tsconfig
的path
,将包的引用路径重定向到源码入口文件。 - 利用 Vite 或 Rollup 插件的
resolveId
方法,通过拦截和重定向包的名称来解决路径问题。 - 使用 pnpm 包管理器,在
.pnpmfile.cjs
文件中使用钩子函数来修改内部包的package.json
文件,实现重定向。
通过这些方法,我们可以优化 Monorepo 项目的打包流程,提高构建速度,避免因为依赖包未正确构建而导致的错误。这对于在本地环境和 CI/CD 流水线中构建项目都是非常重要的优化点。