前言:

webpack5 的令人激动的新特性 Module federation 可能并不会让很多开发者激动,但是对于深受多应用伤害的腾讯文档来说,却是着实让人眼前一亮,这篇文章就带你了解腾讯文档的困境以及 Module federation 可以如何帮助我们走出这个困境。

0x1 腾讯文档的困境

1.1 多应用场景背景

腾讯文档从功能层面上来说,用户最熟悉的可能就是 word、excel、ppt、表单这四个大品类,四个品类彼此独立,可能由不同的团队主要负责开发维护,那从开发者角度来说,四个品类四个仓库各自独立维护,好像事情就很简单,但是现实情况实际上却复杂很多。我们来看一个场景:

通知中心的需求

image-20200329125332068

对于复杂的权限场景,为了让使用者能快速能获得最新状态,我们实际上有一个通知中心的需求,在 pc 的样式大致就是上图里面的样子。这里是在腾讯文档的列表页看到的入口,实际上在上面提到的四大品类里面,都需要嵌入这样的一个页面。

那么问题来了,为了最小化这里的开发和维护成本,肯定是各个品类公用一套代码是最好的,那最容易想到的就是使用独立 npm 包的方式来引入。确实,腾讯文档的内部很多功能现在是使用 npm 包的方式来引入的,但是实际上这里会遇到一些问题:

问题一:历史代码

腾讯文档的历史很复杂,简而言之,在刚开始的时候,代码里面是不支持写 ES6 的,所以没办法引入 npm 包。一时半会想改造完成是不现实的,产品需求也不会等你去完成这样的改造

问题二:发布效率

这里的问题实际上也是现在我们使用 npm 包的问题,其实还是我们懒,想投机取巧。以 npm 包的方式引入的话,一旦有改动,你需要改 5 个仓库(四个品类+列表页)去升级这里的版本,实际上发布成本是蛮大的,对于开发者来说其实也很痛苦

1.2 我们的解决方案

为了能在不支持 ES6 代码的环境下快速引入 React 来加速需求开发,我们想出了一个所谓的 Script-Loader(下面会简称 SL)的模式。

整体架构如图:

image-20200329131110457

简单来说就是,参考 jquery 的引入方式,我们用另外一个项目去实现这些功能,然后把代码打包成 ES5 代码,对外提供很多接口,然后在各个品类页,引入我们提供的加载脚本,内部会自动去加载文件,获取每个模块的 js 文件的 CDN 地址并且加载。这样做到各个模块各自独立,并且所有模块和各个品类形成独立。

在这种模式下,每次发布,我们只需要去发布各个改动的模块以及最新的配置文件,其他品类就能获得自动更新。

这个模式并不一定适合所有项目,也不一定是最好的解决方案,从现在的角度来看,有点像微前端的概念,但是实际上却也是有区别的,这里就不展开了。这种模式目前确实能解决腾讯文档这种多应用复用代码的需求。

1.3 遇到的问题

这种模式本质上目前没有很严重的问题,但是有一个很痛点一直困扰我们,那就是品类代码和 SL 的代码共享问题。举个例子:

Excel 品类改造后使用了 React,SL 的模块 A、模块 B、模块 C 引入了 React

因为 SL 的模块之间是各自独立的,所以 React 也是各自打包的,那就是说当你打开 Excel 的时候,如果你用了模块 A、B、C,那你最终页面会加载四份 React 代码,虽然不会带上什么问题,但是对于有追求的前端来说,我们还是想去解决这样的问题。

解决方案: External

对于 React 来说,我们可以默认品类是加载了 React,所以我们直接把 SL 里面的 React 配置为 External,这样就不会打包了,但是实际上情况没有这么简单:

问题一:模块可能独立页面

就以上面的通知中心来说,在移动端上面就不是嵌入的了,而且独立页面,所以这个独立页面需要你手动引入 React

问题二:公共包不匹配

简单来说,就是 SL 依赖的包,在品类里面可能并没有使用,例如 Mobx 或者 Redux

问题三:不是所有包都可以直接配置 External

这里的问题是说像 React 这种包我们可以通过配置 External 为 window.React 来达到共用,但是不是所有包都可以这样的,那对于不能配置为全局环境的包来说,还没法解决这里的代码共享问题

基于这些问题,我们目前的选择是一种折中方案,我们把可以配置全局环境的包提取出来,每个模块指明依赖,然后在 SL 内部,加载模块代码之前会去检测依赖,依赖加载完成才会加载执行实际模块代码。

