模块联邦概述
模块联邦(Module Federation)是 Webpack 5 中新增的一项功能,可以实现跨应用共享模块。
以下图为例:
在 A 应用中有一个 sayHelloFromA
方法,在 B 应用中有一个 sayHelloFromB
方法。
如果要实现在 A 应用中调用 B 应用中的 sayHelloFromB
方法,在 B 应用中调用 A 应用的 sayHelloFromA
方法,这种跨应用调用方法的场景可以使用 模块联邦 实现。
将每个应用看作一个模块,在一个应用中加载另一个应用,这样就可以实现微前端架构。
创建应用结构
需求
稍后会创建 3 个应用,一个容器应用,两个微应用。最终要通过模块联邦的方式在容器应用中加载微应用。应用中全部使用 faker 创建假数据。
应用结构
这三个应用使用相同的结构,以产品列表应用为例:
products
├─ public # 静态资源文件
│ └─ index.html
├─ src # 应用源代码
│ ├─ bootstrap.js # 加载远程模块和执行业务逻辑的文件
│ └─ index.js # 应用入口文件
├─ package-lock.json # 项目工程文件
├─ package.json # 项目工程文件
└─ webpack.config.js # webpack 配置文件
应用初始搭建
# 创建 module-federations 文件夹存放所有应用
mkdir module-federations
cd module-federations# 创建 products 文件夹存放产品列表应用
mkdir products
cd products# 初始化 package.json
npm init -y# 安装依赖
npm i faker html-webpack-plugin webpack webpack-cli webpack-dev-server
本例安装的依赖版本如下:
"dependencies": {"faker": "^5.5.3","html-webpack-plugin": "^5.5.0","webpack": "^5.68.0","webpack-cli": "^4.9.2","webpack-dev-server": "^4.7.4"
}
新建模板文件 public/index.html
新建应用入口文件 src/index.js
。
新建 webpack.config.js
配置文件。
修改 package.json 的启动脚本:
"scripts": {"start": "webpack serve"
},
最后相同的步骤创建容器应用 container 和 购物车应用 cart。
产品微应用初始化
入口文件中加入产品列表
// products\src\index.js
import faker from 'faker'let products = ''for (let i = 0; i <= 5; i++) {// 随机生成产品名称products += `<div>${faker.commerce.productName()}</div>`
}document.querySelector('#dev-products').innerHTML = products// 导出一个方法
export default function sayHello(name) {console.log(`Hello ${name}`)
}
定义模板文件
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>产品列表</title>
</head>
<body><div id="dev-products"></div>
</body>
</html>
webpack 配置
// products\webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')module.exports = {mode: 'development',devServer: {port: 8081},plugins: [new HtmlWebpackPlugin({template: './public/index.html'})]
}
运行
npm start
运行应用,访问 http://localhost:8081
查看是否成功。
容器应用初始化
入口文件添加测试代码
// container\src\index.js
console.log('container')
定义模板文件
复制 products/public/index.html 文件到容器应用,并修改标题(注意保留同 id
的 div):
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Container</title>
</head>
<body><div id="dev-products"></div>
</body>
</html>
webpack 配置
复制 products/webpack.config.js 文件到容器应用中,并修改启动端口:
// container\webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')module.exports = {mode: 'development',devServer: {port: 8080},plugins: [new HtmlWebpackPlugin({template: './public/index.html'})]
}
运行
npm start
运行后访问 http://localhost:8080
在容器应用中加载产品列表微应用
通过配置模块联邦实现在容器应用中加载产品列表微应用。
在产品列表为应用中将自身作为模块进行导出
// products\webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
// 导入模块联邦插件
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')module.exports = {mode: 'development',devServer: {port: 8081},plugins: [new HtmlWebpackPlugin({template: './public/index.html'}),// 将应用自身作为模块暴露出去new ModuleFederationPlugin({// 构建输出的模块文件名称// 其它应用引入当前模块时需要加载的文件名称filename: 'remoteEntry.js',// 模块名称,相当于 single-spa 的组织名称// 被远程引用时路径为 `<name>/<expose>`// 模块名称具有唯一性,不同的模块不能具有相同的名称,如果名称相同,可以在 `remotes` 配置引入的时候设置模块别名name: 'products',// 被远程引用时可暴露的资源路径及其别名// key 是模块名称// value 是具体的模块路径(`.js` 扩展名可以省略)exposes: {// 被远程引用时的路径为 `<name>/index`// 注意:模块名称前要添加 `./` 才会生效'./index': './src/index'}})]
}
在容器应用中导入产品列表微应用
在容器应用中引入产品列表微应用的方式:
- 在容器应用中加载产品列表微应用构建的模块文件
- 在容器应用中通过
import
关键字从模块文件中导入产品列表微应用模块
// container\webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
// 导入模块联邦插件
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')module.exports = {mode: 'development',devServer: {port: 8080},plugins: [new HtmlWebpackPlugin({template: './public/index.html'}),new ModuleFederationPlugin({name: 'container',// 远程引用的应用及其别名的映射remotes: {// key 是模块的别名,作为当前应用中引入该模块时的 name// value 是模块具体地址,有两部分组成:`<name>@<url>`// name 是模块自己配置的名称// url 是模块构建的文件地址products: 'products@http://localhost:8081/remoteEntry.js'}})]
}
加载远程应用模块:
// container\src\index.js
// 因为 products 是远程应用模块,要发送请求,所以使用异步加载的方式
// products 是 remotes 中配置的模块别名,index 是产品列表微应用配置的具体资源名称
import('products/index').then(products => {const sayHello = products.defaultsayHello('container')
})
重新启动
重新启动 products 和 container 应用,访问 http:localhost:8080
看是否显示了产品列表。
加载方式优化
webpack 关于使用 bootstrap.js 的介绍
在入口文件中加载远程应用模块,需要异步加载,在回调中执行全部逻辑。
可以将加载远程应用模块和逻辑,以同步的形式写在另一个文件中,在入口文件中异步加载这个文件,解决嵌套一层回调的问题。
创建一个 bootstrap.js
文件:
// container\src\bootstrap.js
// 已同步的方式引入远程应用模块
import sayHello from 'products/index'sayHello('container-bootstrap')
修改入口文件:
// container\src\index.js
// 因为 products 是远程应用模块,要发送请求,所以使用异步加载的方式
// products 是 remotes 中配置的模块别名,index 是产品列表微应用配置的具体资源名称
// import('products/index').then(products => {
// const sayHello = products.default
// sayHello('container')
// })// 异步加载
import('./bootstrap')
应用 webpack 打包分析和容器应用文件加载顺序分析
Products 应用打包分析
products 应用中只有一个入口文件 index.js
,webpack 在对这个应用打包的时候会执行两个流程:
- 正常打包流程
- 模块联邦插件的打包流程
正常打包流程
正常打包流程,最终会构建生成 main.js
(默认)的文件,允许我们单独运行应用。
模块联邦插件打包流程
配置模块联邦插件的时候,通过 filename
选项指定了当前应用模块的文件名称为 remoteEntry.js
,模块联邦插件会将应用模块打包成这个文件。
该文件中包含模块中需要加载的文件列表,以及如何加载它们的代码。
另外还通过 exposes
选项配置了具体资源文件,即当前应用模块要导出的模块列表。
模块联邦插件会将这个模块列表中的文件打包成单独的文件,例如当前 products 应用中会将 ./src/index.js
打包成名为 src_index_js.js
的文件(图中以 src_index.js
表示),这个文件中包含 ./src/index.js
文件的代码。
在 ./src/index.js
文件中又引入了 faker
模块,该模块对应的文件地址是 node_modules/faker/index.js
,所以模块联邦插件也会将它单独打包为名为(默认前缀) vendors-node_modules_faker_index_js.js
的文件(图中以 faker.js
表示)。
Container 应用打包分析
container 应用中包含两个文件:
src/index.js
:入口文件,异步加载了src/bootstrap.js
文件src/bootstrap.js
:同步加载 products 远程模块
webpack 在对 container 应用打包的时候,最终会生成两个文件:
main.js
:包含src/index.js
文件的内容bootstrap.js
:包含src/bootstrap.js
文件的内容
由于 products 模块是远程模块,需要异步加载,所以不会被打包为本地文件。
文件加载顺序分析
访问 container 应用(http://localhost:8080
) 查看文件加载顺序。
- 首先会加载该应用的 main.js 文件
- 加载 main.js 文件时发现需要加载 bootstrap.js
- 加载 bootstrap.js 的时候发现需要从 products 模块中导入一些内容
- 首先找到 products 对应的模块文件 remoteEntry.js,该文件包含如何加载模块的代码:加载
src_index.js
和faker.js
- 当
src_index.js
和faker.js
两个文件加载完成后,bootstrap.js 就完成了 products 模块的加载,具备了执行的条件 - 于是 bootstrap.js 执行了
import sayHello from 'products/index'
的操作,并执行后面的代码
购物车微应用初始化
下面使用相同的方式在 Container 应用中加载 cart 应用。
入口文件
// cart\src\index.js
import faker from 'faker'// 生成随机数
document.querySelector('#dev-cart').innerHTML = `在您的购物车中有${faker.datatype.number()}件商品`
模板文件
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>购物车</title>
</head>
<body><div id="dev-cart"></div>
</body>
</html>
webpack 配置
// cart\webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')module.exports = {mode: 'development',devServer: {port: 8082},plugins: [new HtmlWebpackPlugin({template: './public/index.html'}),new ModuleFederationPlugin({filename: 'remoteEntry.js',name: 'cart',exposes: {'./index': './src/index'}})]
}
在容器应用中加载 cart 应用
在容器应用中配置 cart 应用:
// container\webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
// 导入模块联邦插件
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')module.exports = {mode: 'development',devServer: {port: 8080},plugins: [new HtmlWebpackPlugin({template: './public/index.html'}),new ModuleFederationPlugin({name: 'container',// 远程引用的应用及其别名的映射remotes: {// key 是模块的别名,作为当前应用中引入该模块时的 name// value 是模块具体地址,有两部分组成:`<name>@<url>`// name 是模块自己配置的名称// url 是模块构建的文件地址products: 'products@http://localhost:8081/remoteEntry.js',cart: 'cart@http://localhost:8082/remoteEntry.js'}})]
}
模板文件中添加 cart 应用挂载节点:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Container</title>
</head>
<body><div id="dev-products"></div><div id="dev-cart"></div>
</body>
</html>
引入 cart 模块:
// container\src\bootstrap.js
// 已同步的方式引入远程应用模块
import sayHello from 'products/index'
import 'cart/index'sayHello('container-bootstrap')
现在重新运行 container 和 cart 应用,再次访问 `http://localhost:8080