英文社区对 Webpack Module Federation 的响应非常热烈,甚至被誉为“A game-changer in JavaScript architecture”,相对而言国内对此热度并不高,这一方面是因为 MF 强依赖于 Webpack5,升级成本有点高;另一方面是国内已经有一些成熟微前端框架,例如 qiankun。不过我个人觉得 MF 有不少实用性强,非常值得学习、使用的特性,包括:
-
应用可按需导出若干模块,这些模块最终会被单独打成模块包,功能上有点像 NPM 模块;
-
应用可在运行时基于 HTTP(S) 协议动态加载其它应用暴露的模块,且用法与动态加载普通 NPM 模块一样简单;
-
与其它微前端方案不同,MF 的应用之间关系平等,没有主应用/子应用之分,每个应用都能导出/导入任意模块;
-
等等。
而对于微前端的解释,大家可以查这篇文章。
简单示例
Module Federation 的基本逻辑是一端导出模块,另一端导入、使用模块,实现上两端都依赖于 Webpack 5 内置的 ModuleFederationPlugin
插件:
-
对于模块生成方,需要使用
ModuleFederationPlugin
插件的expose
参数声明需要导出的模块列表; -
对于模块使用方,需要使用
ModuleFederationPlugin
插件的remotes
参数声明需要从哪些地方导入远程模块。
接下来,我们按这个流程一步步搭建一个简单的 Webpack Module Federation 示例,基本框架:
//MF-basic
├─ app-1
│ ├─ dist
│ │ ├─ ...
│ ├─ package.json
│ ├─ src
│ │ ├─ main.js
│ │ ├─ foo.js
│ │ └─ utils.js
│ └─ webpack.config.js
├─ app-2
│ ├─ dist
│ │ ├─ ...
│ ├─ package.json
│ ├─ src
│ │ ├─ bootstrap.js
│ │ └─ main.js
│ ├─ webpack.config.js
├─ lerna.json
└─ package.json
提示:为简化依赖管理,示例引入 lerna 实现 Monorepo 策略,不过这与文章主题无关,这里不做过多介绍。
其中,app-1
、app-2
是两个独立应用,分别有一套独立的 Webpack 构建配置,类似于微前端场景下的“微应用”概念。在本示例中,app-1
负责导出模块 —— 类似于子应用;app-2
负责使用这些模块 —— 类似于主应用。
我们先看看模块导出方 —— 也就是 app-1
的构建配置:
const path = require("path");
const { ModuleFederationPlugin } = require("webpack").container;module.exports = {mode: "development",devtool: false,entry: path.resolve(__dirname, "./src/main.js"),output: {path: path.resolve(__dirname, "./dist"),// 必须指定产物的完整路径,否则使用方无法正确加载产物资源publicPath: `http://localhost:8081/dist/`,},plugins: [new ModuleFederationPlugin({// MF 应用名称name: "app1",// MF 模块入口,可以理解为该应用的资源清单filename: `remoteEntry.js`,// 定义应用导出哪些模块exposes: {"./utils": "./src/utils","./foo": "./src/foo",},}),],// MF 应用资源提供方必须以 http(s) 形式提供服务// 所以这里需要使用 devServer 提供 http(s) server 能力devServer: {port: 8081,hot: true,},
};
作用模块导出方,app-1
的配置逻辑可以总结为:
-
需要使用
ModuleFederationPlugin
的exposes
项声明哪些模块需要被导出;使用filename
项定义入口文件名称; -
需要使用
devServer
启动开发服务器能力。
使用 ModuleFederationPlugin
插件后,Webpack 会将 exposes
声明的模块分别编译为独立产物,并将产物清单、MF 运行时等代码打包进 filename
定义的应用入口文件(Remote Entry File)中。例如 app-1
经过 Webpack 编译后,将生成如下产物:
//MF-basic
├─ app-1
│ ├─ dist
│ │ ├─ main.js
│ │ ├─ remoteEntry.js
│ │ ├─ src_foo_js.js
│ │ └─ src_utils_js.js
│ ├─ src
│ │ ├─ ...
-
main.js
为整个应用的编译结果,此处可忽略; -
src_utils_js.js
与src_foo_js.js
分别为exposes
声明的模块的编译产物; -
remoteEntry.js
是ModuleFederationPlugin
插件生成的应用入口文件,包含模块清单、MF 运行时代码。
拓展1
devServer的配置项
devServer
是 Webpack 开发工具中的一个选项,主要用于提供一个快速的本地开发服务器,帮助开发人员在开发过程中更方便地构建和测试应用。使用 devServer
可以提高开发效率,提供热更新功能,并简化静态资源的提供。以下是 devServer
的一些关键特性和配置选项:
关键特性
-
热模块替换 (Hot Module Replacement, HMR):
- 支持在运行时替换、添加或删除模块,而无需完全刷新页面。这使得开发者可以看到改动的即时效果,提高开发效率。
-
自动刷新页面:
- 当文件发生变化时,
devServer
可以自动刷新浏览器,确保开发者总是能看到最新的代码效果。
- 当文件发生变化时,
-
自定义路由:
- 可以配置
devServer
的路由,例如重定向请求到特定页面,适用于单页应用(SPA)。
- 可以配置
-
Proxy:
- 支持 API 代理,允许将开发环境中的请求代理到其他服务器,这在需要与后端 API 交互时非常有用。
-
错误页面:
- 可以定制错误页面,方便开发者在出现问题时进行调试。
基本配置
在 Webpack 配置文件中,通常在 module.exports
对象中添加 devServer
配置。以下是一个基本的配置示例:
const path = require('path');module.exports = {// 其他配置...devServer: {contentBase: path.join(__dirname, 'dist'), // 静态文件的目录compress: true, // 启用 gzip 压缩port: 9000, // 监听端口hot: true, // 启用热模块替换open: true, // 自动打开浏览器proxy: {'/api': 'http://localhost:5000' // 代理 API 请求},historyApiFallback: true, // 处理所有 404 请求,重定向到 index.html},
};
contentBase
: 指定静态文件的根目录,默认是public
目录。compress
: 布尔值,启用 gzip 压缩。port
: 指定开发服务器的端口。hot
: 启用热模块替换。open
: 在服务器启动后自动打开浏览器。proxy
: 配置代理,将请求代理到其他服务器,适用于跨域请求。historyApiFallback
: 启用后将所有 404 响应重定向到index.html
,适合单页应用。
接下来继续看看模块导入方 —— 也就是 app-2
的配置方法:
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;module.exports = {mode: "development",devtool: false,entry: path.resolve(__dirname, "./src/main.js"),output: {path: path.resolve(__dirname, "./dist"),},plugins: [// 模块使用方也依然使用 ModuleFederationPlugin 插件搭建 MF 环境new ModuleFederationPlugin({// 使用 remotes 属性声明远程模块列表remotes: {// 地址需要指向导出方生成的应用入口文件RemoteApp: "app1@http://localhost:8081/dist/remoteEntry.js",},}),new HtmlWebpackPlugin(),],devServer: {port: 8082,hot: true,open: true,},
};
作用远程模块使用方,app-2
需要使用 ModuleFederationPlugin
声明远程模块的 HTTP(S) 地址与模块名称(示例中的 RemoteApp
),之后在 app-2
中就可以使用模块名称异步导入 app-1
暴露出来的模块,例如:
// app-2/src/main.js
(async () => {const { sayHello } = await import("RemoteApp/utils");sayHello();
})();
其中:
-
remoteEntry.js
即app-1
构建的应用入口文件; -
src_utils_js.js
则是import("RemoteApp/utils")
语句导入的远程模块。
总结一下,MF 中的模块导出/导入方都依赖于 ModuleFederationPlugin
插件,其中导出方需要使用插件的 exposes
项声明导出哪些模块,使用 filename
指定生成的入口文件;导入方需要使用 remotes
声明远程模块地址,之后在代码中使用异步导入语法 import("module")
引入模块。
这种模块远程加载、运行的能力,搭配适当的 DevOps 手段,已经足以满足微前端的独立部署、独立维护、开发隔离的要求,在此基础上 MF 还提供了一套简单的依赖共享功能,用于解决多应用间基础库管理问题。
拓展2
ModuleFederationPlugin 插件
ModuleFederationPlugin
是 Webpack 5 引入的一个强大功能,旨在实现微前端架构(Micro Frontends)。它允许多个独立构建的应用程序(或模块)在运行时动态共享和加载彼此的代码,从而实现更灵活的代码复用和跨团队协作。
核心概念
-
微前端:将一个大型应用拆分成多个小型、独立的应用,每个应用可以独立开发、部署和维护。
-
共享模块:通过
ModuleFederationPlugin
,不同的应用可以共享某些公共依赖,从而减少重复代码和整体包的大小。 -
动态加载:应用可以在运行时加载其他应用的模块,而无需在构建时将它们打包在一起。
使用方法
1. 配置主应用
首先,我们需要在主应用(Host)中配置 ModuleFederationPlugin
。以下是一个简单的配置示例:
// webpack.config.js (主应用)
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const path = require('path');module.exports = {mode: 'development',entry: './src/index.js',output: {filename: 'main.js',path: path.resolve(__dirname, 'dist'),publicPath: 'auto', // 适应于动态加载},plugins: [new ModuleFederationPlugin({name: 'app1', // 主应用的名称filename: 'remoteEntry.js', // 远程入口文件exposes: {// 指定要暴露的模块'./Component': './src/Component', },shared: {// 共享模块react: { singleton: true, eager: true },'react-dom': { singleton: true, eager: true },},}),],
};
在这个示例中,配置了一个名为 app1
的主应用,暴露了一个名为 Component
的模块,并且共享了 react
和 react-dom
。
2. 配置远程应用
接下来,我们需要配置一个远程应用(Remote),它将从主应用中加载模块。
// webpack.config.js (远程应用)
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin');
const path = require('path');module.exports = {mode: 'development',entry: './src/index.js',output: {filename: 'remoteEntry.js',path: path.resolve(__dirname, 'dist'),publicPath: 'auto',},plugins: [new ModuleFederationPlugin({name: 'app2', // 远程应用的名称filename: 'remoteEntry.js',remotes: {app1: 'app1@http://localhost:3001/remoteEntry.js', // 指定主应用的远程入口},shared: {react: { singleton: true, eager: true },'react-dom': { singleton: true, eager: true },},}),],
};
在这个示例中,远程应用 app2
可以从主应用 app1
中加载暴露的模块。确保两个应用都在不同的端口运行(例如,app1
在 3001 端口,app2
在 3002 端口),然后你可以在 app2
中加载和使用 app1
中的模块。
3. 加载远程模块
在远程应用中,我们可以使用动态导入来加载主应用暴露的模块。
// src/index.js (远程应用)
import('app1/Component').then((Component) => {// 使用加载的组件const App = Component.default;// 渲染组件
});
依赖共享
上例应用相互独立,各自管理、打包基础依赖包,但实际项目中应用之间通常存在一部分公共依赖 —— 例如 Vue、React、Lodash 等,如果简单沿用上例这种分开打包的方式势必会出现依赖被重复打包,造成产物冗余的问题,为此 ModuleFederationPlugin 提供了 shared 配置用于声明该应用可被共享的依赖模块。
例如,改造上例模块导出方 app-1
,添加 shared
配置:
module.exports = {// ...plugins: [new ModuleFederationPlugin({name: "app1",filename: `remoteEntry.js`,exposes: {"./utils": "./src/utils","./foo": "./src/foo",}, // 可被共享的依赖模块
+ shared: ['lodash']}),],// ...
};
接下来,还需要修改模块导入方 app-2
,添加相同的 shared
配置:
module.exports = {// ...plugins: [// 模块使用方也依然使用 ModuleFederationPlugin 插件搭建 MF 环境new ModuleFederationPlugin({// 使用 remotes 属性声明远程模块列表remotes: {// 地址需要指向导出方生成的应用入口文件RemoteApp: "app1@http://localhost:8081/dist/remoteEntry.js",},
+ shared: ['lodash']}),new HtmlWebpackPlugin(),],// ...
};
之后,运行页面可以看到最终只加载了一次 lodash
产物,而改动前则需要分别从导入/导出方各加载一次 lodash
:
添加 shared 后 | 改动前 |
注意,这里要求两个应用使用 版本号完全相同 的依赖才能被复用,假设上例应用 app-1
用了 lodash@4.17.0
,而 app-2
用的是 lodash@4.17.1
,Webpack 还是会同时加载两份 lodash 代码,我们可以通过 shared.[lib].requiredVersion
配置项显式声明应用需要的依赖库版本来解决这个问题:
module.exports = {// ...plugins: [new ModuleFederationPlugin({// ...// 共享依赖及版本要求声明
+ shared: {
+ lodash: {
+ requiredVersion: "^4.17.0",
+ },
+ },}),],// ...
};
上例 requiredVersion: "^4.17.0"
表示该应用支持共享版本大于等于 4.17.0
小于等于 4.18.0
的 lodash,其它应用所使用的 lodash 版本号只要在这一范围内即可复用。requiredVersion
支持 Semantic Versioning 2.0 标准,这意味着我们可以复用 package.json
中声明版本依赖的方法。
requiredVersion
的作用在于限制依赖版本的上下限,实用性极高。除此之外,我们还可以通过 shared.[lib].shareScope
属性更精细地控制依赖的共享范围,例如:
module.exports = {// ...plugins: [new ModuleFederationPlugin({// ...// 共享依赖及版本要求声明
+ shared: {
+ lodash: {
+ // 任意字符串
+ shareScope: 'foo'
+ },
+ },}),],// ...
};
在这种配置下,其它应用所共享的 lodash 库必须同样声明为 foo
空间才能复用。shareScope
在多团队协作时能够切分出多个资源共享空间,降低依赖冲突的概率。
以下为我总结后的app1和app2webpack.config.js配置项
app1:
const path = require('path');
const { ModuleFederationPlugin } = require('webpack').container;
const HtmlWebpackPlugin = require('html-webpack-plugin');module.exports = {mode: 'development',devtool: false,entry: {main: path.resolve(__dirname, './src/main.js'),},output: {path: path.resolve(__dirname, './dist'),publicPath: 'http://localhost:8081/dist/',filename: '[name].js', // 初始文件名模板chunkFilename: 'src_[name]_js.js', // 动态分块文件名模板},module: {rules: [{test: /\.(js|jsx)$/,exclude: /node_modules/,use: {loader: 'babel-loader',options: {presets: ['@babel/preset-env', '@babel/preset-react'],plugins: ['@babel/plugin-transform-runtime','@babel/plugin-syntax-dynamic-import']}}}],},plugins: [new ModuleFederationPlugin({name: 'app1',filename: 'remoteEntry.js',exposes: {'./utils': './src/utils','./foo': './src/foo',},shared: {lodash: {singleton: true, // 确保只有一个实例requiredVersion: '^4.17.21', // 指定所需的版本范围import: 'lodash', // 指定导入的模块路径strictVersion: false, // 是否严格匹配版本},},}),new HtmlWebpackPlugin(),],optimization: {splitChunks: {chunks: 'all',cacheGroups: {foo: {test: /[\\/]src[\\/]foo\.js$/,name: 'foo',chunks: 'all',},utils: {test: /[\\/]src[\\/]utils\.js$/,name: 'utils',chunks: 'all',},},},},devServer: {port: 8081,hot: true,},
};
app2:
const path = require('path');
const { ModuleFederationPlugin } = require('webpack').container;
const HtmlWebpackPlugin = require('html-webpack-plugin');module.exports = {mode: 'development',devtool: false,entry: {main: path.resolve(__dirname, './src/main.js'),},output: {path: path.resolve(__dirname, './dist'),publicPath: 'http://localhost:8082/dist/', // 确保与 app2 的启动端口一致filename: '[name].js',chunkFilename: 'src_[name]_js.js',},module: {rules: [{test: /\.(js|jsx)$/,exclude: /node_modules/,use: {loader: 'babel-loader',options: {presets: ['@babel/preset-env', '@babel/preset-react'],plugins: ['@babel/plugin-transform-runtime','@babel/plugin-syntax-dynamic-import']}}}],},plugins: [new ModuleFederationPlugin({name: 'app2',filename: 'remoteEntry.js',remotes: {app1: 'app1@http://localhost:8081/dist/remoteEntry.js', // 指向 app1 的 remoteEntry.js},shared: {lodash: {singleton: true,requiredVersion: '^4.17.21',import: 'lodash',strictVersion: false,},}, // 如果需要共享库,可以在这里配置}),new HtmlWebpackPlugin(),],optimization: {splitChunks: {chunks: 'all',cacheGroups: {// 可以根据需要添加缓存组},},},devServer: {port: 8082,hot: true,open: true,},
};
拓展3
shared
配置
常见配置选项
-
singleton
:- 类型:
boolean
- 说明: 如果设置为
true
,Webpack 会确保只会有一个实例被加载,适用于像 React 这样的库,它们通常应该只存在一个实例。
- 类型:
-
eager
:- 类型:
boolean
- 说明: 如果设置为
true
,Webpack 会在应用加载时立即加载这个共享模块,而不是在第一次使用时异步加载。这对于某些共享库(如 React)可能是有益的。
- 类型:
-
requiredVersion
:- 类型:
string
- 说明: 用于指定所需的版本范围。Webpack 会根据这个版本来判断是否可以共享该模块。如果不兼容,则会引发警告或错误。
- 类型:
-
strictVersion
:- 类型:
boolean
- 说明: 如果设置为
true
,则要求共享模块的版本必须完全匹配requiredVersion
指定的版本。这样可以避免因版本不匹配导致的问题。
- 类型:
-
import
:- 类型:
string
- 说明: 可以用于指定在其他应用中使用时共享模块的导入路径。例如,你可以使用此配置来导入不同的模块路径。
- 类型:
-
shareScope
:- 类型:
string
- 说明: 用于指定共享模块的作用域,通常在微前端架构中使用。
- 类型:
完整的微前端应用
Module Federation 是一种非常新的技术,社区资料还比较少,接下来我们来编写一个完整的微前端应用,帮助你更好理解 MF 的功能与用法。微前端架构通常包含一个作为容器的主应用及若干负责渲染具体页面的子应用,分别对标到下面示例的 packages/host
与 packages/order
应用:
//MF-micro-fe
├─ packages
│ ├─ host
│ │ ├─ public
│ │ │ └─ index.html
│ │ ├─ src
│ │ │ ├─ App.js
│ │ │ ├─ HomePage.js
│ │ │ ├─ Navigation.js
│ │ │ ├─ bootstrap.js
│ │ │ ├─ index.js
│ │ │ └─ routes.js
│ │ ├─ package.json
│ │ └─ webpack.config.js
│ └─ order
│ ├─ src
│ │ ├─ OrderDetail.js
│ │ ├─ OrderList.js
│ │ ├─ main.js
│ │ └─ routes.js
│ ├─ package.json
│ └─ webpack.config.js
├─ lerna.json
└─ package.json
示例代码:MF-micro-fe,可以辅助阅读
先看看 order
对应的 MF 配置:
module.exports = {// ...plugins: [new ModuleFederationPlugin({name: "order",filename: "remoteEntry.js",// 导入路由配置exposes: {"./routes": "./src/routes",},}),],
};
注意,order
应用实际导出的是路由配置文件 routes.js
。而 host
则通过 MF 插件导入并消费 order
应用的组件,对应配置:
module.exports = {// ...plugins: [// 模块使用方也依然使用 ModuleFederationPlugin 插件搭建 MF 环境new ModuleFederationPlugin({// 使用 remotes 属性声明远程模块列表remotes: {// 地址需要指向导出方生成的应用入口文件RemoteOrder: "order@http://localhost:8081/dist/remoteEntry.js",},})],// ...
};
注意,order
应用实际导出的是路由配置文件 routes.js
。而 host
则通过 MF 插件导入并消费 order
应用的组件,对应配置:
module.exports = {// ...plugins: [// 模块使用方也依然使用 ModuleFederationPlugin 插件搭建 MF 环境new ModuleFederationPlugin({// 使用 remotes 属性声明远程模块列表remotes: {// 地址需要指向导出方生成的应用入口文件RemoteOrder: "order@http://localhost:8081/dist/remoteEntry.js",},})],// ...
};
之后,在 host
应用中引入 order
的路由配置并应用到页面中:
import localRoutes from "./routes";
// 引入远程 order 模块
import orderRoutes from "RemoteOrder/routes";const routes = [...localRoutes, ...orderRoutes];const App = () => (<React.StrictMode><HashRouter><h1>Micro Frontend Example</h1><Navigation /><Routes>{routes.map((route) => (<Routekey={route.path}path={route.path}element={<React.Suspense fallback={<>...</>}><route.component /></React.Suspense>}exact={route.exact}/>))}</Routes></HashRouter></React.StrictMode>
);export default App;
通过这种方式,一是可以将业务代码分解为更细粒度的应用形态;二是应用可以各自管理路由逻辑,降低应用间耦合性。最终能降低系统组件间耦合度,更有利于多团队协作。除此之外,MF 技术还有非常大想象空间,国外有大神专门整理了一系列实用 MF 示例:Module Federation Examples,感兴趣的读者务必仔细阅读这些示例代码。
拓展4
什么是沙箱技术
“沙箱”是一种用于安全和隔离的技术,通常用于运行不可信或潜在危险的代码而不影响主系统或其他应用程序的环境。在软件开发、网络安全和虚拟化等领域,沙箱的概念被广泛应用。以下是沙箱的一些关键特征和应用场景:
关键特征
-
隔离性:
- 沙箱环境与主系统或其他应用程序是隔离的。运行在沙箱中的代码无法直接访问主系统的资源,确保主系统不会受到不良代码的影响。
-
安全性:
- 沙箱可以限制代码的权限和行为,防止其执行恶意操作,如访问敏感数据、修改系统文件等。
-
可控性:
- 在沙箱中运行的代码可以被监控和控制,可以轻松地对其进行调试和分析。
-
资源限制:
- 沙箱可以限制代码的资源使用,例如 CPU、内存和网络访问,防止代码过度消耗系统资源。
应用场景
-
Web 浏览器:
- 现代浏览器使用沙箱技术来隔离网页和插件,防止恶意网页对用户系统的攻击。例如,浏览器的“安全沙箱”可以防止 JavaScript 访问本地文件系统。
-
虚拟机和容器:
- 通过虚拟机(如 VMware、VirtualBox)和容器(如 Docker),可以创建沙箱环境来运行应用程序。每个虚拟机或容器都是一个独立的执行环境,提供了隔离和安全性。
-
移动应用:
- 移动操作系统(如 Android 和 iOS)使用沙箱来限制应用程序的访问权限,确保一个应用程序无法影响其他应用程序或系统。
-
API 测试:
- 在开发和测试 API 时,可以使用沙箱环境来模拟生产环境,以安全地测试代码而不影响实际数据。
-
恶意软件分析:
- 安全研究人员使用沙箱环境分析和研究恶意软件的行为,帮助识别威胁而不危及主系统的安全。
在微前端中的应用
在微前端架构中,沙箱可以用于隔离不同微前端应用之间的代码和状态,确保它们不会相互影响。虽然 Module Federation 本身没有内置的沙箱功能,但可以借助其他技术(如 iframe 或 Web Workers)来实现沙箱效果,增强微前端应用的安全性。
Module Federation 实现的微前端架构并未提供沙箱能力,可能会造成一些问题,如全局命名冲突、CSS 风格冲突、恶意代码注入、CSS 风格冲突等。而qiankun是拥有js沙箱技术的,感兴趣的可以前往官网学习。