这种方式有很大问题,你需要手动去维护这样的依赖,每个共享包实际上你都是需要单独打包成一个 CDN 文件,为的是当依赖检测失败的时候,可以有一个兜底加载文件。因此,实际上目前也只有 React 包做了这个共享。

那么到这里,核心问题就变成了品类代码和 SL 如何做到代码共享。对于其他项目来说,其实也就是多应用如何做到代码共享

0x2 webpack 的打包原理

为了解决上面的问题,我们实际上想从 webpack 入手,去实现这样的一个插件帮我们解决这个问题。核心思路就是 hook webpack 的内部 require 函数,在这之前我们先来看一下 webpack 打包后的一些原理,这个也是后面理解 Module federation 的核心。如果这里你比较熟悉,也可以快速跳过到第三节,但是不熟悉的同学还是建议认真了解一下。

2.1 chunk 和 module

webpack 里面有两个很核心的概念,叫 chunk 和 module,这里为了简单,只看 js 相关的,用笔者自己的理解去解释一下他们直接的区别:

module:每一个源码 js 文件其实都可以看成一个 module

chunk:每一个打包落地的 js 文件其实都是一个 chunk,每个 chunk 都包含很多 module

默认的 chunk 数量实际上是由你的入口文件的 js 数量决定的,但是如果你配置动态加载或者提取公共包的话,也会生成新的 chunk。

2.2 打包代码解读

有了基本理解后,我们需要去理解 webpack 打包后的代码在浏览器端是如何加载执行的。为此我们准备一个非常简单的 demo,来看一下它的生成文件。

