首页文章关于

module federation - handbook

module-federationarchitecture

模块联邦是指把需要共享的模块通过去中心化的形式构建出来并应用于分布式系统的解决方案,不仅为代码带来了可扩展性还未个人和组织的生产力带来了可扩展性

背景

随着前端业务的复杂度越来越高对于微前端架构的需求愈加强烈,在此之前解决方案大多通过iframes或者服务端代码嵌套(将一个应用嵌入另一个应用中)的方式尝试解决.但在性能、灵活性和安全性方面存在很大的局限性

2020年随着Webpack5伴随模块联邦这一新功能的发布使得web开发中代码共享的发展迈进了重要一步,模块联邦通过Module Federation Runtime扩展webpack打包以支持远端加载和依赖项解析,它允许开发者在多个应用共享整个模块(不局限于某一技术栈).在Webpack5最初版本只支持在webpack上使用,随着模块联邦的发展和完善在5.1版本之后支持了在不同打包器的实现(RollupParcelVite)

在模块联邦不断的发展和完善下将来会有很多工具和框架对其进行采用,目前存在一些潜在的功能方向:

  • 改进了对动态、异步模块的共享支持
  • serverless和其他都端服务的整合
  • 对跨语言和环境共享模块的支持

概括

模块联邦的核心思想就是从远端动态加载js模块而不是从一个全量的单个应用中,这样使得应用能够拆分出更小的模块并且单独部署当需要时动态加载.所以就有了提供者和消费者的概念,在webpack配置上主要体现在exposes(暴露给其他应用使用)和remotes(需要向远端请求加载使用)选项上

总而言之,模块联邦通过微前端使允许开发者共享代码和资源从而减少重复并提高代码可维护性并允许独立开发和部署减少团队协调成本、提升开发效率

使用用例

  • 微前端架构
  • 多应用集成
  • 第三方集成
  • 共享库模式

使用

基本配置

  • name: 应用模块名称
  • filename: 远程模块入口文件名称
  • exposes: 将本地模块名称映射到远程应用使用共享的对象
  • remotes: 将远程应用的名称映射到托管它们的URL的对象
  • shared: 在不同应用中共享使用的模块列表
js
const consumerConfig = {
// ...
plugins: [
new ModuleFederationPlugin({
name: "app1",
remotes: {
// 结合ExternalTemplateRemotePlugin对@[url]进行占位替换
app2: "app2@[app2Url]/remoteEntry.js"
},
shared: { react: { singleton: true }, "react-dom": { singleton: true } }
}),
new ExternalTemplateRemotesPlugin()
// ...
]
// ...
}
const hostConfig = {
// ...
plugins: [
new ModuleFederationPlugin({
name: "app2",
filename: "remoteEntry.js",
exposes: {
"./App": "./src/App"
},
shared: { react: { singleton: true }, "react-dom": { singleton: true } }
}),
new HtmlWebpackPlugin({
template: "./public/index.html"
})
]
// ...
}

使用远程模块

js
// index.js
window.app2Url = "http://localhost:3002"
import("./bootstrap")
// bootstrap.js
import App from "./App"
import React from "react"
import ReactDOM from "react-dom"
ReactDOM.render(<App />, document.getElementById("root"))
// App.js
import React, { Suspense } from "react"
const RemoteApp = React.lazy(() => import("app2/App"))
const App = () => {
return (
<div>
<div
style={{
margin: "10px",
padding: "10px",
textAlign: "center",
backgroundColor: "greenyellow"
}}
>
<h1>App1</h1>
</div>
<Suspense fallback={"loading..."}>
<RemoteApp />
</Suspense>
</div>
)
}

核心功能

Dynamic Remotes

使用环境变量直接替换远程模块地址,优点是使用简单缺点是需要构建不同的版本来对应URLS

js
module.exports = (env) => ({
plugins: [
new ModuleFederationPlugin({
name: "Host",
remotes: {
RemoteA: `RemoteA@${env.A_URL}/remoteEntry.js`
}
})
]
})

使用external-remotes-plugin在运行时解析urls进行模版替换,对于模版需要提前定义在bootstrap.js引入之前.优点是相对于环境变量它只需要设置一个全局变量或者URLs映射表,而且在服务端渲染场景可以由service层定义URLs.缺点则是在加载期间内没有完全掌控权

js
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "Host",
remotes: {
RemoteA: `RemoteA@[window.appAUrl]/remoteEntry.js`
}
}),
new ExternalTemplateRemotesPlugin()
]
}

使用符合get/init接口的Promise替换URLs字符串,优点是在加载期间有更多的控制权缺点则是对于这种函数字符串不利于调试和维护

