webpack5 新出了个特性: 模块联邦。大家可能虽然听说过,但还没在项目中使用,今天就带大家实战一下。

业务场景

假设公司有个业务集群,公共业务组件库升级了,希望能够尽可能少得影响业务线,仅仅在基础组件库版本升级即可全业务线升级,那么可以考虑使用模块联邦来实现。

他和利用 npm 发包来实现的方案的区别在于,npm 发布的组件库从 1.0.1 升级到 1.0.2 的时候,必须要把业务线项目重新构建,打包,发布才能使用到最新的特性,而模块联邦可以实现实时动态更新而无需打包业务线项目。

大致的原型图如下:

webpack5之模块联邦_加载

我们看到,project1 的 home 页的 specialItem,project2 的 about 页的 searchItem 组件被用于 project2 的 home 中, project2 的 about 直接用的 project1 的 about 页。

总体上的源代码来自于模块联邦的​​示例代码​​,稍作改动。

以下只列出改动的关键部分目录结构,冗余文件已省略。​​戳我​​查看本项目代码示例地址。

├── README.md
├── app-exposes
│ ├── babel.config.js
│ ├── src
│ │ ├── App.vue
│ │ ├── assets
│ │ ├── components
│ │ │ ├── SearchItem.vue ---搜索组件
│ │ │ └── SpecialItem.vue ---自定义业务组件
│ │ ├── index.ts
│ │ ├── main.ts
│ │ ├── router
│ │ │ └── index.ts
│ │ └── views
│ │ ├── AboutView.vue ---关于页
│ │ └── HomeView.vue ---首页
│ ├── tsconfig.json
│ └── vue.config.js
├── app-general
│ ├── babel.config.js
│ ├── src
│ │ ├── router
│ │ │ └── index.ts
│ │ └── views
│ │ └── HomeView.vue
│ ├── tsconfig.json
│ └── vue.config.js

利用脚手架分别创建 app-exposes 与 app-general 的 vue3 项目,此部分大家应该都轻车熟路在此就略过了。嫌麻烦的可以直接用我提供的 demo 样本。

首先克隆本项目代码地址后,分别在 app-exposes 与 app-general 项目下执行 npm i 安装依赖,然后分别执行 npm run serve 运行代码。 此时能够看到本地起了两个服务,端口号分别为 8083 与 8081,其中 app-exposes 为 8083,app-general 为 8081。

项目运行示意效果图如下

webpack5之模块联邦_加载_02

然后我们看看两个项目的配置文件如何配置的。

app-exposes 的 vue.config.js 配置:

webpack5之模块联邦_加载_03

app-general 的 vue.config.js 配置:

webpack5之模块联邦_json_04

可以看到,总体上我们用到了 webpack 原生的插件 ModuleFederationPlugin 来实现模块联邦的效果的。

在首页中,我们异步引用的 app-exposes 提供的 SearchItem 以及 SpecialItem 组件。

webpack5之模块联邦_json_05

在 about 页面的路由配置中,我们直接引入的远程连接的 AboutView 页面。

webpack5之模块联邦_加载_06

如果想查看更多关于联邦模块的案例,可以访问​​官方仓库​​。

二.联邦模块插件的结构及其常见的调用方式(Module Federation Plugin)

上面我们大概了解了下模块联邦插件的大致使用方法。不过知其然也要知其所以然,所以我接下来从个人角度简单聊一聊他的实现原理。

webpack 的整体流程上来说大体分为三个主要阶段

  • 初始化阶段
  • 构建阶段
  • 生成阶段

在这三大阶段时拥有极其庞大的插件库在各个阶段以及节点中发挥各自的作用,而模块联邦插件就是其中之一。

模块联邦作为一个 webpack5 时期新出的插件,形态上看通常是一个带有 apply 方法的类。

class ModuleFederationPlugin {
apply(compiler) {}
}

参数 compiler 是 webpack 上下文,可以调用 hook 对象注册各种钩子回调。

如下文中的 compiler.hooks.thisCompilation.tap,表明调用 afterPlugins 这个钩子的 tap 方法,传入插件名称与回调函数,执行我们指定的逻辑,webpack 通过这种方式来构建其庞大繁杂的插件体系。