非常简单,入口 js 是 main.js,里面就是直接引入 moduleA.js,然后动态引入 moduleB.js,那么最终生成的文件就是两个 chunk,分别是:

  1. main.jsmoduleA.js 组成的 bundle.js
  2. `moduleB.js 组成的 0.bundle.js

如果你了解 webpack 底层原理的话,那你会知道这里是用 mainTemplate 和 chunkTemplate 分别渲染出来的,不了解也没关系,我们继续解读生成的代码

import 变成了什么样

整个 main.js 的代码打包后是下面这样的

可以看到,我们的直接 import moduleA 最后会变成 webpack_require,而这个函数是 webpack 打包后的一个核心函数,就是解决依赖引入的。

webpack_require 是怎么实现的

那我们看一下 webpack_require 它是怎么实现的:

如果简化一下它的实现,其实很简单,就是每次 require,先去缓存的 installedModules 这个缓存 map 里面看是否加载过了,如果没有加载过,那就从 modules 这个所有模块的 map 里去加载。

modules 从哪里来的

那相信很多人都有疑问了,modules 这么个至关重要的 map 是从哪里来的呢,我们把 bundle.js 生成的 js 再简化一下:

所以可以看到,这其实是个立即执行函数,modules 就是函数的入参,具体值就是我们包含的所有 module,到此,一个 chunk 是如何加载的,以及 chunk 如何包含 module,相信大家一定会有自己的理解了。

动态引入如何操作呢

上面的 chunk 就是一个 js 文件,所以维护了自己的局部 modules,然后自己使用没啥问题,但是动态引入我们知道是会生成一个新的 js 文件的,那这个新的 js 文件 0.bundle.js 里面是不是也有自己的 modules 呢?那 bundle.js 如何知道 0.bundle.js 里面的 modules

先看动态 import 的代码变成了什么样:

从代码看,实际上就是外面套了一层 webpck_require.e,然后这是一个 promise,在 then 里面再去执行 webpack_require。

实际上 webpck_require.e 就是去加载 chunk 的 js 文件 0.bundle.js,具体代码就不贴了,没啥特别的。

等到加载回来后它认为bundle.js 里面的 modules 就一定会有了 0.bundle.js 包含的那些 modules,这是如何做到的呢?

我们看 0.bundle.js 到底是什么内容,让它拥有这样的魔力:

拿简化后的代码一看,大家第一眼想到的是 jsonp,但是很遗憾的是它不是一个函数,却只是向一个全局数组里面 push 了自己的模块 id 以及对应的 modules。那看起来魔法的核心应该是在 bundle.js 里面了,事实的确也是如此。

bundle.js 的里面,我们看到这么一段代码,其实就是说我们劫持了 push 函数,那 0.bundle.js 一旦加载完成,我们岂不是就会执行这里,那不就能拿到所有的参数,然后把 0.bundle.js 里面的所有 module 加到自己的 modules 里面去!

2.3 总结一下

如果你没有很理解,可以配合下面的图片,再把上面的代码读几遍。

image-20200329143727089

其实简单来说就是,对于 mainChunk 文件,我们维护一个 modules 这样的所有模块 map,并且提供类似 webpack_require 这样的函数。对于 chunkA 文件(可能是因为提取公共代码生成的、或者是动态加载)我们就用类似 jsonp 的方式,让它把自己的所有 modules 添加到主 chunk 的 modules 里面去。

2.4 如何解决腾讯文档的问题?

基于这样的一个理解,我们就在思考,那腾讯文档的多应用代码共享能不能解决呢?

具体到腾讯文档的实际场景,就是如下图:

image-20200329143446668

因为是独立的项目,所以 webpack 打包也是有两个 mainChunk,然后有各自的 chunk(其实这里会有 chunk 覆盖或者 chunk 里面的 module 覆盖问题,所以 id 要采用 md5)。

那问题的核心就是如何打通两个 mainChunk 的 modules

如果是自由编程,我想大家的实现方式可就太多了,但是在 webpack 的框架限制下面,如何快速的实现这个,我们也一直在思考方案,目前想到的方案如下:

SL 模块内部的 webpack_require 被我们 hack,每次在 modules 里面找不到的时候,我们去 Excel 的 modules 里面去找,这样需要把 Excel 的 modules 作为全局变量

但是对于 Excel 不存在的模块我们需要怎么处理?

这种很明显就是运行时环境,我们需要做好加载时的失败降级处理,但是这样就会遇到同步转异步的问题,本来你是同步引入一个模块的,但是如果它在 Excel 的 modules 不存在的时候,你就需要先一步加载这个 module 对应的 chunk,变成了类似动态加载,但是你的代码还是同步的,这样就会有问题。

所以我们需要将依赖前置,也就是说在加载 SL 模块后,它知道自己依赖哪些共享模块,然后去检测是否存在,不存在则依次去加载,所有依赖就位后才开始执行自己。

0x3 webpack5 的 Module federation

说实话,webpack 底层还是很复杂的,在不熟悉的情况下而且定制程度也不能确定,所以我们也是迟迟没有去真正做这个事情。但是偶然的机会了解到了 webpack5 的 Module federation,通过看描述,感觉和我们想要的东西很像,于是我们开始一探究竟!

3.1 Module federation 的介绍

关于 Module federation 是什么,有什么作用,现在已经有一些文章去说明,这里贴一篇,大家可以先去了解一下

Module federation allows a JavaScript application to dynamically run code from another bundle/build, on both client and server

简单来说就是允许运行时动态决定代码的引入和加载。

3.2 Module federation 的 demo

我们最关心的还是 Module federation 的的实现方式,才能决定它是不是真的适合腾讯文档。

这里我们用已有的 demo:

module-federation-examples/basic-host-remote

在此之前,还是需要向大家介绍一下这个 demo 做的事情

这是文件结构,其实你可以看成是两个独立应用 app1 和 app2,那他们之前有什么爱恨情仇呢?

我这里只贴了 app1 的 js 代码,app2 的代码你不需要关心。代码没有什么特殊的,只有一点,app1 的 App.js 里面:

也就是关键来了,跨应用复用代码来了!app1 的代码用了 app2 的代码,但是这个代码最终长什么样?是如何引入 app2 的代码的?

3.3 Module federation 的配置

先看我们的 webpack 需要如何配置:

这个其实就是 Module federation 的配置了,大概能看到想表达的意思:

  1. 用了远程模块 app2,它叫 app2
  2. 用了共享模块,它叫 shared

remotes 和 shared 还是有一点区别的,我们先来看效果。

生成的 html 文件:

ps:这里的 js 路径有修改,这个是可以配置的,这里只是表明从哪里加载了哪些 js 文件

app1 打包生成的文件:

ps: app2 你也需要打包,只是我没有贴 app2 的代码以及配置文件,后面需要的时候会再贴出来的

最终页面表现以及加载的 js:

image-20200329152614947

从上往下加载的 js 时序其实是很有讲究的,后面将会是解密的关键:

这里最需要关注的其实还是每个文件从哪里加载,在不去分析原理之前,看文件加载我们至少有这些结论:

  1. remotes 的代码自己不打包,类似 external,例如 app2/button 就是加载 app2 打包的代码
  2. shared 的代码自己是有打包的

Module federation 的原理

在讲解原理之前,我还是放出之前的一张图,因为这是 webpack 的文件模块核心,即使升级 5,也没有发生变化

image-20200329152252834

app1 和 app2 还是有自己的 modules,所以实现的关键就是两个 modules 如何同步,或者说如何注入,那我们就来看看 Module federation 如何实现的。

3.3.1 import 变成了什么

从这里来看,我们好像看不出什么,因为还是正常的 webpack_require,难道说它真的像我们之前所设想的那样,重写了 webpack_require 吗?

遗憾的是,从源码看这个函数是没有什么变化的,所以核心点不是这里。

但是你注意看加载的 js 顺序:

回想上一节我们自己的分析

所以我们需要将依赖前置,也就是说在加载 SL 模块后,它知道自己依赖哪些共享模块,然后去检测是否存在,不存在依次去加载,所以依赖就位后才开始执行自己。

所以它是不是通过依赖前置来解决的呢?

3.3.2 main.js 文件内容

因为 html 里面和 app1 相关的只有两个文件:app1/app1.js 以及 app1/main.js

那我们看看 main.js 到底写了啥

可以看到区别不大,只是把之前的 modules 换成了 webpack_modules,然后把这个 modules 的初始化由参数改成了内部声明变量。

那我们来看看 webpack_modules 内部的实现:

从代码看起来就三个 module:

那在加载 src_bootstrap.js 之前加载的那些 react 文件还有 app2/button 文件都是谁做的呢?通过 debug,我们发现秘密就在 webpack_require__.e("src_bootstrap_js") 这句话

在第二节解析 webpack 加载的时候,我们得知了:

实际上 webpck_require.e 就是去加载 chunk 的 js 文件 0.bundle.js,等到加载回来后它认为 bundle.js 里面的 modules 就一定会有了 0.bundle.js 包含的那些 modules

也就是说原来的 webpack_require__.e 平淡无奇,就是加载一个 script,以致于我们都不想去贴出它的代码,但是这次升级后一切变的不一样了,它成了关键中的关键!

3.3.3 webpack_require__.e 做了什么

看代码,的确发生了变化,现在底层是去调用 webpack_require.f 上面的函数了,等到所有函数都执行完了,才执行 promise 的 then

那问题的核心又变成了 webpack_require.f 上面有哪些函数了,最后发现有三个函数:

一:overridables

二:remotes

三:jsonp

这三个函数我把核心部分节选出来了,其实注释也写得比较清楚了,我还是解释一下:

  1. overridables 可覆盖的,看代码你应该已经知道和 shared 配置有关
  2. remotes 远程的,看代码非常明显是和 remotes 配置相关
  3. jsonp 这个就是原有的加载 chunk 函数,对应的是以前的懒加载或者公共代码提取
3.3.4 加载流程

知道了核心在 webpack_require.e 以及内部实现后,不知道你脑子里是不是对整个加载流程有了一定的思路,如果没有,容我来给你解析一下

  1. 先加载 src_main.js,这个没什么好说的,注入在 html 里面的
  2. src_main.js 里面执行 webpack_require("./src/index.js")
  3. src/index.js 这个 module 的逻辑很简单,就是动态加载 src_bootstrap_js 这个 chunk
  4. 动态加载 src_bootstrap_js 这个 chunk 时,经过 overridables,发现这个 chunk 依赖了 react、react-dom,那就看是否已经加载,没有加载就去加载对应的 js 文件,地址也告诉你了
  5. 动态加载 src_bootstrap_js 这个 chunk 时,经过 remotes,发现这个 chunk 依赖了?ad8d,那就去加载这个 js
  6. 动态加载 src_bootstrap_js 这个 chunk 时,经过 jsonp,就正常加载就好了
  7. 所有依赖以及 chunk 都加载完成了,就去执行 then 逻辑:webpack_require src_bootstrap_js 里面的 module:./src/bootstrap.js

到此就一切都正常启动了,其实就是我们之前提到的依赖前置,先去分析,然后生成配置文件,再去加载

看起来一切都很美好,但其实还是有一个关键信息没有解决!

3.3.5 如何知道 app2 的存在

上面的第 4 步加载 react 的时候,因为我们自己实际上也打包了 react 文件,所以当没有加载的时候,我们可以去加载一份,也知道地址

但是第五步的时候,当页面从来没有加载过 app2/Button 的时候,我们去什么地址加载什么文件呢?

这个时候就要用到前面我们提到的 main.js 里面的 webpack_modules

这里面有三个 module,我们还有 ?8bfd、container-reference/app2 没有用到,我们再看一下 remotes 的实现

当我们加载 src_bootstrap_js 这个 chunk 时,经过 remotes,发现这个 chunk 依赖了?ad8d,那在运行时的时候:

结合 main.js 的 module ?8bfd 的代码,那最终就是 app2.get("Button")

这不就是个全局变量吗?看起来有些蹊跷啊!

3.3.6 再看 app2/remoteEntry.js

我们好像一直忽略了这个文件,它是第一个加载的,必然有它的作用,带着对全局 app2 有什么蹊跷的疑问,我们去看了这个文件,果然发现了玄机!

如果你细心看,就会发现,这个文件定义了全局的 app2 变量,然后提供了一个 get 函数,里面实际上就是去加载具体的模块

所以 app2.get("Button") 在这里就变成了 app2 内部定义的 get 函数,随后执行自己的 webpack_require

是不是有种焕然大悟的感觉!

原来它是这样在两个独立打包的应用之间,通过全局变量去建立了一座彩虹桥!

当然,app2/remoteEntry.js 是由 app2 根据配置打包出来的,里面实际上就是根据配置文件的导出模块,生成对应的内部 modules

你可能忽略的 bootstrap.js

细心的读者如果注意的话,会发现,在入口文件 index.js 和真正的文件 app.js 之间多了一个 bootstrap.js,而且里面内容就是异步加载 app.js

那这个文件是不是多余的,笔者试了一下,直接把入口换成 app.js 或者这里换成同步加载,整个应用就跑不起来了

其实从原理上分析后也是可以理解的:

因为依赖需要前置,并且等依赖加载完成后才能执行自己的入口文件,如果不把入口变成一个异步的 chunk,那如何去实现这样的依赖前置呢?毕竟实现依赖前置加载的核心是 webpack_require.e

3.3.7 总结

至此,Module federation 如何实现 shared 和 remotes 两个配置我相信大家都有了理解了,其实还是逃不过在第二节末尾说的问题:

  1. 如何解决依赖问题,这里的实现方式是重写了加载 chunk 的 webpack_require.e,从而前置加载依赖

  2. 如何解决 modules 的共享问题,这里是使用全局变量来 hook

整体看起来实现还是挺巧妙的,不是 webpack 核心开发者,估计不能想到这样解决,实际上改动也是蛮大的。

这种实现方式的优缺点其实也明显:

优点:做到代码的运行时加载,而且 shared 代码无需自己手动打包

缺点:对于其他应用的依赖,实际上是强依赖的,也就是说 app2 有没有按照接口实现,你是不知道的

至于网上一些其他文章所说的 app2 的包必须在代码里面异步使用,这个你看前面的 demo 以及知道原理后也知道,根本没有这样的限制!

0x4 总结

对于腾讯文档来说,实际上更需要的是目前的 shared 能力,对一些常见的公共依赖库配置 shared 后就可以解决了,但是也只是理想上的,实际上还是会遇到一些可见的问题,例如:

  1. 不同的版本生成的公共库 id 不同,还是会导致重复加载
  2. app2 的 remotEntry 更新后如何获取最新地址
  3. 如何获知其他应用导出接口

但是至少带来了解决这个问题的希望,remotes 配置也让我们看到了多应用共享代码的可能,所以还是会让人眼前一亮,期待 webpack5 的正式发布!

最后,如果有写的不正确的地方,欢迎斧正~

原创文章转载请注明:

转载自AlloyTeam:http://www.alloyteam.com/2020/04/14338/

  1. Rainsho 2020 年 5 月 5 日

    我们之前的方式是构建的时候从远程 (主项目) 加载 DLLReference,这个东西看起来可以做到更精细化的控制。

  2. arnold 2020 年 4 月 13 日

    赞,非常喜欢作者的剖析思路

  3. AlienZHOU 2020 年 4 月 11 日

    我们之前的解决方式是 hook 到 webpack 内部,生成所有模块资源的依赖图谱。同时会有对应的 Node runtime 将前置资源注入到页面中,并将页面需要的依赖图谱子集也注入到页面变量中。
    同时 hack webpack_require 相关代码,通过 hack 的 require 来分发前端运行时依赖。对于编译期打包进来的依赖还是走 webpack 自己的,非编译期的外部动态化依赖,则会解析编译期生成的资源表。
    所以对开发者来说,和同步 import 其他模块没有区别,开发者不用关心是否是外部模块。

发表评论到 arnold