js
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "Host",
remotes: {
RemoteA: `promise new Promise(${fetchRemoteA.toString()})`,
},
}),
],
}
const fetchRemoteA = (resolve) => {
const script = document.createElement("script");
script.src = window.appAUrl;
script.onload = () => {
const module = {
get: (request) => window.RemoteA.get(request),
init: (arg) => {
try {
return window.RemoteA.init(arg);
} catch (e) {
console.log("Remote A has already been loaded");
}
}
};
resolve(module);
}
document.head.appendChild(script);
}

使用Dynamic Remote Containers(动态远程容器)通过模块容器注入而非在webpack配置远程模块和定义对应URLs,优点是更加灵活缺点则是需要手动通过动态script标签初始化共享模块和容器

js
module.exports = {
new ModuleFederationPlugin({
name: 'Host',
// remotes: {} no needed
})
}
// 在加载任何远程应用时,使用动态script标签手动初始化远程容器
(async () => {
// 初始化共享作用域次对象包含了已注册的远端模块
await __webpack_init_sharing__("default");
// 获取已知容器,也就是提供远程模块的容器配置
const container = window.someContainer;
// 将初始化的远端模块传递给容器
await container.init(__webpack_share_scopes__.default);
// module就对应在remotes中配置的模块
const module = await container.get("./module");
})

Shared API

shared api是为了避免加载重复依赖从而减少不必要的请求以提升性能,配置如下:

  • shared(object | [string]): 当为数组时以字符串作为依赖(['react','react-dom'])为对象时以依赖名称为key然后包含如下配置属性
    • eager(boolean): 为true时同步加载false时以异步的形式懒加载
    • singleton(boolean): 是否以单例模式在联邦应用中被共享
    • requiredVersion(string): 指定依赖必须的版本,当加载的共享依赖和应用本身不兼容时会分别加载两个版本的依赖,当singleton为true时则会console警告

shared api的工作原理与dynamic import(动态引入)较为类似,它请求一个模块并返回一个Promise并解析一个包含了共享对象中对应moduleName声明的所有导出的对象,模块联邦天然的异步特性使得shared api及其的灵活

当请求一个远程模块时会加载一个remoteEntry.js里面例举了这个模块所需的所有依赖,由于加载操作是异步的因此容器可以检查所有入口文件并列出每个模块在shared中声明的所有依赖项.每当模块请求所需依赖时host就可以提供此依赖的单个副本并将其共享给所有需要它的模块

正因为shared依赖于异步操作的加载和解析,当应用想要同步加载时就会遇到Uncaught Error: Shared module is not available for eager consumption,对此可以通过如下两个选项解决

  1. 设置共享模块作为同步模块进行打包,缺点是需不需要的时候都将会被加载所以需要进行Async Boundary(异步边界)处理
js
new ModuleFederationPlugin({
shared: {
react: {
eager: true
}
}
})
  1. 对远程模块入口文件通过动态导入的方式进行异步化包裹
js
// bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
// index.js
import('./bootstrap.js');

