具有原生联合的微前端
我发现一件有趣的事情是浏览器在过去几年里发生了多大的变化。当然,之前我们也取得了不少进展,但现在只是在细节上投入了大量精力。在这些细微的改进中,最引人注目的是它对原生模块(EcmaScript 模块,简称 ESM)的支持,以及通过 importmaps 标准为模块提供别名的功能。
利用这种组合来支持微前端的技术是“原生联合”。在本文中,我将介绍什么是原生联合,以及您需要具备哪些条件才能开始使用它。
👉在 GitHub 上查找本文的代码。
什么是本土联盟
原生联合是一种编译时和运行时机制,允许不同的脚本(微前端)共享依赖项并相互暴露/导入组件。它建立在模块联合的思想之上。
据其作者介绍,它具有以下特点:
- ✅ 模块联邦的思维模型
- ✅ 面向未来:独立于 Webpack 和框架等构建工具
- ✅ 拥抱 importmaps(一种新兴的浏览器技术)和 ECMAScript 模块(ESM)
- ✅ 易于配置
- ✅ 极快:参考实现不仅使用了快速的 esbuild;它还缓存了已构建的共享依赖项(例如 Angular 本身)。但是,如上所述,您可以随意将其与任何其他构建工具一起使用。
它采用以下思维模型来工作:
- 远程:远程是一个单独构建和部署的应用程序。它可以公开可加载到其他应用程序中的 ESM。
- 主机:主机会按需加载一个或多个远程对象。从框架的角度来看,这类似于传统的延迟加载。最大的区别在于,主机在编译时并不知道远程对象的具体信息。
- 共享依赖项:如果多个远程服务器和主机使用相同的库,您可能不想多次下载。相反,您可能只想下载一次并在运行时共享。对于这种用例,心智模型允许定义此类共享依赖项。
具体来说,这意味着如果两个或多个应用程序使用同一共享库的不同版本,原生联合可以防止版本不匹配。有几种策略可以缓解此问题,例如回退到适合应用程序的另一个版本、使用其他兼容版本(根据语义版本控制)或抛出错误。
如上所述,总体而言,这与模块联邦非常相似。让我们比较一下两者。
与模块联合的比较
原生联邦建立在模块联邦的思想和理念之上。然而,与一些流行观点相反,这两者彼此不兼容。
这两种技术在其他方面也存在差异:
区域 | 模块联合 | 原住民联盟 |
---|---|---|
工具 | Webpack、rspack | Vite、esbuild |
依赖项 | 构建时间 | 构建时间 |
微前端 | 构建和运行时 | 运行时 |
嵌套 | 可能的 | 不是直接 |
支持清单 | 不是直接 | 是的 |
即时更新 | 可能的 | 不可能 |
模块格式 | 独立的 | 欧洲安全管理 |
最关键的区别在于 Native Federation 不直接依赖于工具支持。缺点是无法直接从远程模块导入代码,例如,以下代码将无法运行:
import('remote/module').then(({ Component }) => {
// Use the imported Component
});
相反,在 Native Federation 中我们需要使用loadRemoteModule
从包中导入的函数@softarc/native-federation
:
loadRemoteModule({
remoteName: "remote",
exposedModule: "./module",
}).then(({ Component }) => {
// Use the imported Component
});
如前所述,Native Federation 并不直接依赖于工具,尽管共享依赖项必须通过工具来解决。为此,Native Federation 使用所谓的适配器,这些适配器几乎可以为所有打包器创建。目前,支持最好的两个打包器是 Vite 和 esbuild。
模块联合最初仅在 Webpack 上可用。截至目前,它也可以在 rspack 中使用(这很自然,因为 rspack 试图实现尽可能多的 Webpack API),并且可以通过 Vite 和 Rollup 等其他打包工具的社区贡献来使用。
从技术上讲,原生联合强烈依赖于 ESM 作为模块格式。另一方面,模块联合则完全独立于格式——只要运行时能够加载更多块(或模块)。通常,这意味着 Webpack 运行时及其自身的加载机制,但实际上真正使用的机制与模块联合无关。
对 ESM 的依赖也有一些限制。最重要的是,Native Federation 使用导入映射来告知微前端使用了哪些依赖项以及如何共享这些依赖项。然而,由于导入映射遵循“初始化即最终确定”的原则,因此无法更新它们。这意味着微前端通常无法动态更新。否则,任何微前端的更新都需要使用导入映射中已共享/构建的依赖项列表。
最后,该@softarc/native-federation
软件包会与每个微前端捆绑在一起。由于软件包较小,这不会直接造成问题,但是,访问全局加载的微前端时就会出现问题。现在我们将通过一个示例应用程序来了解如何缓解/改进这个问题。
示例应用程序
为了实际了解 Native Federation 的功能,我们将构建一个简单的示例应用程序。我自然选择了Michael Geers 著名的 Tractor Shop,它是微前端的 ToDo-MVC。
最终我们的应用程序应该是这样的:
重要的是,应用的 CSS 设计(红色虚线、蓝色虚线、绿色虚线)用于清晰地显示各个 UI 片段的边界和起源。
整个应用程序也是完全交互式的,需要各个微前端进行通信,例如,在选定的拖拉机上对齐:
考虑到这一点,我们有以下 UI 片段分布:
- app shell:入口点,用于从red加载并显示页面组件。
- red:包含红队的组件 - 只有一个页面(拖拉机详情页面)。此页面包含另外三个片段:2 个来自blue,1 个来自green。
- 蓝色:包含来自蓝色团队的组件 - 一个显示带有计数器的购物车符号的组件和一个购买按钮组件。
- 绿色:包含来自绿色团队的组件 - 仅针对当前选定的产品用作推荐产品的单个组件。
要启动 App Shell,我们实际上需要什么?我们首先搭建一个新的 npm 项目,并安装所需的依赖项:
npm init -y
npm install native-federation-esbuild @module-federation/vite vite typescript --save-dev
npm install @softarc/native-federation --save
现在我们创建一个文件index.html,其内容如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Tractor Store</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/index.ts"></script>
</body>
</html>
这假设我们的代码将在支持 ESM 和 importmap 的浏览器中运行。如果这个假设不正确,我们应该将以下部分添加到<head>
:
<script type="esms-options">
{
"shimMode": true
}
</script>
<script src="https://ga.jspm.io/npm:es-module-shims@1.5.17/dist/es-module-shims.js"></script>
完成此设置后,就可以查看src/index.ts 了:
import "./style.css";
import { initFederation, loadRemoteModule } from "@softarc/native-federation";
(async () => {
await initFederation({
"red": "http://localhost:2001/remoteEntry.json",
"blue": "http://localhost:2002/remoteEntry.json",
"green": "http://localhost:2003/remoteEntry.json",
});
await loadRemoteModule({
remoteName: "red",
exposedModule: "./productPage",
}).then(({ renderProductPage }) => {
const root = document.querySelector("#app");
renderProductPage(root);
});
})();
这会从style.css获取基本样式。然后使用该函数执行实际的引导initFederation
。之后,我们可以使用该函数从红色loadRemoteModule
微前端获取公开的产品页面。此时,所有内容都应该在本地提供。
现在我们继续第一个微前端——红色的微前端。
移动到一个新的目录后,我们开始搭建基础框架。我们需要一个包含正确依赖项的新 npm 项目:
npm init -y
npm i @hyrious/esbuild-plugin-style @module-federation/vite esbuild-auto-path-plugin native-federation-esbuild typescript vite @types/react @types/react-dom --save-dev
npm i @softarc/native-federation react react-dom --save
正如你所见,我们还安装了 React,以便用 React 编写这个微前端的组件。当然,Native Federation 完全独立于这些框架,我们也可以选择其他框架。
关于实际的组件代码,我们product-page.tsx
在src
文件夹中创建一个新文件,其内容如下:
import "./style/product-page.css";
import React from "react";
import ReactDOM from "react-dom";
import { loadRemoteModule } from "@softarc/native-federation";
import tractorRed from "./images/tractor-red.jpg";
import tractorBlue from "./images/tractor-blue.jpg";
import tractorGreen from "./images/tractor-green.jpg";
import tractorRedThumb from "./images/tractor-red-thumb.jpg";
import tractorBlueThumb from "./images/tractor-blue-thumb.jpg";
import tractorGreenThumb from "./images/tractor-green-thumb.jpg";
const product = {
name: "Tractor",
variants: [
{
sku: "porsche",
color: "red",
name: "Porsche-Diesel Master 419",
image: tractorRed,
thumb: tractorRedThumb,
price: "66,00 €",
},
{
sku: "fendt",
color: "green",
name: "Fendt F20 Dieselroß",
image: tractorGreen,
thumb: tractorGreenThumb,
price: "54,00 €",
},
{
sku: "eicher",
color: "blue",
name: "Eicher Diesel 215/16",
image: tractorBlue,
thumb: tractorBlueThumb,
price: "58,00 €",
},
],
};
const BasketInfo = React.lazy(() => loadRemoteModule({
remoteName: "blue", exposedModule: "./basketInfo",
}));
const BuyButton = React.lazy(() => loadRemoteModule({
remoteName: "blue", exposedModule: "./buyButton",
}));
const ProductRecommendations = React.lazy(() => loadRemoteModule({
remoteName: "green", exposedModule: "./recommendations",
}));
function getCurrent(sku: string) {
return product.variants.find((v) => v.sku === sku) || product.variants[0];
}
const ProductPage = () => {
const [sku, setSku] = React.useState("porsche");
const current = getCurrent(sku);
return (
<React.Suspense fallback="Loading ...">
<h1 id="store">The Model Store</h1>
<div className="blue-basket" id="basket">
<BasketInfo sku={sku} />
</div>
<div id="image">
<div>
<img src={current.image} alt={current.name} />
</div>
</div>
<h2 id="name">
{product.name} <small>{current.name}</small>
</h2>
<div id="options">
{product.variants.map((variant) => (
<button
key={variant.sku}
className={sku === variant.sku ? "active" : ""}
type="button"
onClick={() => setSku(variant.sku)}
>
<img src={variant.thumb} alt={variant.name} />
</button>
))}
</div>
<div className="blue-buy" id="buy">
<BuyButton sku={sku} />
</div>
<div className="green-recos" id="reco">
<ProductRecommendations sku={sku} />
</div>
</React.Suspense>
);
};
export default ProductPage;
export function renderProductPage(container: HTMLElement) {
ReactDOM.render(<ProductPage />, container);
}
重要的是,我们不仅直接导出组件(作为默认导出 - 使其与 兼容React.lazy
),还提供了一个函数,用于在某个容器(在本例中为renderProductPage
)内渲染它。后者对于跨框架工作是必需的 - 或者像我们在应用外壳中看到的那样,只需挂载根节点即可。
现在,我们还将构建其他(蓝色和绿色)微前端。与红色微前端一样,我们使用 搭建它们npm init -y
。但是,它们内部的代码有所不同。
对于绿色微前端,我们有一个将被公开的组件/文件(product-recommendations.tsx,放在src文件夹中):
import "./style/recommendations.css";
import React from "react";
import ReactDOM from "react-dom";
import reco1 from "./images/reco_1.jpg";
import reco2 from "./images/reco_2.jpg";
import reco3 from "./images/reco_3.jpg";
import reco4 from "./images/reco_4.jpg";
import reco5 from "./images/reco_5.jpg";
import reco6 from "./images/reco_6.jpg";
import reco7 from "./images/reco_7.jpg";
import reco8 from "./images/reco_8.jpg";
import reco9 from "./images/reco_9.jpg";
const recos = {
1: reco1,
2: reco2,
3: reco3,
4: reco4,
5: reco5,
6: reco6,
7: reco7,
8: reco8,
9: reco9,
};
const allRecommendations = {
porsche: ["3", "5", "6"],
fendt: ["3", "6", "4"],
eicher: ["1", "8", "7"],
};
const Recommendations = ({ sku = "porsche" }) => {
const recommendations = allRecommendations[sku] || allRecommendations.porsche;
return (
<>
<h3>Related Products</h3>
{recommendations.map((id) => (
<img src={recos[id]} key={id} alt={`Recommendation ${id}`} />
))}
</>
);
};
export default Recommendations;
export function renderRecommendations(container: HTMLElement) {
ReactDOM.render(<Recommendations />, container);
}
对于蓝色微前端,我们有两个组件。每个组件都位于各自的文件中。
首先,让我们在src文件夹中创建一个文件basket-info.tsx:
import "./style/basket-info.css";
import React from "react";
import ReactDOM from "react-dom";
const BasketInfo = ({ sku = "porsche" }) => {
const [items, setItems] = React.useState([]);
const count = items.length;
React.useEffect(() => {
const handler = () => {
setItems((items) => [...items, sku]);
};
window.addEventListener("add-item", handler);
return () => window.removeEventListener("add-item", handler);
}, [sku]);
return (
<div className={count === 0 ? "empty" : "filled"}>basket: {count} item(s)</div>
);
};
export default BasketInfo;
export function renderBasketInfo(container: HTMLElement) {
ReactDOM.render(<BasketInfo />, container);
}
然后,对于第二个组件,在src文件夹中创建一个文件buy-button.tsx:
import "./style/buy-button.css";
import React from "react";
import ReactDOM from "react-dom";
const defaultPrice = "0,00 €";
const prices = {
porsche: "66,00 €",
fendt: "54,00 €",
eicher: "58,00 €",
};
const BuyButton = ({ sku = "porsche" }) => {
const price = prices[sku] || defaultPrice;
return (
<form
onSubmit={(e) => {
e.preventDefault();
window.dispatchEvent(new CustomEvent("add-item", { detail: price }));
}}
>
<button>buy for {price}</button>
</form>
);
};
export default BuyButton;
export function renderBuyButton(container: HTMLElement) {
ReactDOM.render(<BuyButton />, container);
}
为了简洁起见,我跳过了 CSS 和图片文件。请查看所有示例代码库;不用说,CSS 文件相当简单。
BuyButton
例如,的 CSS如下所示:
#buy {
align-self: center;
grid-area: buy;
}
#buy button {
background: none;
border: 1px solid gray;
border-radius: 20px;
cursor: pointer;
display: block;
font-size: 20px;
outline: none;
padding: 20px;
width: 100%;
}
#buy button:hover {
border-color: black;
}
#buy button:active {
border-color: seagreen;
}
.blue-buy {
display: block;
outline: 3px dashed royalblue;
padding: 5px;
}
这里没有什么特别花哨的东西。
代码写完后,就该运行了。但是怎么运行呢?毕竟,这里需要与 Native Federation 集成。我们先从绿色微前端开始,它是独立的(类似蓝色),但只有一个组件(不同于蓝色)。
为此,我们创建一个文件vite.config.ts,其内容如下:
import { defineConfig } from "vite";
import { style } from "@hyrious/esbuild-plugin-style";
import { autoPathPlugin } from "esbuild-auto-path-plugin";
import { federation } from "@module-federation/vite";
import { createEsBuildAdapter } from "native-federation-esbuild";
export default defineConfig(async ({ command }) => ({
plugins: [
await federation({
options: {
workspaceRoot: __dirname,
outputPath: "dist",
tsConfig: "tsconfig.json",
federationConfig: "src/federation.ts",
verbose: false,
dev: command === "serve",
},
adapter: createEsBuildAdapter({
plugins: [autoPathPlugin(), style()],
}),
}),
],
}));
该文件放置在包的根文件夹中(例如,green),即与package.json相邻。
那么这个文件中发生了什么?让我们剖析一下:
- 我们定义一个 Vite 使用的构建配置,即让微前端成为一个独立的应用程序
- 通过使用
federation
Module Federation 的功能,我们将应用程序的一部分提取到侧包中,该侧包使用不同的工具集构建 - 不同的工具集使用提供的适配器
createEsBuildAdapter
,即使用 esbuild - esbuild 配置还附带一些插件;一个插件用于使用样式,另一个插件用于在运行时动态转换路径(例如,转换为图像等资产);这很重要,因为我们还不知道微前端托管在哪里/如何托管,以及它们在不同环境(例如,生产环境)中采用哪些路径
至关重要的是,助手/插件选项的具体机制由另一个可重复使用的文件federation.tsfederation
决定:
const {
withNativeFederation,
shareAll,
} = require("@softarc/native-federation/build");
module.exports = withNativeFederation({
name: "green",
exposes: {
"./recommendations": "./src/product-recommendations.tsx",
},
shared: {
...shareAll({
singleton: true,
strictVersion: true,
requiredVersion: "auto",
includeSecondaries: false,
}),
},
});
这里,我们导出一个与 Webpack 模块联合配置非常相似的配置。具体来说,我们设置了微前端的名称(green)、通过 暴露的模块exposes
以及通过 共享依赖项shared
。对于后者,我们使用了辅助函数,它从package.json中shareAll
获取并将其转换为共享依赖项。dependencies
此时,绿色微前端可以独立运行;至少当我们像为应用程序外壳一样创建index.html时。
对于这个微前端,我们可以使用以下 HTML 样板:
<!DOCTYPE html>
<html lang="en">
<head>
<script type="esms-options">
{
"shimMode": true
}
</script>
<script src="https://ga.jspm.io/npm:es-module-shims@1.5.17/dist/es-module-shims.js"></script>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Green Standalone</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
其中main.ts是应用程序的入口模块:
import { initFederation } from "@softarc/native-federation";
(async () => {
await initFederation({});
await import("./bootstrap");
})();
对蓝色部分进行同样的操作,我们最终得到了一堆微前端——它们组合起来就能提供完整的应用程序。这就是我们遇到的第一块石头。
事实证明,只有初始化 Native Federation 的应用程序才能设置哪些微前端存在。然而,
- 从我们的主要应用程序的角度来看,只有红色存在
- 只有在红色中,我们才知道需要绿色和蓝色
这让我们陷入了困境。更糟糕的是:由于@softarc/native-federation
必须是非共享的(毕竟,在应用程序内部,它是作为标准脚本使用的;否则它无法在第一个 ESM 加载之前设置/操作导入映射),每个微前端都有自己的版本。因此,即使在应用程序内设置了所有微前端(红色、绿色、蓝色),我们仍然无法从红色访问绿色和蓝色(记住,红色有它自己的包版本,该包未初始化/不识别这些微前端——它们只能从根级别/主应用程序访问)。
那么,我们该如何改善这种情况呢?我们可以引入一个小帮手,优雅地解决这个问题:
import { lazy } from "react";
import { initFederation, loadRemoteModule } from "@softarc/native-federation";
export async function setup(manifest?: string | Record<string, string>) {
await initFederation(manifest);
window.loadComponent = (remoteName, exposedModule) =>
lazy(() =>
loadRemoteModule({
remoteName,
exposedModule,
})
);
}
declare global {
interface Window {
loadComponent(remoteName: string, modulePath: string): React.FC<any>;
}
}
这是共享代码,因为每个微前端都可以使用和嵌入它;但并非共享,因为每个运行时都需要访问此代码的同一个实例。实际上,每个微前端都会将此代码捆绑在其独立的入口模块中,而不是在其暴露的模块中。
initFederation
通过这种方式,我们可以用 替换对 的调用setup
,loadRemoteModule
用loadComponent
from替换对 的调用window
。
在红色的微前端中它看起来像这样(首先,让我们看一下main.ts):
import { setup } from "@shared/loader";
(async () => {
await setup({
green: "http://localhost:2003/remoteEntry.json",
blue: "http://localhost:2002/remoteEntry.json",
});
await import("./bootstrap");
})();
再次强调,这只与独立模式相关。然而,product-page.tsx文件也受到了影响:
import "./style/product-page.css";
import React from "react";
import ReactDOM from "react-dom";
// no import of `@softarc/native-federation` any more!
// data etc. remains unchanged
const BasketInfo = window.loadComponent("mf-blue", "./basketInfo");
const BuyButton = window.loadComponent("mf-blue", "./buyButton");
const ProductRecommendations = window.loadComponent(
"mf-green",
"./recommendations"
);
// rest as before
因此,我们现在反对全局可用的加载器 - 我们确信必须提供它,因为最终我们都在应用程序中运行。
这个包装器允许我们轻松更改/扩展微前端的另一个好处是引入微前端发现服务。
使用微前端发现进行扩展
仅使用 Native Federation 是可以接受的,但是,我们希望更上一层楼。我们需要一个微前端发现服务,能够轻松发布新的微前端或更新现有的微前端。
实际上,使用Piral Feed 服务我们已经可以做到这一点了。还记得该initFederation
函数也接受字符串/URL 吗?我们可以这样使用:
import './style.css';
import { initFederation } from "@softarc/native-federation";
(async () => {
await initFederation('https://native-federation-demo.my.piral.cloud/api/v1/native-federation');
await import("./bootstrap");
})();
在此示例中,我们使用自己的 feed(“native-federation-demo”)作为native-federation
表示。从此 URL 获取数据将产生以下有效负载:
{
"mf-blue": "https://assets.piral.cloud/pilets/native-federation-demo/mf-blue/1.0.0/remoteEntry.json",
"mf-green": "https://assets.piral.cloud/pilets/native-federation-demo/mf-green/1.0.0/remoteEntry.json",
"mf-red": "https://assets.piral.cloud/pilets/native-federation-demo/mf-red/1.0.3/remoteEntry.json"
}
如果您想按照以下步骤操作,您很可能还需要创建自己的Feed。为此,您需要登录feed.piral.cloud并点击“+”(创建Feed):
之后,您将被重定向到一个包含 feed 详情的页面。在这里,您可以复制原生联邦清单的 URL(当然还有其他选项,但这个格式最简单,因此适合我们/本文)。
其余部分保持不变。现在的问题是:我们如何上传这些微前端?
要发布我们的微前端,我们可以使用该publish-microfrontend
包。这是一个简单的命令行工具,只需一条命令即可发布微前端:
npx publish-microfrontend --url https://native-federation-demo.my.piral.cloud/api/v1/pilet --interactive
在此示例中,该--url
标志告诉 CLI 实用程序要使用哪个服务(在本例中为 feed)。通常,您可以使用任何服务,只要它使用适当的协议即可——在本例中,使用包含微前端资源的 tarball 进行表单 POST 请求。
该--interactive
标志指示 CLI 使用所提供 API 中的交互流程(如果可用)。这将打开一个 Web 浏览器,重定向到 Piral Feed 服务的登录页面。或者,我们可以创建一个 API 密钥并通过该--api-key
标志使用它。
一旦所有微前端都发布完毕,您应该在 feed 概览中看到以下内容:
这样我们就完成了 - 现在一切都可以正常运行并且也可以扩展!
结论
如果我们知道如何处理与模块联合相比现有的边缘情况和缺点,那么原生联合将是一项有趣的技术。据推测,最大的缺点是从 ESM 继承而来——需要支持导入映射,并且受导入映射规则(例如初始化即完成)的约束。
通过使用微前端发现服务,我们实际上可以将我们的解决方案提升到一个新的水平 - 确保生产力和效率,使我们的解决方案不仅适用于现在,而且也适用于未来。
👉在 GitHub 上查找本文的代码。
如果有任何问题或一般建议,请随时通过LinkedIn与我联系🙏。
文章来源:https://dev.to/florianrappl/micro-frontends-with-native-federation-56j4