微前端(译)
by Teobler on 25 / 11 / 2020
views
微前端
好地前端开发是很难的。扩展前端开发,使许多团队能够同时在一个庞大而复杂的产品上工作更是难上加难。在这篇文章中,我们将介绍最近的一个趋势,即把前端单体分解成许多更小的、更容易管理的部分,以及这种架构如何提高团队治理前端代码的效率并提升效益。除了谈论各种好处和弊端之外,我们还将介绍一些可用的实现方案,并将深入到一个完整的示例应用中去展示这一技术。
本文作者
Cam Jackson -- 就职于 ThoughtWorks 的全栈工程师和咨询顾问,对大型企业如何扩展其前端开发流程和实践特别感兴趣。他曾与多个行业和国家的客户合作,帮助他们更有效地交付 Web 应用程序。
前言
近年来,微服务的发展呈爆炸式增长,许多组织使用这种架构风格来避免大型单体后端的限制。虽然关于这种软件架构风格的代码在服务端已经有很多优秀案例,但许多公司仍然在单体前端代码库中挣扎。
也许你想构建一个渐进式或响应式的 Web 应用程序,但找不到一个合适的地方将这些功能集成到现有代码中。也许你想试试新的 JavaScript 特性(或那些可以编译成 JavaScript 的语言),但你没办法将必要的构建工具融入到你现有的构建流程中。或者你只是想扩大开发规模,让多个团队可以同时开发一个产品,但现有单体中的耦合性和复杂性意味着每个人都在互相踩踏。这些都是真实存在的问题,都会对你高效向客户提供高质量优体验程序的能力产生负面影响。
最近,我们看到越来越多的人开始关注复杂的现代 Web 应用开发所必需的整体架构和组织结构。特别是,我们看到了将前端单体分解成更小、更简单的模块的模式出现,这些模块可以独立开发、测试和部署,同时仍然作为一个单一的内聚的产品出现在客户面前。我们将这种技术称为微前端,我们将其定义为:
一种将能够独立交付的前端应用程序组合成一个更大整体的架构风格
在2016年11月的 ThoughtWorks 技术雷达中,我们将微前端列为组织应该评估的技术。后来我们将其推广到试用,最后推广到采用,这意味着我们将其视为一种经过验证的解决方案,在合适的时候你应该使用。
我们从微前端中看到的一些关键的好处是:
- 更小,更内聚且可维护性更高的代码库
- 更可扩展的组织,拥有独立自主的团队
- 能够以更多的方式升级、更新、甚至重写前端的某些部分
这些引人注目的优势并非巧合,这些优势也是微服务能够提供的一些优势。
当然,在软件架构方面,天下没有免费的午餐,一切都要付出代价。一些微前端的实现可能会导致重复的依赖,加载应用时增加用户必须下载的字节数。此外,团队自主性的大幅提高会导致你的团队工作方式的碎片化。尽管如此,我们相信这些风险是可以控制的,而且微前端的好处往往大于成本。
微前端带来的好处
我们不从具体的技术方法或实现细节来定义微前端,而是强调其属性及这些属性带来的好处。
渐进式升级
对于许多组织来说,这是他们为什么采用微前端的原因。他们那些旧的大型前端单体被老旧的技术栈或是在交付压力下编写的垃圾代码所拖累,已经到了需要完全重写的地步。为了避免全面重写带来的风险,我们更愿意把旧的应用一块一块地掐死,同时继续向客户提供新的功能,而不被单体拖累。
这往往引出了微前端架构。一旦一个团队有了对旧代码几乎不做修改就能将一个新功能全部投入生产环境的经验,其他团队也会想加入这个新世界。现有的代码仍然需要维护,在某些情况下,继续在其中添加新功能可能是有意义的,但现在有别的选择了。
这里的最终目的是,我们获得了更多的自由,可以对产品的个别部分进行逐一决策,并对我们的架构、依赖关系和用户体验进行渐进式升级。如果我们的主框架有重大的突破性变化,每一个微前端都可以在任何时候进行升级,而不是被迫宕机,一次性升级所有的东西。如果我们想尝试新的技术,或者新的交互模式,我们可以用比以前更解耦的方式去做。
简单且解耦的代码库
根据微前端的定义,每个单独的微前端的源代码将比单个单体前端的源代码小得多。这些较小的代码库往往更简单,也更容易为开发人员所用。特别是,我们避免了本不应该相互了解的组件之间无意的、不恰当的耦合所产生的复杂性。通过在应用程序的限界上下文周围画更粗的线,我们使这种意外的耦合更难产生。
当然,一个单一的、高层次的架构决策(即 "让我们来做微前端吧"),并不能代替老式的 "clean code"。我们并不是要免除自己对代码的思考和对其质量的努力。相反,我们是想通过更难做坏的决定,和更容易做简单的决定,让自己掉进成功的坑里。例如,跨边界上下文共享领域模型变得更加困难,所以开发人员不太可能这样做。同样,微前端促使你对数据和事件如何在应用的不同部分之间流动进行明确和慎重的考虑,这也是我们无论如何都应该做的事情!
独立部署
就像微服务一样,微前端能否独立部署是关键。独立部署减少了部署的限制,从而降低了相关风险。无论你的前端代码如何托管或在哪里托管,每个微前端都应该有自己的持续交付流水线,它能够独立构建、测试和部署到生产环境。我们应该能够独立部署每个微前端,而很少考虑其他代码库或流水线的当前状态。不管老的单体前端代码库是不是在一个固定的、手动的、季度性的发布周期,或者隔壁的团队已经把一个半成品或坏掉的功能推送到他们的主分支中,都不应该对你的微前端造成影响。如果一个给定的微前端已经准备好投入生产环境,它应该能够达到上述要求,这个是否部署的决定权也应该由构建和维护它的团队来做。
团队自治
在我们将代码库和发布周期与大的单体前端脱钩后有一个更高层次的好处,我们拥有了一个完全独立的团队,这些团队可以拥有一个产品的一部分,从构思到生产,甚至更多。团队可以完全拥有为客户提供价值所需的一切,这使他们能够快速有效地前进。要做到这一点,我们的团队需要围绕业务功能的垂直切片来组建,而不是围绕技术能力。一个简单的方法是根据最终用户将看到的内容来划分产品,因此每个微前端封装了应用的一个页面,并由一个团队端到端拥有。与围绕技术或 "横向 "关注问题(如样式、表单或验证)组建团队相比,这带来了团队工作更高的内聚。
小结
简而言之,微前端就是把大而可怕的东西切成更小、更容易管理的碎片,然后明确它们之间的依赖关系。我们的技术选择、我们的代码库、我们的团队和我们的发布流程都应该能够相互独立运行和发展,而不需要过多的协调。
示例
想象一下,在一个网站上,顾客可以定外卖。这是一个相当简单的概念,但如果你想做好它,有不少令人惊讶的细节:
- 应该有一个登陆页面,顾客可以在这里浏览和搜索餐厅。餐馆应该可以被搜索,并且可以通过任何数量的属性进行过滤,包括价格、菜品或顾客以前点过的东西等
- 每家餐厅都需要有自己的页面,显示其菜单,并允许顾客选择他们想吃的东西,并提供折扣、用餐优惠等特殊要求
- 客户应该有一个个人资料页面,在这里他们可以看到他们的订单历史,跟踪送货情况,并调整他们的付款方式
每一个页面都有足够的复杂性,我们可以为每一个页面建立一个专门的团队,而且这些团队中的每一个都应该能够独立于所有其他团队在他们自己的页面上工作。他们应该能够开发、测试、部署和维护他们的代码,而不用担心与其他团队的冲突或协调。然而,我们的客户仍然应该看到一个单一的、无缝的网站。
在本文的其余部分,我们将在任何需要示例代码或场景讲解的地方使用这个示例应用程序。
集成方式
鉴于上述相当宽泛的定义,有许多架构都可以合理地称为微前端。在本节中,我们将展示一些例子并讨论它们的取舍。所有的架构都有一个相当自然的模式 -- 一般来说,应用程序中的每个页面都有一个微前端,并且有一个单一的容器应用程序,它应该:
- 渲染常见的页面元素,如页眉和页脚
- 解决认证和导航等跨领域问题
- 将不同的微前端汇集到页面上,并告诉每个微前端在何时何地进行渲染
服务端模板组合
我们首先采用了一种绝对没有新意的前端开发方法 -- 在服务器端用多个模板或片段来渲染 HTML。我们有一个 index.html,它包含了一些常见的页面元素,然后使用服务器端的 includes 从 HTML 片段文件中插入特定的内容:
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<title>Feed me</title>
</head>
<body>
<h1>🍽 Feed me</h1>
<!--# include file="$PAGE.html" -->
</body>
</html>
我们使用 Nginx 来持有这个文件,通过与请求的 URL 匹配来配置 $PAGE 变量:
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
ssi on;
# Redirect / to /browse
rewrite ^/$ http://localhost:8080/browse redirect;
# Decide which HTML fragment to insert based on the URL
location /browse {
set $PAGE 'browse';
}
location /order {
set $PAGE 'order';
}
location /profile {
set $PAGE 'profile'
}
# All locations should render through index.html
error_page 404 /index.html;
}
这是相当标准的服务器端组合。我们之所以可以理直气壮地将其称为微前端,是因为我们将代码以这样的方式分割开来,每一块都代表了一个独立的领域概念,可以由一个独立的团队来交付。这里没有展示的是这些不同的 HTML 文件最终是如何在 Web 服务器上完成的。我们假设它们每个页面都有自己的部署流水线,这使得我们可以在不影响或不考虑任何其他页面的情况下部署对一个页面的更改。
为了获得更多的独立性,可以有一个单独的服务器负责渲染和持有每个微前端,再在前面部署一个服务器,向所有其它服务器发出请求。可以通过对响应的缓存,做到不影响延迟。
这个例子说明了微前端不一定是一种新技术,也不一定要复杂。只要我们谨慎思考设计决策将如何影响我们的代码库和团队的自主性,无论我们的技术栈如何,我们都可以实现由微服务带来的好处。
编译时集成
我们有时会看到的一种方法是将每个微前端作为一个包发布,并让容器应用程序将它们作为库依赖项全部包含在内。下面是前面提到的示例应用的 package.json 样子。
{
"name": "@feed-me/container",
"version": "1.0.0",
"description": "A food delivery web app",
"dependencies": {
"@feed-me/browse-restaurants": "^1.2.3",
"@feed-me/order-food": "^4.5.6",
"@feed-me/user-profile": "^7.8.9"
}
}
乍一看,这似乎是有道理的。它产生了一个单一的可部署的 JavaScript 包,像往常一样,允许我们从我们的各种应用程序中去掉重复的共同依赖关系。然而,这种方法意味着,我们每一次对单个微前端进行修改后,我们都必须重新编译和发布,然后修改相关依赖。就像微服务一样,我们已经看到了这种锁定式的发布过程所带来的痛苦,所以我们强烈建议不要采用这种方法来发布微前端。
在大费周章地将我们的应用划分为可以独立开发和测试的离散代码库之后,我们不要在发布阶段重新引入别的耦合。我们应该找到一种方法,在运行时而不是在构建时整合我们的微前端。
利用 iframes 的运行时集成
在浏览器中把应用程序组合在一起的最简单方法之一就是简陋的 iframe。从本质上讲,iframe 可以很容易地在独立的页面中构建一个子页面。同时它们在样式和全局变量方面互不干扰相互隔离。
<html>
<head>
<title>Feed me!</title>
</head>
<body>
<h1>Welcome to Feed me!</h1>
<iframe id="micro-frontend-container"></iframe>
<script type="text/javascript">
const microFrontendsByRoute = {
'/': 'https://browse.example.com/index.html',
'/order-food': 'https://order.example.com/index.html',
'/user-profile': 'https://profile.example.com/index.html',
};
const iframe = document.getElementById('micro-frontend-container');
iframe.src = microFrontendsByRoute[window.location.pathname];
</script>
</body>
</html>
就像使用服务端的 includes 模板组合一样,用 iframe 构建一个页面并不是一个新的技术,也许看起来并不那么令人兴奋。但如果我们重新审视一下前面列出的微前端的主要好处,只要我们小心翼翼地切分应用和架构团队,iframe 符合大多要求。
我们经常看到很多人不愿意选择 iframe。虽然有些不情愿的想法似乎是出于一种直觉 -- 觉得 iframe 有点 "恶心",但确实也有一些很好的理由让我们避免使用 iframe。上面提到的容易隔离确实会使它们的灵活性低于其他选项。在应用程序的不同部分之间建立集成可能会很困难,所以它们使路由、history 对象和深层链接变得更加复杂,并且 iframe 使得你的页面响应式需要面对一些额外的挑战。
利用 JavaScript 的运行时集成
下一个我们要介绍的方法可能是最灵活的方法,也是我们看到团队最常采用的方法。每一个微前端都使用 <script>
标签包含在页面中,并在加载时暴露出一个全局函数作为其入口点。然后,容器应用决定应该挂载哪个微前端,并调用相关函数来告诉一个微前端在何时何地渲染自己。
<html>
<head>
<title>Feed me!</title>
</head>
<body>
<h1>Welcome to Feed me!</h1>
<!-- These scripts don't render anything immediately -->
<!-- Instead they attach entry-point functions to `window` -->
<script src="https://browse.example.com/bundle.js"></script>
<script src="https://order.example.com/bundle.js"></script>
<script src="https://profile.example.com/bundle.js"></script>
<div id="micro-frontend-root"></div>
<script type="text/javascript">
// These global functions are attached to window by the above scripts
const microFrontendsByRoute = {
'/': window.renderBrowseRestaurants,
'/order-food': window.renderOrderFood,
'/user-profile': window.renderUserProfile,
};
const renderFunction = microFrontendsByRoute[window.location.pathname];
// Having determined the entry-point function, we now call it,
// giving it the ID of the element where it should render itself
renderFunction('micro-frontend-root');
</script>
</body>
</html>
以上显然是一个简陋的例子,但它展示了基本的技术实现。与编译时集成不同,我们可以独立部署每个 bundle.js 文件。而且与 iframes 不同,我们有充分的灵活性,可以按照自己的意愿在微前端之间建立集成。我们可以以多种方式扩展上述代码,例如,只在需要时下载每个 JavaScript 包,或者选择在渲染微前端时将数据传入和传出。
这种方法的灵活性,再加上独立的可部署性,使它成为我们的默认选择,也是我们所见到的最广泛最常见的选择。当我们进入完整的例子时,我们会更详细地探讨它。
利用 Web Component 的运行时集成
对前一种方法的一个变化是,每个微前端都要为容器定义一个 HTML 元素来实例化,而不是为容器定义一个全局函数来调用。
<html>
<head>
<title>Feed me!</title>
</head>
<body>
<h1>Welcome to Feed me!</h1>
<!-- These scripts don't render anything immediately -->
<!-- Instead they each define a custom element type -->
<script src="https://browse.example.com/bundle.js"></script>
<script src="https://order.example.com/bundle.js"></script>
<script src="https://profile.example.com/bundle.js"></script>
<div id="micro-frontend-root"></div>
<script type="text/javascript">
// These element types are defined by the above scripts
const webComponentsByRoute = {
'/': 'micro-frontend-browse-restaurants',
'/order-food': 'micro-frontend-order-food',
'/user-profile': 'micro-frontend-user-profile',
};
const webComponentType = webComponentsByRoute[window.location.pathname];
// Having determined the right web component custom element type,
// we now create an instance of it and attach it to the document
const root = document.getElementById('micro-frontend-root');
const webComponent = document.createElement(webComponentType);
root.appendChild(webComponent);
</script>
</body>
</html>
这里的最终结果和前面的例子很相似,主要区别在于你选择了 "Web Component 的方式" 来实现。如果你喜欢 Web Component 规范,并且你喜欢使用浏览器提供的功能,那么这是一个不错的选择。如果你喜欢在容器应用程序和微前端之间定义自己的接口,那么你可能更喜欢前面的例子。
样式
CSS作为一种语言,本质上是全局的、继承的、级联的,传统上没有模块系统、命名空间和封装。现在确实已经演化出了一些这样的功能,但浏览器的支持往往是缺乏的。在微前端的环境下,很多问题都会加剧。例如,如果一个团队的微前端有一个样式表 h2 { color: black; }
,而另一个团队的微前端则是 h2 { color: blue; }
,如果这两个选择器都位于在同一个页面,那么这两个团队都会疯掉!这并不是一个新问题,但由于这些选择器是由不同的团队在不同的时间编写的,而且代码很可能被拆分在不同的仓库中,使其更难被发现,这使得问题变得更加严重。
多年来,人们发明了许多方法来使 CSS 更容易管理。有些人选择使用严格的命名规范,如 BEM,以确保选择器只应用于预期的地方。另一些人则不愿意仅仅依靠开发规范,他们使用预处理器,比如 SASS,其选择符嵌套可以作为一种命名空间的形式。一种较新的方法是用 CSS 模块或各种 CSS-in-JS 库来程序化地应用所有的样式,这样可以确保样式只应用在开发者想要应用的地方。或者对于更加基于平台的方法,Shadow DOM 也提供了样式隔离。
你所选择的方法并不重要,重要的是你能找到一种方法来确保不同团队的开发人员可以独立地编写他们的样式,并且有信心他们的代码组合成一个单一的应用程序时所有行为都是可预测的。
共享组件库
我们在上面提到过,不同微前端的视觉一致性是很重要的,其中一个方法是开发一个共享的、可重复使用的 UI 组件库。一般来说,我们认为这是一个好主意,虽然很难做好。创建这样一个库的主要好处是通过代码的重用来减少工作量,以及实现视觉上的一致。此外,你的组件库可以作为一个活的样式指南,它可以成为开发者和设计师之间的一个很好的合作点。
最容易出错的事情之一是过早地创建太多这样的组件。创建一个 Foundation Framework 是很有诱惑力的,它包含了所有应用程序都需要的通用视觉效果。然而,经验告诉我们,在你对组件有实际使用之前,很难甚至不可能猜到组件的 API 应该是什么,这就导致了在通用组件的早期阶段出现大量的反复修改。出于这个原因,即使在初期会造成一些重复,我们更倾向于让团队根据自己的需要在自己的代码库中创建自己的组件。让模式自然而然地出现,一旦组件的 API 变得明显,你就可以将重复的代码抽离到共享组件库中,这样可以让你的组件库更加成熟。
最明显的共享对象是 "愚蠢的" 视觉基础,如图标、标签和按钮等。我们也可以共享更复杂的组件,这些组件可能包含大量的 UI 逻辑,比如一个自动完成的下拉式搜索框,或者一个可排序、可过滤、可分页的表格。但是,要注意确保你的共享组件只包含 UI 逻辑,而不包含业务或领域逻辑。当领域逻辑被放到共享库中时,就会在不同的应用中产生高度的耦合,并且增加了更改的难度。举例来说,你通常不应该尝试共享一个 ProductTable,它可能包含许多关于 "product" 到底是什么的概念,以及一个人应该如何操作这个表格的假设。这种领域建模和业务逻辑属于微前端的应用代码,而不是共享库。
与任何共享的内部库一样,围绕着它的所有权和治理,都存在着一些棘手的问题。一种模式是,作为一种共享资产,"每个人"都拥有它,尽管在实践中这通常意味着没有人拥有它。它很快就会变成一个由不同风格的代码组成的大杂烩,没有明确的约定或技术愿景。也有另一种极端,就是共享库的开发完全集中化,但是创建组件的人和消费组件的人之间会有很大的脱节。我们所见过的最好的模式是任何人都可以为库做出贡献,但有一个保管人(一个人或一个团队)负责确保这些贡献的质量、一致性和有效性。维护共享组件库的工作需要很强的技术能力,同时也需要培养和多团队协作所需的人际关系处理能力。
跨应用通信
关于微前端最常见的问题之一是如何让它们互相通信。一般来说,我们建议让它们尽可能少地通信,因为这往往会重新引入我们首先要避免的那种不适当的耦合。
尽管如此,某种程度的跨应用通信通常是需要的。自定义事件允许微前端进行间接通信,这是将直接耦合降到最低的好方法,尽管它确实使明确和执行微前端之间的契约变得更加困难。另外,React 模式将回调和数据向下传递(在本例中,从容器应用向下传递到微前端)也是一个很好的解决方案,它使契约更加明确。第三种选择是使用 URL 地址栏作为通信机制,我们将在后面详细探讨。
无论我们选择什么方法,我们都希望我们的微前端能够通过相互发送消息或事件来进行通信,而避免有任何状态共享。就像在微服务之间共享数据库一样,一旦我们共享我们的数据结构和领域模型,我们就会产生大量的耦合,而且要进行更改会变得非常困难。
如果你正在使用 redux,通常的方法是为整个应用程序拥有一个单一的、全局的、共享的存储。然而,如果每个微前端都应该是自己的独立应用,那么每个微前端都有自己的 redux store 是有意义的。redux 文档中甚至提到 "将 Redux 应用作为更大应用中的一个组件进行隔离" 是拥有多个 store 的一个有效理由。
和样式设计一样,有几种不同的方法可以很好地发挥作用。最重要的是要仔细思考你要引入什么样的耦合,以及如何长期维护这种契约。就像微服务之间的集成一样,如果没有一个跨不同应用和团队的协调升级过程,你将无法对你的集成进行不兼容的突破性更改。
你还应该考虑如何自动验证集成是否被破坏。功能测试是一种方法,但由于实施和维护的成本,我们应该限制我们编写的功能测试的数量。或者你可以实现某种形式的消费者驱动的契约,这样每个微前端都可以指定它对其他微前端的要求,而不需要实际集成后一起在浏览器中运行它们。
前后端通信
如果我们在前端应用上有独立的团队,那么后端开发呢?我们坚信全栈团队(译者注:又叫全功能团队,即不以功能划分团队,一个团队内拥有所有职能的人员,包括但不限于前端开发,后端开发,测试人员,业务人员,设计人员等)的价值,他们拥有自己应用的开发,从可视化代码一直到 API 开发,以及数据库和基础设施代码。有一种模式在这里很有帮助,那就是 BFF 模式,即每个前端应用都有一个相应的后端,其目的仅仅是为了满足该前端的需求。虽然 BFF 模式最初可能是指为每个前端渠道(Web、移动等)提供专门的后端,但它可以很容易地扩展为每个微前端的后端。
这里有很多变数要考虑。BFF 可能是自带业务逻辑和数据库的,也可能只是下游服务的聚合器。如果有下游服务团队,那么对于拥有微前端和 BFF 的团队来说,可能没有太多意义。如果微前端只有一个与之对话的 API,而且这个 API 相当稳定,那么建立一个 BFF 可能根本没有多大价值。这里的指导原则是,构建某个微前端的团队不应该等待其他团队为他们构建东西。所以,如果一个微前端增加的每一个新功能也都需要后端改变,那就很有必要建立一个BFF,由同一个团队拥有。
另一个常见的问题是,微前端应用的用户应该如何与服务器进行认证和授权?显然,我们的客户端应该只需要对自己进行一次认证,所以身份验证显然属于多个客户端交叉关注的范畴,应该由容器应用拥有。容器可能有某种登录形式,我们通过它获得 token。这个 token 将由容器拥有,并且可以在初始化时注入到每个微前端中。最后,微前端可以在向服务器发出任何请求时发送 token,服务器可以做任何需要的验证。
测试
在测试方面,我们看不到单体前端和微前端之间有什么区别。一般来说,无论你使用什么策略来测试单体前端,都可以在每个单独的微前端上重现。也就是说,每个微前端都应该有自己全面的自动化测试套件,以确保代码的质量和正确性。
然后,明显的区别来自于各个微前端与容器应用的集成测试。这样的集成测试可以使用你认为最佳的功能/端到端测试工具(如Selenium或Cypress)来完成,但注意集成测试不要过于细致,这样的测试应该只覆盖那些在测试金字塔底层无法覆盖的情况。也就是说,使用单元测试来覆盖你的底层业务逻辑和渲染逻辑,然后功能测试只是为了验证页面的组装是否正确。例如,您可能会在特定的 URL 处加载完全集成的应用程序,并断言相关某个微前端的某个标题存在于页面上。
如果有跨微前端的用户旅程(User Journey),那么你可以使用功能测试(Functional Test)来覆盖这些场景,但要将功能测试集中在验证几个前端的集成上,而不是每个微前端的内部业务逻辑,因为内部业务逻辑单元测试应该已经覆盖了。如上所述,消费者驱动的契约可以帮助我们直接指定微前端之间应该发生的交互,而无需担心集成测试中集成环境和相关功能测试带来的这些烦恼。
详细例子
到这里本文剩下的大部分内容将只是详细解释我们上面提到的示例应用程序的一种实现方式。我们将主要关注容器应用和微前端如何使用 JavaScript 集成在一起,因为这可能是最有趣和最复杂的部分。你可以在 https://demo.microfrontends.com 上看到网页的最终效果,完整的源代码可以在 Github 上看到。