class ModuleFederationPlugin {
apply(compiler) {
compiler.hooks.afterPlugins.tap("ModuleFederationPlugin", () => {
...
}
}
}

钩子的核心逻辑定义在 ​​Tapable​​ 仓库,内部定义了如下类型的钩子。

const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");

三.联邦模块的原理分析

联邦模块有两个主要概念:Host(消费其他 Remote)和 Remote(被 Host 消费)。 每个项目可以是 Host 也可以是 Remote,也可以两个都是。可以通过 webpack 配置来区分,可以参考​​例子​​。

  • 作为 Host 需要配置 remote 列表和 shared 模块。
  • 作为 Remote 需要配置项目名(name),打包方式(library),打包后的文件名(filename),提供的模块(exposes),和 Host 共享的模块(shared)。

webpack 打包原理

webpack4 对于异步模块加载步骤

  • import(chunkId) =>webpack_require.e(chunkId) 将相关的请求回调存入 installedChunks。
  • 发起 JSONP 请求。
  • 将下载的模块录入 modules。
  • 执行 chunk 请求回调。
  • 加载 module。
  • 执行用户回调。

联邦模块是基于 webpack 做的优化,所以在深入联邦模块之前我们首先得知道 webpack 是怎么做的打包工作。 webpack 每次打包都会将资源全部包裹在一个立即执行函数里面,这样虽然避免了全局环境的污染,但也使得外部不能访问内部模块。 在这个立即执行函数里面,webpack 使用 webpack_modules 对象保存所有的模块代码,然后用内部定义的 webpack_require 方法从 webpack_modules 中加载模块。并且在异步加载和文件拆分两种情况下向全局暴露一个 webpackChunk 数组用于沟通多个 webpack 资源,这个数组通过被 webpack 重写 push 方法,会在其他资源向 webpackChunk 数组中新增内容时同步添加到 webpack_modules 中从而实现模块整合。

联邦模块就是基于这个机制,修改了 webpack_require 的部分实现,在 require 的时候从远程加载资源,缓存到全局对象 window["webpackChunk"+appName] 中,然后合并到 webpack_modules 中。

ModuleFederationPlugin 的原理

源码中 ModuleFederationPlugin 主流程 主要做了三件事:

  • 通过参数是否配置 shared 来判断是否使用共享依赖 SharePlugin 模块。
  • 通过参数是否配置 exposes 来判断是否使用公开 ContainerPlugin 模块。
  • 通过参数是否配置 remotes 来判断是否使用 ContainerReferencePlugin 引用模块。

下面是项目源码,部分代码以及判断条件已省略。

// 源码目录 lib/container/ModuleFederationPlugin
class ModuleFederationPlugin {
...
apply(compiler) {
if (library && ...) {
compiler.options.output.enabledLibraryTypes.push(library.type);
}
compiler.hooks.afterPlugins.tap("ModuleFederationPlugin", () => {
if (options.exposes && ...) {
new ContainerPlugin({
...
}).apply(compiler);
}
if (options.remotes && ...) {
new ContainerReferencePlugin({
remoteType,
remotes: options.remotes
}).apply(compiler);
}
if (options.shared) {
new SharePlugin({
shared: options.shared,
shareScope: options.shareScope
}).apply(compiler);
}
});
}
}

module.exports = ModuleFederationPlugin;

webpack5 模块联邦对异步模块加载的处理

  • 下载并执行 remoteEntry.js,挂载入口点对象到 window.app-exposes,他有两个函数属性,init 和 get。init 方法用于初始化作用域对象 initScope,get 方法用于下载 moduleMap 中导出的远程模块。
  • 加载 app-exposes 到本地模块。
  • 创建 app-exposes.init 的执行环境,收集依赖到共享作用域对象 shareScope。
  • 执行 app-exposes.init,初始化 initScope。
  • 用户 import 远程模块时调用 app-exposes.get(moduleName) 通过 Jsonp 懒加载远程模块,然后缓存在全局对象 window['webpackChunk' + appName]。
  • 通过 webpack_require 读取缓存中的模块,执行用户回调。

四.使用场景

目前模块联邦已经在微前端领域发挥了巨大的作用,也起到 webpack 能够越来越强大。

利用模块联邦强大的跨应用级模块共享能力,我们可以搭建一个非业务的中台搭建系统,实现 app 级别的低代码搭建平台,这与市场上常见页面级低代码搭建不同,能够实现系统级能力复用的同时降低维护成本。后续比如说 sso 单点登录,页面跳转,埋点,异常捕获等都可以考虑抽象封装成系统内置的方法到里面。