shared api有可能遇到的潜在就是(Inconsistencies in Dependencies at Runtime)[https://module-federation.io/docs/en/mf-docs/0.2/shared-api/]在运行时依赖不一致的问题,官方推荐在monorepo项目中使用单个package.json来进行统一管理

Delegate Modules

dynamic remotes章节主要介绍了它的几种使用方法基本都是通过动态插入script配合容器初始化这种低等级操作实现相对繁琐并且丢失很多webpack功能,通过委托模块可以利用require和import语句使得其被打包.此种方式也非常合适处理复杂的需求,例如加载远程模块或自定义联合Api.

主要用例有:

  • 当某个远程模块加载失败时作为它的的fallback处理
  • 用于服务端集成后可用于从各种来源获取远程资源而又无需依赖Http,而且增强了对于数据检索和访问的安全性从而实现更惊喜的访问控制和数据保护
  • 可以通过获取html并将其作为React组件返回,从而在ESI和KV(key value)集成,为分布式系统提供了一种更为不可知的方式,而不需要在边缘部署时进行完整的实现

委托模块是一个并不包含任何实质代码的联邦模块,它扮演一个当实际模块被请求时的占位符.委托模块主要有两个配置属性remote(用于制定实际模块的名称)和remoteType(制定远程模块的构建类型:varscript, module),同时也具有一个get方法它负责加载远程构建并从中请求实际模块

js
// webpack.config.js for app1
new ModuleFederationPlugin({
name: "app1",
filename: "remoteEntry.js",
exposes: {
"./delegate": "./src/delegate.js",
}
})
// src/delegate.js for app1
import { get } from "webpack/container/entry/dynamic-remotes"
export default {
remote: "app2",
remoteType: "var",
get: () => get("app2/foo")
}
// webpack.config.js for host app
new ModuleFederationPlugin({
name: "host",
remotes: {
app1: "app1@http://localhost:3001/remoteEntry.js",
}
})
// src/index.js for host app
import("./bootstrap")
async function bootstrap() {
const delegate = await import("app1/delegate");
const foo = await delegate.get();
foo.doSomething();
}
bootstrap()

上面的例子步骤如下:

  1. app1作为提供者暴露一个委托模块delegate.js
  2. 委托模块delegate.js通过模块联邦容器的工具函数get获取到在app2中暴露的远程模块
  3. host app消费app1这个远程模块自然就有了间接访问app2的权限(通过委托模块中转的方式),然后在启动函数中就可以通过动态导入的方式获取到实际模块正常使用

优点:

  • 可以避免联合模块之间的重复和版本冲突(多应用依赖实际模块不需要多次加载只需加载一次委托模块)
  • 可以将远程构建相互解耦(当委托模块更新时不会导致实际模块更新)
  • 可以从消费者哪里抽象出远程构建的详细信息(当实际模块变更时只需要更新指向它的委托模块)

缺点:

  • 在模块联邦的配置中增加了额外的间接性和复杂性
  • 委托模块依赖于动态远程,在运行时而不是编译时加载远程构建一定程度上对性能和安全性产生一些影响
  • 使用不同构建工具或非标准功能、语法所带来的差异委托模块可能无法很好配合和兼容

Versioned Shared Modules

在复杂的模块联邦应用场景中对于分享模块及其消费者之间的版本控制至关重要,所有的共享模块都会经过基于(semver)[https://semver.org/]要求检查它允许共享模块存在多个版本并根据所需版本使用.此策略允许远程向联邦应用提供升级版本为hostremotes的解耦铺平了道路

共享模块的版本系统中策略:

  • 消费模式: 选择消费一个共享模块而非提供,当容器始终在提供这些共享模块时这种策略可以减少构建时间和部署体积
  • 版本检查模式: 严格或者宽松
    • Strict mode: 如果版本不在有效范围内,则不会使用共享模块。否则使用备用模块
    • Loose mode: 始终使用共享模块,但会针对无效版本打印警告。仅当没有可用的共享模块时才使用后备模块
  • 初始化阶段: 又一个初始化阶段所有远程模块(包括嵌套关系)都有机会提供共享模块,深层嵌套又可能需要更高版本的共享模块并且需要有机会提供它

指定共享模块配置的方式有三类,数组、对象、带有共享命中属性Sharing hints的对象,具体用例如下:

  1. 永远使用找到的更高的版本
js
{
shared: ['react']
}
  1. 使用符合SemVer版本规范的最高版本>=2.20 & <3
js
{
shared: {
"moment": "^2.20.0"
}
}
  1. 当版本<16.7.0 & >=17时警告
js
{
shared: {
"react": {
singleton: true,
requiredVersion: "^16.7.0"
}
}
}
  1. 当版本<2.6.5 | >=3时警告
js
{
shared: {
"vue": {
import: false,
requiredVersion: "^2.6.5"
}
}
}
  1. 当版本<2.6.5 | >=3时抛出异常
js
{
shared: {
"vue": {
import: false,
strictVersion: true,
requiredVersion: "^2.6.5"
}
}
}

清除缓存

  • 给远程模块增加随机字符串或者时间戳
  • 使用external-remotes-plugin
  • 对构建产物增加哈希值([name].[content-hash].js)

总结

优点

  • 减少代码重复: 模块的共享性
  • 改善团队间协作: 不同职责下的业务模块联邦
  • 更好的灵活性: 按需对可复用模块进行拆分
  • 性能提升: 拆分更小的粒度提升加载时间
  • 更好的扩展性: 跨团队、技术栈、单独开发、部署

缺点

  • 增加复杂度: 配置、版本控制带来的复杂性
  • 安全风险: 多应用共享带来的隐患
  • 增加耦合: 模块共享必然面的问题
  • 潜在性能问题: 共享模块较大或复杂的情况所带来的加载时间过长

最佳实践

  • 模块设计应当符合职责单一原则,避免体积或复杂度过大
  • 在多应用中共享应当注意版本控制和兼容性,以免在某个应用中不能使用
  • 通过功能、领域等范式来约定代码组织及结构,在不同模块间建立清晰的界限
  • 应该建立清晰的依赖关系层次结构以减少模块之间的依赖关系
  • 建立清晰的通讯协议以便不同模块之间的相互通信
  • 良好的单元测试更利于模块代码的健壮性,确保更改不会破坏依赖他们的其他应用
  • 明确访问权限确保访问者有权访问该共享模块
  • 通过指标及监控工具进行问题追踪提升整体应用的可靠性
  • 根据性能监控和指标进行专项的优化确保用户体验
  • 建立错误处理(记录、报告)机制能更好的排查及修复
  • 良好的模块文档说明有助于减少错误并提升可维护性

资料

Copyright © 2023, Daily learning records and sharing. Build by