模块联邦(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 += `${faker.commerce.productName()}`
}
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>
// 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>Containertitle>
head>
<body>
<div id="dev-products">div>
body>
html>
复制 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 的组织名称
// 被远程引用时路径为 `/`
// 模块名称具有唯一性,不同的模块不能具有相同的名称,如果名称相同,可以在 `remotes` 配置引入的时候设置模块别名
name: 'products',
// 被远程引用时可暴露的资源路径及其别名
// key 是模块名称
// value 是具体的模块路径(`.js` 扩展名可以省略)
exposes: {
// 被远程引用时的路径为 `/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 是模块构建的文件地址
products: 'products@http://localhost:8081/remoteEntry.js'
}
})
]
}
加载远程应用模块:
// container\src\index.js
// 因为 products 是远程应用模块,要发送请求,所以使用异步加载的方式
// products 是 remotes 中配置的模块别名,index 是产品列表微应用配置的具体资源名称
import('products/index').then(products => {
const sayHello = products.default
sayHello('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')
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 应用中包含两个文件:
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
) 查看文件加载顺序。
src_index.js
和 faker.js
src_index.js
和 faker.js
两个文件加载完成后,bootstrap.js 就完成了 products 模块的加载,具备了执行的条件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>
// 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 应用:
// 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 是模块构建的文件地址
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>Containertitle>
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