认识
一、认识
在 2020
年上半年,Webpack
提出了一项非常激动人心的特性——Module Federation
(译为模块联邦),这个特性一经推出就获得了业界的广泛关注,甚至被称为前端构建领域的Game Changer
。实际上,这项技术确实很好地解决了多应用模块复用的问题,相比之前的各种解决方案,它的解决方式更加优雅和灵活。
模块联邦Module Federation(MF)
分为两种模块, 本地模块和远程模块。本地模块即为普通模块,是当前构建流程中的一部分,而远程模块不属于当前构建流程,在本地模块的运行时进行导入,同时本地模块和远程模块可以共享某些依赖的代码,如下图所示:
在模块联邦中,每个模块既可以是本地模块,导入其它的远程模块,又可以作为远程模块,被其他的模块导入。如下面这个例子所示:
二、特点
2.1 优点
-
实现任意粒度的模块共享。这里所指的模块粒度可大可小,包括第三方
npm
依赖、业务组件、工具函数,甚至可以是整个前端应用!而整个前端应用能够共享产物,代表着各个应用单独开发、测试、部署,这也是一种微前端的实现。 -
优化构建产物体积。远程模块可以从本地模块运行时被拉取,而不用参与本地模块的构建,可以加速构建过程,同时也能减小构建产物。
-
运行时按需加载。远程模块导入的粒度可以很小,如果你只想使用
app1
模块的add函数,只需要在app1
的构建配置中导出这个函数,然后在本地模块中按照诸如import('app1/add')
的方式导入即可,这样就很好地实现了模块按需加载。 -
第三方依赖共享。通过模块联邦中的共享依赖机制,我们可以很方便地实现在模块间公用依赖代码,从而避免以往的
external
+CDN
引入方案的各种问题。
从以上的分析你可以看到,模块联邦近乎完美地解决了以往模块共享的问题,甚至能够实现应用级别的共享,进而达到微前端的效果。
2.2 缺点
-
维护共享库: 它并不是没有任何缺点,在配置过程中我们可以看到需要配置
share
字段,即不同项目需要共享的三方库,例如A
项目使用vue
,B
项目也使用vue
,那么就需要将vue
声明出来,插件会将vue
自动分割出来,这样A
,B
项目可以共享使用了,在remote
端和host
端都需要声明。 但是,这里对开发同学有非常不友好的点:-
需要将组件共享的包都手动找出来
-
共享有时是强制的,如果漏了某些包,可能会导致页面报错或崩溃,想象一下页面同时有两个
vue
-
同一个库,有时需要维护在约定的版本范围内,有时一个包太老或太新也会在加载时报错
-
简而言之,就是对共享包的维护成本很大!
-
三、对比
3.1 NPM 包
发布 NPM
包是一种常见的复用模块的做法,我们可以将一些公用的代码封装为一个 NPM
包,具体的发布更新流程是这样的:
-
公共库
lib1
改动,发布到NPM
-
所有的应用安装新的依赖,并进行联调。
封装 NPM
包可以解决模块复用的问题,但它本身又引入了新的问题:
-
开发效率问题。每次改动都需要发版,并所有相关的应用安装新依赖,流程比较复杂。
-
项目构建问题。引入了公共库之后,公共库的代码都需要打包到项目最后的产物后,导致产物体积偏大,构建速度相对较慢。
因此,这种方案并不能作为最终方案,只是暂时用来解决问题的无奈之举。
3.2 Monorepo
作为一种新的项目管理方式,Monorepo
也可以很好地解决模块复用的问题。在 Monorepo
架构下,多个项目可以放在同一个 Git
仓库中,各个互相依赖的子项目通过软链的方式进行调试,代码复用显得非常方便,如果有依赖的代码变动,那么用到这个依赖的项目当中会立马感知到。
不得不承认,对于应用间模块复用的问题,Monorepo
是一种非常优秀的解决方案,但与此同时,它也给团队带来了一些挑战:
-
所有的应用代码必须放到同一个仓库。如果是旧有项目,并且每个应用使用一个
Git
仓库的情况,那么使用Monorepo
之后项目架构调整会比较大,也就是说改造成本会相对比较高。 -
Monorepo
本身也存在一些天然的局限性,如项目数量多起来之后依赖安装时间会很久、项目整体构建时间会变长等等,我们也需要去解决这些局限性所带来的的开发效率问题。而这项工作一般需要投入专业的人去解决,如果没有足够的人员投入或者基建的保证,Monorepo
可能并不是一个很好的选择。 -
项目构建问题。跟 发
npm
包的方案一样,所有的公共代码都需要进入项目的构建流程中,产物体积还是会偏大。
3.3 Git Submodule
通过 git submodule
的方式,我们可以将代码封装成一个公共的 Git
仓库,然后复用到不同的应用中,但也需要经历如下的步骤:
-
公共库
lib1
改动,提交到Git
远程仓库; -
所有的应用通过
git submodule
命令更新子仓库代码,并进行联调。
你可以看到,整体的流程其实跟发 npm
包相差无几,仍然存在 npm
包方案所存在的各种问题。
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>
如上面的例子所示,我们可以对react
和react-dom
使用 CDN
的方式引入,一般使用UMD
格式产物,这样不同的项目间就可以通过window.React
来使用同一份依赖的代码了,从而达到模块复用的效果。不过在实际的使用场景,这种方案的局限性也很突出:
-
兼容性问题。并不是所有的依赖都有
UMD
格式的产物,因此这种方案不能覆盖所有的第三方npm
包。 -
依赖顺序问题。我们通常需要考虑间接依赖的问题,如对于
antd
组件库,它本身也依赖了react
和moment
,那么react
和moment
也需要external
,并且在HTML
中引用这些包,同时也要严格保证引用的顺序,比如说moment
如果放在了antd
后面,代码可能无法运行。而第三方包背后的间接依赖数量一般很庞大,如果逐个处理,对于开发者来说简直就是噩梦。 -
产物体积问题。由于依赖包被声明
external
之后,应用在引用其CDN
地址时,会全量引用依赖的代码,这种情况下就没有办法通过Tree Shaking
来去除无用代码了,会导致应用的性能有所下降。