前言:
webpack5 的令人激动的新特性 Module federation 可能并不会让很多开发者激动,但是对于深受多应用伤害的腾讯文档来说,却是着实让人眼前一亮,这篇文章就带你了解腾讯文档的困境以及 Module federation 可以如何帮助我们走出这个困境。
0x1 腾讯文档的困境
1.1 多应用场景背景
腾讯文档从功能层面上来说,用户最熟悉的可能就是 word、excel、ppt、表单这四个大品类,四个品类彼此独立,可能由不同的团队主要负责开发维护,那从开发者角度来说,四个品类四个仓库各自独立维护,好像事情就很简单,但是现实情况实际上却复杂很多。我们来看一个场景:
通知中心的需求
对于复杂的权限场景,为了让使用者能快速能获得最新状态,我们实际上有一个通知中心的需求,在 pc 的样式大致就是上图里面的样子。这里是在腾讯文档的列表页看到的入口,实际上在上面提到的四大品类里面,都需要嵌入这样的一个页面。
那么问题来了,为了最小化这里的开发和维护成本,肯定是各个品类公用一套代码是最好的,那最容易想到的就是使用独立 npm 包的方式来引入。确实,腾讯文档的内部很多功能现在是使用 npm 包的方式来引入的,但是实际上这里会遇到一些问题:
问题一:历史代码
腾讯文档的历史很复杂,简而言之,在刚开始的时候,代码里面是不支持写 ES6 的,所以没办法引入 npm 包。一时半会想改造完成是不现实的,产品需求也不会等你去完成这样的改造
问题二:发布效率
这里的问题实际上也是现在我们使用 npm 包的问题,其实还是我们懒,想投机取巧。以 npm 包的方式引入的话,一旦有改动,你需要改 5 个仓库(四个品类+列表页)去升级这里的版本,实际上发布成本是蛮大的,对于开发者来说其实也很痛苦
1.2 我们的解决方案
为了能在不支持 ES6 代码的环境下快速引入 React 来加速需求开发,我们想出了一个所谓的 Script-Loader(下面会简称 SL)的模式。
整体架构如图:
简单来说就是,参考 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,来看一下它的生成文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
src ---main.js ---moduleA.js ---moduleB.js /** * moduleA.js */ export default function testA() { console.log('this is A'); } /** * main.js */ import testA from './moduleA'; testA(); import('./moduleB').then(module => { }); |
非常简单,入口 js 是 main.js
,里面就是直接引入 moduleA.js
,然后动态引入 moduleB.js
,那么最终生成的文件就是两个 chunk,分别是:
main.js
和moduleA.js
组成的bundle.js
- `
moduleB.js
组成的0.bundle.js
如果你了解 webpack 底层原理的话,那你会知道这里是用 mainTemplate 和 chunkTemplate 分别渲染出来的,不了解也没关系,我们继续解读生成的代码
import 变成了什么样
整个 main.js
的代码打包后是下面这样的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
(function (module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); /* harmony import */ var _moduleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( /*! ./moduleA */ "./src/moduleA.js"); Object(_moduleA__WEBPACK_IMPORTED_MODULE_0__["default"])(); __webpack_require__.e( /*! import() */ 0).then(__webpack_require__.bind(null, /*! ./moduleB */ "./src/moduleB.js")).then(module => { }); }) |
可以看到,我们的直接 import moduleA 最后会变成 webpack_require,而这个函数是 webpack 打包后的一个核心函数,就是解决依赖引入的。
webpack_require 是怎么实现的
那我们看一下 webpack_require 它是怎么实现的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function __webpack_require__(moduleId) { // Check if module is in cache // 先检查模块是否已经加载过了,如果加载过了直接返回 if (installedModules[moduleId]) { return installedModules[moduleId].exports; } // Create a new module (and put it into the cache) // 如果一个import的模块是第一次加载,那之前必然没有加载过,就会去执行加载过程 var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; // Execute the module function modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // Flag the module as loaded module.l = true; // Return the exports of the module return module.exports; } |
如果简化一下它的实现,其实很简单,就是每次 require,先去缓存的 installedModules 这个缓存 map 里面看是否加载过了,如果没有加载过,那就从 modules
这个所有模块的 map 里去加载。
modules 从哪里来的
那相信很多人都有疑问了,modules 这么个至关重要的 map 是从哪里来的呢,我们把 bundle.js
生成的 js 再简化一下: