跳到主要内容

认识

2023年05月26日
柏拉文
越努力,越幸运

一、认识


作为一种新的项目管理方式,Monorepo 也可以很好地解决模块复用的问题。在 Monorepo 架构下,多个项目可以放在同一个 Git 仓库中,各个互相依赖的子项目通过软链的方式进行调试,代码复用显得非常方便,如果有依赖的代码变动,那么用到这个依赖的项目当中会立马感知到。

Preview

二、特点


不得不承认,对于应用间模块复用的问题,Monorepo 是一种非常优秀的解决方案,但与此同时,它也给团队带来了一些挑战:

  1. 所有的应用代码必须放到同一个仓库。如果是旧有项目,并且每个应用使用一个 Git 仓库的情况,那么使用 Monorepo 之后项目架构调整会比较大,也就是说改造成本会相对比较高。

  2. Monorepo 本身也存在一些天然的局限性,如项目数量多起来之后依赖安装时间会很久、项目整体构建时间会变长等等,我们也需要去解决这些局限性所带来的的开发效率问题。而这项工作一般需要投入专业的人去解决,如果没有足够的人员投入或者基建的保证,Monorepo 可能并不是一个很好的选择。

  3. 项目构建问题。跟 发 npm 包的方案一样,所有的公共代码都需要进入项目的构建流程中,产物体积还是会偏大。

三、对比


3.1 NPM 包

发布 NPM 包是一种常见的复用模块的做法,我们可以将一些公用的代码封装为一个 NPM 包,具体的发布更新流程是这样的:

  1. 公共库 lib1 改动,发布到 NPM

  2. 所有的应用安装新的依赖,并进行联调。

Preview

封装 NPM 包可以解决模块复用的问题,但它本身又引入了新的问题:

  1. 开发效率问题。每次改动都需要发版,并所有相关的应用安装新依赖,流程比较复杂。

  2. 项目构建问题。引入了公共库之后,公共库的代码都需要打包到项目最后的产物后,导致产物体积偏大,构建速度相对较慢。

因此,这种方案并不能作为最终方案,只是暂时用来解决问题的无奈之举。

3.2 Git Submodule

通过 git submodule 的方式,我们可以将代码封装成一个公共的 Git 仓库,然后复用到不同的应用中,但也需要经历如下的步骤:

  1. 公共库 lib1 改动,提交到 Git 远程仓库;

  2. 所有的应用通过 git submodule 命令更新子仓库代码,并进行联调。

你可以看到,整体的流程其实跟发 npm 包相差无几,仍然存在 npm 包方案所存在的各种问题。

3.3 Module Federation

模块联邦Module Federation(MF) 分为两种模块, 本地模块远程模块本地模块即为普通模块,是当前构建流程中的一部分,而远程模块不属于当前构建流程,在本地模块的运行时进行导入,同时本地模块远程模块可以共享某些依赖的代码,如下图所示:

Preview

在模块联邦中,每个模块既可以是本地模块,导入其它的远程模块,又可以作为远程模块,被其他的模块导入。如下面这个例子所示:

Preview

模块联邦优势如下:

  1. 实现任意粒度的模块共享。这里所指的模块粒度可大可小,包括第三方 npm 依赖、业务组件、工具函数,甚至可以是整个前端应用!而整个前端应用能够共享产物,代表着各个应用单独开发、测试、部署,这也是一种微前端的实现。

  2. 优化构建产物体积。远程模块可以从本地模块运行时被拉取,而不用参与本地模块的构建,可以加速构建过程,同时也能减小构建产物。

  3. 运行时按需加载。远程模块导入的粒度可以很小,如果你只想使用 app1 模块的add函数,只需要在 app1 的构建配置中导出这个函数,然后在本地模块中按照诸如import('app1/add')的方式导入即可,这样就很好地实现了模块按需加载。

  4. 第三方依赖共享。通过模块联邦中的共享依赖机制,我们可以很方便地实现在模块间公用依赖代码,从而避免以往的external + CDN 引入方案的各种问题。

从以上的分析你可以看到,模块联邦近乎完美地解决了以往模块共享的问题,甚至能够实现应用级别的共享,进而达到微前端的效果。

模块联邦缺点如下:

  1. 维护共享库: 它并不是没有任何缺点,在配置过程中我们可以看到需要配置share字段,即不同项目需要共享的三方库,例如A项目使用vueB项目也使用vue,那么就需要将vue声明出来,插件会将vue自动分割出来,这样A,B项目可以共享使用了,在remote端和host端都需要声明。 但是,这里对开发同学有非常不友好的点:

    • 需要将组件共享的包都手动找出来

    • 共享有时是强制的,如果漏了某些包,可能会导致页面报错或崩溃,想象一下页面同时有两个vue

    • 同一个库,有时需要维护在约定的版本范围内,有时一个包太老或太新也会在加载时报错

    • 简而言之,就是对共享包的维护成本很大!

3.4 依赖外部化(external)+ CDN 引入

即对于某些第三方依赖我们并不需要让其参与构建,而是使用某一份公用的代码。按照这个思路,我们可以在构建引擎中对某些依赖声明external,然后在 HTML 中加入依赖的 CDN 地址:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<!-- 从 CDN 上引入第三方依赖的代码 -->
<script src="https://cdn.jsdelivr.net/npm/react@17.0.2/index.min.js"><script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@17.0.2/index.min.js"><script>
</body>
</html>

如上面的例子所示,我们可以对reactreact-dom使用 CDN 的方式引入,一般使用UMD格式产物,这样不同的项目间就可以通过window.React来使用同一份依赖的代码了,从而达到模块复用的效果。不过在实际的使用场景,这种方案的局限性也很突出:

  1. 兼容性问题。并不是所有的依赖都有 UMD 格式的产物,因此这种方案不能覆盖所有的第三方 npm 包。

  2. 依赖顺序问题。我们通常需要考虑间接依赖的问题,如对于 antd 组件库,它本身也依赖了 reactmoment,那么reactmoment 也需要 external,并且在 HTML 中引用这些包,同时也要严格保证引用的顺序,比如说moment如果放在了antd后面,代码可能无法运行。而第三方包背后的间接依赖数量一般很庞大,如果逐个处理,对于开发者来说简直就是噩梦。

  3. 产物体积问题。由于依赖包被声明external之后,应用在引用其 CDN 地址时,会全量引用依赖的代码,这种情况下就没有办法通过 Tree Shaking 来去除无用代码了,会导致应用的性能有所下降。

参考资料


从 Turborepo 看 Monorepo 工具的任务编排能力