微前端
良好的前端开发很困难。扩展前端开发,使多个团队能够同时在一个大型复杂的产品上工作,更加困难。在本文中,我们将介绍将前端单体分解成许多更小、更易于管理的部分的最新趋势,以及这种架构如何提高团队在前端代码上的工作效率和有效性。除了讨论各种优点和成本外,我们还将介绍一些可用的实现选项,并深入研究一个完整的示例应用程序,该应用程序演示了该技术。
2019 年 6 月 19 日
近年来,微服务 的流行程度激增,许多组织使用这种架构风格来避免大型单体后端带来的限制。虽然关于这种构建服务器端软件的风格已经有很多文章,但许多公司仍然在与单体前端代码库作斗争。
也许您想构建一个渐进式或响应式 Web 应用程序,但找不到将这些功能轻松集成到现有代码中的方法。也许您想开始使用新的 JavaScript 语言功能(或可以编译为 JavaScript 的众多语言之一),但无法将必要的构建工具集成到现有的构建流程中。或者,也许您只是想扩展您的开发,以便多个团队可以同时在一个产品上工作,但是现有单体中的耦合和复杂性意味着每个人都在互相踩踏。这些都是真实存在的问题,它们都会对您高效地为客户提供高质量体验的能力产生负面影响。
最近,我们看到越来越多的关注点集中在复杂、现代 Web 开发所需的整体架构和组织结构上。特别是,我们看到了一些模式正在出现,这些模式将前端单体分解成更小、更简单的块,这些块可以独立开发、测试和部署,同时仍然对客户显示为一个单一的、连贯的产品。我们称这种技术为 **微前端**,我们将其定义为
"一种架构风格,其中独立可交付的前端应用程序被组合成一个更大的整体"
在 2016 年 11 月的 Thoughtworks 技术雷达中,我们将 微前端 列为组织应该评估的技术。我们后来将其提升到试用阶段,最后提升到采用阶段,这意味着我们认为它是一种经过验证的方法,在有意义的情况下,您应该使用它。
图 1:微前端已多次出现在技术雷达上。
我们从微前端中看到的一些关键好处是
- 更小、更具凝聚力和可维护的代码库
- 具有解耦、自治团队的更具可扩展性的组织
- 能够以比以前更增量的方式升级、更新甚至重写前端的部分
这些主要优势与微服务可以提供的优势相同,这并非巧合。
当然,在软件架构方面,没有免费的午餐——一切都有成本。一些微前端实现会导致依赖项重复,从而增加用户必须下载的字节数。此外,团队自治性的急剧增加会导致团队工作方式的碎片化。尽管如此,我们相信这些风险是可以管理的,并且微前端的优势通常超过其成本。
好处
我们没有从特定的技术方法或实现细节的角度来定义微前端,而是强调出现的属性及其带来的好处。
增量升级
对于许多组织来说,这是他们微前端之旅的开始。旧的、大型的前端单体被过去的技术栈或在交付压力下编写的代码所阻碍,它已经到了让人想要完全重写的程度。为了避免 完全重写的风险,我们更愿意 逐步淘汰 旧的应用程序,同时继续向我们的客户交付新功能,而不会被单体所拖累。
这通常会导致微前端架构。一旦一个团队体验到将一个功能完全交付到生产环境,而对旧世界几乎没有修改,其他团队也会想要加入新世界。现有代码仍然需要维护,在某些情况下,继续向其添加新功能可能是有意义的,但现在可以选择。
这里的最终目标是,我们能够更自由地对产品的各个部分做出逐案决策,并对我们的架构、依赖项和用户体验进行增量升级。如果我们的主要框架存在重大变更,每个微前端可以在有意义的时候进行升级,而不是被迫停止所有工作并立即升级所有内容。如果我们想尝试新的技术或新的交互模式,我们可以以比以前更隔离的方式进行。
简单、解耦的代码库
每个单独的微前端的源代码,从定义上来说,将比单个单体前端的源代码小得多。这些更小的代码库往往更简单,开发人员更容易使用。特别是,我们避免了由于不应相互了解的组件之间的无意和不适当耦合而产生的复杂性。通过在应用程序的 边界上下文 周围绘制更粗的线条,我们使这种意外耦合更难出现。
当然,单一的、高级的架构决策(即“让我们做微前端”),并不能替代传统的干净代码。我们不是试图免除我们思考代码并努力提高其质量。相反,我们试图通过使错误的决策变得困难,而良好的决策变得容易,来让自己进入 成功的陷阱。例如,跨边界上下文共享域模型变得更加困难,因此开发人员不太可能这样做。同样,微前端会迫使您明确和有意地了解数据和事件如何在应用程序的不同部分之间流动,而这本来就是我们应该做的事情!
独立部署
与微服务一样,微前端的独立可部署性是关键。这减少了任何给定部署的范围,进而减少了相关的风险。无论您的前端代码托管在何处或以何种方式托管,每个微前端都应该有自己的持续交付管道,该管道将构建、测试并将其部署到生产环境。我们应该能够部署每个微前端,而无需过多考虑其他代码库或管道的当前状态。旧的单体是否处于固定的、手动、季度发布周期,或者隔壁的团队是否将半成品或有问题的功能推送到他们的主分支,这并不重要。如果一个给定的微前端已准备好投入生产,它应该能够做到,并且该决定应该由构建和维护它的团队决定。
图 2:每个微前端都独立部署到生产环境。
自治团队
作为解耦我们的代码库和发布周期的更高阶优势,我们朝着拥有完全独立的团队迈出了一大步,这些团队可以从构思到生产以及之后拥有产品的某个部分。团队可以完全拥有他们为客户提供价值所需的一切,这使他们能够快速有效地行动。为了使这能够实现,我们的团队需要围绕业务功能的垂直切片来组建,而不是围绕技术能力来组建。一个简单的做法是根据最终用户将看到的内容来划分产品,因此每个微前端封装应用程序的单个页面,并由单个团队端到端拥有。与团队围绕技术或“水平”问题(如样式、表单或验证)组建相比,这带来了团队工作更高的凝聚力。
图 3:每个应用程序都应该由单个团队拥有。
概括
简而言之,微前端就是将大型、可怕的事物切分成更小、更易于管理的部分,然后明确它们之间的依赖关系。我们的技术选择、代码库、团队和发布流程都应该能够彼此独立地运行和发展,而无需过度的协调。
示例
想象一个网站,客户可以在上面订购外卖。从表面上看,这是一个相当简单的概念,但如果你想做好,就会有令人惊讶的细节。
- 应该有一个着陆页,客户可以在上面浏览和搜索餐厅。餐厅应该可以通过各种属性进行搜索和过滤,包括价格、菜系或客户以前订购过的菜品。
- 每家餐厅都需要有自己的页面,上面显示其菜单项,并允许客户选择他们想吃的东西,包括折扣、套餐和特殊要求。
- 客户应该有一个个人资料页面,他们可以在上面查看自己的订单历史记录、跟踪配送情况并自定义付款方式。
图 4:一个外卖网站可能包含几个相当复杂的页面。
每个页面都有足够的复杂性,我们可以轻松地为每个页面分配一个专门的团队,并且每个团队都应该能够独立于所有其他团队在自己的页面上工作。他们应该能够开发、测试、部署和维护自己的代码,而无需担心与其他团队发生冲突或协调。但是,我们的客户仍然应该看到一个单一的、无缝的网站。
在本文的其余部分,我们将使用此示例应用程序,无论何时需要示例代码或场景。
集成方法
鉴于上述定义相当宽泛,许多方法都可以合理地称为微前端。在本节中,我们将展示一些示例并讨论它们的权衡。在所有方法中,都会出现一个相当自然的架构——通常,应用程序中的每个页面都有一个微前端,并且有一个单一的 **容器应用程序**,它
- 呈现常见的页面元素,如页眉和页脚
- 处理跨领域问题,如身份验证和导航
- 将各种微前端组合到页面上,并告诉每个微前端何时何地呈现自身
图 5:您通常可以从页面的视觉结构中推导出您的架构。
服务器端模板组合
我们从一种毫不新颖的前端开发方法开始——在服务器端从多个模板或片段渲染 HTML。我们有一个index.html
,它包含所有通用的页面元素,然后使用服务器端包含来插入来自片段 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 服务器上的,但假设它们都有自己的部署管道,这使我们能够部署对一个页面的更改,而不会影响或考虑任何其他页面。
为了获得更大的独立性,可以有一个单独的服务器负责渲染和提供每个微前端,前面有一个服务器向其他服务器发出请求。通过仔细缓存响应,这可以在不影响延迟的情况下完成。
图 6:这些服务器中的每一个都可以独立构建和部署。
这个例子展示了微前端不一定是新技术,也不一定很复杂。只要我们小心地设计决策如何影响代码库和团队的自主性,无论我们的技术栈如何,我们都可以获得许多相同的好处。
构建时集成
我们有时会看到的一种方法是将每个微前端发布为一个包,并让容器应用程序将它们全部包含为库依赖项。以下是容器的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 包,这与往常一样,使我们能够从各种应用程序中消除重复的通用依赖项。但是,这种方法意味着我们必须重新编译并发布每个微前端,才能发布对产品任何单个部分的更改。就像微服务一样,我们已经看到了这种同步发布过程造成的足够多的痛苦,因此我们强烈建议不要采用这种微前端方法。
既然我们已经费尽心思将应用程序划分为可以独立开发和测试的离散代码库,那么我们不要在发布阶段重新引入所有这些耦合。我们应该找到一种在运行时而不是在构建时集成微前端的方法。
通过 iframe 进行运行时集成
在浏览器中将应用程序组合在一起的最简单方法之一是简陋的 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>
就像服务器端包含选项一样,从 iframe 构建页面并不是一项新技术,也许看起来并不那么令人兴奋。但是,如果我们重新审视微前端的主要优势前面列出,iframe 大致符合要求,只要我们小心地划分应用程序并构建团队结构。
我们经常看到很多人不愿意选择 iframe。虽然其中一些犹豫似乎是由一种直觉驱动的,即 iframe 有点“恶心”,但人们避免使用 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
文件。与 iframe 不同,我们可以完全灵活地根据自己的喜好构建微前端之间的集成。我们可以以多种方式扩展上面的代码,例如仅在需要时下载每个 JavaScript 包,或者在渲染微前端时传入和传出数据。
这种方法的灵活性,加上独立的可部署性,使其成为我们的默认选择,也是我们在野外最常看到的选择。当我们进入完整示例时,我们将更详细地探讨它。
通过 Web Components 进行运行时集成
前一种方法的一个变体是,每个微前端都为容器定义一个 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 组件方式”做事。如果您喜欢 Web 组件规范,并且喜欢使用浏览器提供的功能,那么这是一个不错的选择。如果您更喜欢定义容器应用程序和微前端之间的自己的接口,那么您可能更喜欢前面的示例。
样式
CSS 作为一种语言,本质上是全局的、继承的和级联的,传统上没有模块系统、命名空间或封装。现在,其中一些功能确实存在,但浏览器支持往往不足。在微前端环境中,许多这些问题都加剧了。例如,如果一个团队的微前端有一个样式表,其中写着h2 { color: black; }
,而另一个团队写着h2 { color: blue; }
,并且这两个选择器都附加到同一个页面,那么有人就会失望!这不是一个新问题,但由于这些选择器是由不同的团队在不同的时间编写的,并且代码可能分布在不同的存储库中,因此更难发现,这个问题变得更糟了。
多年来,人们发明了许多方法来使 CSS 更易于管理。有些人选择使用严格的命名约定,例如BEM,以确保选择器仅在预期的地方应用。其他人则更喜欢不依赖于开发人员的纪律,而是使用诸如SASS之类的预处理器,其选择器嵌套可以用作命名空间的一种形式。一种较新的方法是使用CSS 模块或各种CSS-in-JS库以编程方式应用所有样式,这确保样式仅在开发人员预期的地方直接应用。或者,对于更基于平台的方法,影子 DOM也提供样式隔离。
您选择的方法并不重要,只要您找到一种方法来确保开发人员可以独立地编写样式,并确信他们的代码在组合到单个应用程序中时会按预期运行。
跨应用程序通信
关于微前端最常见的问题之一是如何让它们相互通信。一般来说,我们建议它们尽可能少地通信,因为它通常会重新引入我们试图避免的这种不适当的耦合。
也就是说,通常需要一定程度的跨应用程序通信。自定义事件允许微前端间接通信,这是一种最大程度地减少直接耦合的好方法,尽管它确实使确定和强制微前端之间存在的契约变得更加困难。或者,React 模型将回调和数据向下传递(在本例中,从容器应用程序向下传递到微前端)也是一个很好的解决方案,它使契约更加明确。第三种选择是使用地址栏作为通信机制,我们将在后面更详细地探讨。
无论我们选择哪种方法,我们都希望我们的微前端通过相互发送消息或事件来进行通信,并避免任何共享状态。就像在微服务之间共享数据库一样,一旦我们共享数据结构和领域模型,我们就会创建大量的耦合,并且更改变得极其困难。
与样式一样,这里也有几种不同的方法可以很好地工作。最重要的是要认真思考你正在引入哪种耦合,以及如何随着时间的推移来维护这种契约。就像微服务之间的集成一样,你无法在不进行跨不同应用程序和团队的协调升级的情况下,对你的集成进行重大更改。
你还要考虑如何自动验证集成不会中断。功能测试是一种方法,但我们更喜欢限制我们编写的功能测试数量,因为实现和维护它们的成本很高。或者,你可以实现某种形式的消费者驱动的契约,这样每个微前端就可以指定它对其他微前端的要求,而无需实际集成并在浏览器中一起运行它们。
后端通信
如果我们有独立工作的不同团队来开发前端应用程序,那么后端开发呢?我们坚信全栈团队的价值,他们拥有从可视化代码到 API 开发,以及数据库和基础设施代码的应用程序开发。这里有助于的一种模式是BFF模式,其中每个前端应用程序都有一个对应的后端,其目的仅仅是满足该前端的需求。虽然 BFF 模式最初可能意味着为每个前端通道(web、移动等)提供专用后端,但它可以轻松扩展到意味着为每个微前端提供一个后端。
这里有很多变量需要考虑。BFF 可能是自包含的,拥有自己的业务逻辑和数据库,或者它可能只是一个下游服务的聚合器。如果有下游服务,拥有微前端及其 BFF 的团队是否也应该拥有其中一些服务,这可能是有意义的,也可能没有意义。如果微前端只有一个它与之通信的 API,并且该 API 相当稳定,那么构建 BFF 可能没有太大价值。这里的指导原则是在构建特定微前端的团队不应该等待其他团队为他们构建东西。因此,如果添加到微前端的每个新功能都需要后端更改,那么这将是 BFF 的一个强有力理由,由同一个团队拥有。
图 7:有许多不同的方法来构建你的前端/后端关系
另一个常见的问题是,微前端应用程序的用户应该如何与服务器进行身份验证和授权?显然,我们的客户只需要对自己进行一次身份验证,因此身份验证通常属于应该由容器应用程序拥有的跨领域问题。容器可能具有一些登录表单,通过这些表单我们获得某种令牌。该令牌将由容器拥有,并且可以在初始化时注入到每个微前端中。最后,微前端可以在它向服务器发出的任何请求中发送令牌,服务器可以执行所需的任何验证。
测试
在测试方面,我们没有看到整体前端和微前端之间有太大区别。一般来说,你用来测试整体前端的任何策略都可以复制到每个单独的微前端。也就是说,每个微前端都应该有自己全面的自动化测试套件,以确保代码的质量和正确性。
然后,明显的差距将是各种微前端与容器应用程序的集成测试。这可以使用你选择的任何功能/端到端测试工具(如 Selenium 或 Cypress)来完成,但不要做得太过火;功能测试应该只涵盖无法在测试金字塔的较低级别进行测试的方面。这意味着,使用单元测试来覆盖你的低级业务逻辑和渲染逻辑,然后使用功能测试来验证页面是否正确组装。例如,你可以在特定 URL 加载完全集成的应用程序,并断言相关微前端的硬编码标题存在于页面上。
如果存在跨越微前端的用户旅程,那么你可以使用功能测试来覆盖这些旅程,但要保持功能测试专注于验证前端的集成,而不是每个微前端的内部业务逻辑,这些逻辑应该已经由单元测试覆盖。 如上所述,消费者驱动的契约可以帮助直接指定微前端之间发生的交互,而无需集成环境和功能测试的脆弱性。
详细示例
本文的大部分内容将详细解释我们示例应用程序的实现方式之一。我们将主要关注容器应用程序和微前端如何使用 JavaScript 集成在一起,因为这可能是最有趣和最复杂的部分。你可以在https://demo.microfrontends.com查看部署的最终结果,并在Github上查看完整的源代码。
图 8:完整的微前端演示应用程序的“浏览”登录页面
演示都是使用 React.js 构建的,因此值得一提的是,React 并不垄断这种架构。微前端可以使用许多不同的工具或框架来实现。我们在这里选择 React 是因为它很流行,而且我们自己也熟悉它。
容器
我们将从容器开始,因为它是我们客户的入口点。让我们看看我们能从它的 package.json
中学到什么
{ "name": "@micro-frontends-demo/container", "description": "Entry point and container for a micro frontends demo", "scripts": { "start": "PORT=3000 react-app-rewired start", "build": "react-app-rewired build", "test": "react-app-rewired test" }, "dependencies": { "react": "^16.4.0", "react-dom": "^16.4.0", "react-router-dom": "^4.2.2", "react-scripts": "^2.1.8" }, "devDependencies": { "enzyme": "^3.3.0", "enzyme-adapter-react-16": "^1.1.1", "jest-enzyme": "^6.0.2", "react-app-rewire-micro-frontends": "^0.0.1", "react-app-rewired": "^2.1.1" }, "config-overrides-path": "node_modules/react-app-rewire-micro-frontends" }
从 react
和 react-scripts
的依赖项中,我们可以得出结论,它是一个使用create-react-app
创建的 React.js 应用程序。更有趣的是它没有什么:任何关于我们将组合在一起形成最终应用程序的微前端的提及。如果我们在这里将它们指定为库依赖项,我们将走上构建时集成的道路,正如之前提到的,这往往会导致我们的发布周期中出现问题耦合。
要了解我们如何选择和显示微前端,让我们看看 App.js
。我们使用React Router 将当前 URL 与预定义的路由列表进行匹配,并渲染相应的组件
<Switch> <Route exact path="/" component={Browse} /> <Route exact path="/restaurant/:id" component={Restaurant} /> <Route exact path="/random" render={Random} /> </Switch>
Random
组件并不那么有趣 - 它只是将页面重定向到随机选择的餐厅 URL。Browse
和 Restaurant
组件如下所示
const Browse = ({ history }) => ( <MicroFrontend history={history} name="Browse" host={browseHost} /> ); const Restaurant = ({ history }) => ( <MicroFrontend history={history} name="Restaurant" host={restaurantHost} /> );
在这两种情况下,我们都渲染了一个 MicroFrontend
组件。除了历史对象(稍后会变得很重要)之外,我们还指定了应用程序的唯一名称,以及可以从其下载捆绑包的主机。此配置驱动的 URL 在本地运行时将类似于 http://localhost:3001
,在生产环境中将类似于 https://browse.demo.microfrontends.com
。
在 App.js
中选择了微前端后,现在我们将它渲染到 MicroFrontend.js
中,它只是另一个 React 组件
class MicroFrontend extends React.Component { render() { return <main id={`${this.props.name}-container`} />; } }
这不是整个类,我们很快就会看到它的更多方法。
在渲染时,我们所做的只是在页面上放置一个容器元素,该元素具有对微前端唯一的 ID。在这里,我们将告诉我们的微前端渲染自己。我们使用 React 的 componentDidMount
作为下载和挂载微前端的触发器
class MicroFrontend…
componentDidMount() { const { name, host } = this.props; const scriptId = `micro-frontend-script-${name}`; if (document.getElementById(scriptId)) { this.renderMicroFrontend(); return; } fetch(`${host}/asset-manifest.json`) .then(res => res.json()) .then(manifest => { const script = document.createElement('script'); script.id = scriptId; script.src = `${host}${manifest['main.js']}`; script.onload = this.renderMicroFrontend; document.head.appendChild(script); }); }
首先,我们检查具有唯一 ID 的相关脚本是否已经下载,如果是,我们可以立即渲染它。如果不是,我们从适当的主机获取 asset-manifest.json
文件,以便查找主脚本资产的完整 URL。设置好脚本的 URL 后,剩下的就是将其附加到文档,并使用一个 onload
处理程序来渲染微前端
class MicroFrontend…
renderMicroFrontend = () => { const { name, history } = this.props; window[`render${name}`](`${name}-container`, history); // E.g.: window.renderBrowse('browse-container', history); };
在上面的代码中,我们调用了一个名为 window.renderBrowse
的全局函数,该函数是由我们刚刚下载的脚本放置在那里的。我们向它传递了 <main>
元素的 ID,微前端应该渲染到该元素中,以及一个 history
对象,我们将在稍后解释。此全局函数的签名是容器应用程序和微前端之间的关键契约。任何通信或集成都应该发生在这里,因此保持它相当轻量级可以使其易于维护,并在将来添加新的微前端。无论何时我们想要做一些需要更改此代码的事情,我们都应该认真思考这对我们代码库的耦合以及契约的维护意味着什么。
还有一个最终的部分,即处理清理。当我们的 MicroFrontend
组件卸载(从 DOM 中删除)时,我们也希望卸载相关的微前端。每个微前端都为此目的定义了一个相应的全局函数,我们从适当的 React 生命周期方法中调用它
class MicroFrontend…
componentWillUnmount() { const { name } = this.props; window[`unmount${name}`](`${name}-container`); }
就其自身内容而言,容器直接渲染的只是网站的顶级标题和导航栏,因为这些内容在所有页面中都是一致的。这些元素的 CSS 已被仔细编写,以确保它只会对标题内的元素进行样式设置,因此它不应该与微前端内的任何样式代码发生冲突。
这就是容器应用程序的全部内容!它相当基本,但这为我们提供了一个外壳,可以在运行时动态下载我们的微前端,并将它们粘合在一起,形成一个在单个页面上的凝聚力。这些微前端可以独立部署到生产环境,而无需对任何其他微前端或容器本身进行任何更改。
微前端
继续这个故事的逻辑位置是我们在不断提到的全局渲染函数。我们应用程序的主页是一个可过滤的餐厅列表,其入口点如下所示
import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; import registerServiceWorker from './registerServiceWorker'; window.renderBrowse = (containerId, history) => { ReactDOM.render(<App history={history} />, document.getElementById(containerId)); registerServiceWorker(); }; window.unmountBrowse = containerId => { ReactDOM.unmountComponentAtNode(document.getElementById(containerId)); };
通常在 React.js 应用程序中,对 ReactDOM.render
的调用将在顶级作用域中,这意味着只要加载了此脚本文件,它就会立即开始渲染到硬编码的 DOM 元素中。对于此应用程序,我们需要能够控制渲染的时间和位置,因此我们将它包装在一个函数中,该函数接收 DOM 元素的 ID 作为参数,并将该函数附加到全局 window
对象。我们还可以看到用于清理的相应卸载函数。
虽然我们已经看到了当微前端集成到整个容器应用程序中时如何调用此函数,但这里最大的成功标准之一是我们能够独立开发和运行微前端。因此,每个微前端也有自己的 index.html
,其中包含一个内联脚本,用于在“独立”模式下渲染应用程序,在容器之外
<html lang="en"> <head> <title>Restaurant order</title> </head> <body> <main id="container"></main> <script type="text/javascript"> window.onload = () => { window.renderRestaurant('container'); }; </script> </body> </html>
图 9:每个微前端都可以作为独立应用程序在容器之外运行。
从这一点开始,微前端基本上只是普通的 React 应用程序。 '浏览' 应用程序从后端获取餐厅列表,提供 <input>
元素用于搜索和过滤餐厅,并渲染 React Router <Link>
元素,这些元素导航到特定的餐厅。在那一点上,我们将切换到第二个 '订单' 微前端,它渲染一个带有菜单的单一餐厅。
图 10:这些微前端仅通过路由更改进行交互,而不是直接交互
关于我们的微前端,最后值得一提的是,它们都使用styled-components
进行所有样式设置。这个 CSS-in-JS 库可以轻松地将样式与特定组件关联起来,因此我们可以保证微前端的样式不会泄露到容器或其他微前端中。
通过路由进行跨应用程序通信
我们之前提到过,跨应用程序通信应该保持在最低限度。在这个例子中,我们唯一的需求是浏览页面需要告诉餐厅页面加载哪个餐厅。在这里,我们将看到如何使用客户端路由来解决这个问题。
这里涉及的所有三个 React 应用程序都使用 React Router 进行声明式路由,但初始化方式略有不同。对于容器应用程序,我们创建一个<BrowserRouter>
,它会在内部实例化一个history
对象。这就是我们之前一直忽略的history
对象。我们使用这个对象来操作客户端历史记录,也可以用它来连接多个 React Router。在我们的微前端中,我们这样初始化 Router
<Router history={this.props.history}>
在这种情况下,我们没有让 React Router 实例化另一个 history 对象,而是向它提供了容器应用程序传递的实例。现在所有<Router>
实例都已连接,因此在任何一个实例中触发的路由更改都会反映在所有实例中。这为我们提供了一种简单的方法,可以通过 URL 将“参数”从一个微前端传递到另一个微前端。例如,在浏览微前端中,我们有一个这样的链接
<Link to={`/restaurant/${restaurant.id}`}>
当点击此链接时,路由将在容器中更新,容器将看到新的 URL 并确定应该挂载和渲染餐厅微前端。然后,该微前端自己的路由逻辑将从 URL 中提取餐厅 ID 并渲染正确的信息。
希望这个示例流程展示了简单 URL 的灵活性和强大功能。除了用于共享和书签之外,在这个特定的架构中,它可以成为跨微前端传达意图的一种有用方式。将页面 URL 用于此目的符合许多要求
- 它的结构是一个定义明确的开放标准
- 它对页面上的任何代码都可全局访问
- 它的大小有限,鼓励只发送少量数据
- 它是面向用户的,这鼓励使用忠实地模拟域的结构
- 它是声明式的,而不是命令式的。即“我们现在的位置”,而不是“请做这件事”
- 它迫使微前端间接通信,而不是直接了解或依赖彼此
当使用路由作为微前端之间的通信方式时,我们选择的路由构成一个契约。在这种情况下,我们已经确定了可以在/restaurant/:restaurantId
查看餐厅的想法,并且我们不能在不更新所有引用它的应用程序的情况下更改该路由。鉴于此契约的重要性,我们应该进行自动化测试,以检查是否遵守了此契约。
通用内容
虽然我们希望我们的团队和微前端尽可能独立,但有些事情应该是通用的。我们之前写过共享组件库如何帮助跨微前端保持一致性,但对于这个小型演示来说,组件库过于复杂。因此,我们有一个小型通用内容存储库,包括图像、JSON 数据和 CSS,这些内容通过网络提供给所有微前端。
我们还可以选择在微前端之间共享另一项内容:库依赖项。正如我们将在稍后描述的那样,依赖项重复是微前端的常见缺点。即使跨应用程序共享这些依赖项会带来自身的一系列困难,但对于这个演示应用程序来说,讨论如何实现这一点还是值得的。
第一步是选择要共享哪些依赖项。对我们编译代码的快速分析表明,大约 50% 的捆绑包是由react
和react-dom
贡献的。除了它们的大小之外,这两个库是我们最“核心”的依赖项,因此我们知道所有微前端都可以从提取它们中受益。最后,这些是稳定、成熟的库,通常会在两个主要版本之间引入重大更改,因此跨应用程序升级工作应该不会太困难。
至于实际提取,我们只需要在 webpack 配置中将这些库标记为外部,我们可以使用类似于之前描述的重连来实现。
module.exports = (config, env) => { config.externals = { react: 'React', 'react-dom': 'ReactDOM' } return config; };
然后,我们在每个index.html
文件中添加几个script
标签,以从我们的共享内容服务器中获取这两个库。
<body> <noscript> You need to enable JavaScript to run this app. </noscript> <div id="root"></div> <script src="%REACT_APP_CONTENT_HOST%/react.prod-16.8.6.min.js"></script> <script src="%REACT_APP_CONTENT_HOST%/react-dom.prod-16.8.6.min.js"></script> </body>
跨团队共享代码始终是一件很难做好事情。我们需要确保我们只共享我们真正想共享的东西,以及我们想在多个地方同时更改的东西。但是,如果我们对共享什么和不共享什么保持谨慎,那么我们可以获得真正的益处。
基础设施
该应用程序托管在 AWS 上,核心基础设施(S3 存储桶、CloudFront 分发、域、证书等)使用集中式 Terraform 代码存储库一次性配置。然后,每个微前端都有自己的源代码存储库,在Travis CI上拥有自己的持续部署管道,该管道会构建、测试并将静态资产部署到这些 S3 存储桶中。这平衡了集中式基础设施管理的便利性和独立可部署性的灵活性。
请注意,每个微前端(以及容器)都有自己的存储桶。这意味着它可以自由支配存储桶中的内容,我们无需担心对象名称冲突或来自其他团队或应用程序的冲突访问管理规则。
缺点
在本文开头,我们提到过微前端存在权衡,就像任何架构一样。我们提到的好处确实需要付出代价,我们将在下面介绍。
有效载荷大小
独立构建的 JavaScript 捆绑包会导致常见依赖项的重复,从而增加了我们必须发送到最终用户网络的字节数。例如,如果每个微前端都包含自己的 React 副本,那么我们就会迫使我们的客户下载 React n 次。之间存在直接关系页面性能和用户参与度/转化率之间,而且世界上大部分地区运行的互联网基础设施比发达城市的互联网基础设施慢得多,因此我们有很多理由关心下载大小。
这个问题不容易解决。我们希望让团队独立编译他们的应用程序以便他们能够自主工作,以及我们希望以一种可以共享常见依赖项的方式构建应用程序之间存在着固有的矛盾。一种方法是从我们的编译捆绑包中提取公共依赖项,正如我们为演示应用程序所描述的那样。但是,一旦我们走上这条道路,我们就重新将一些构建时耦合引入到我们的微前端中。现在它们之间存在一个隐式契约,即“我们都必须使用这些依赖项的这些确切版本”。如果依赖项中存在重大更改,我们最终可能需要进行大规模的协调升级工作和一次性的同步发布事件。这正是我们最初使用微前端想要避免的一切!
这种固有的矛盾是一个难题,但并非全是坏消息。首先,即使我们选择对重复的依赖项置之不理,每个页面仍然可能比我们构建单个单体前端时加载得更快。原因是通过独立编译每个页面,我们实际上已经实现了我们自己的代码拆分形式。在传统的单体架构中,当加载应用程序中的任何页面时,我们通常会一次性下载所有页面的源代码和依赖项。通过独立构建,任何单个页面加载只会下载该页面的源代码和依赖项。这可能会导致更快的初始页面加载,但后续导航速度会变慢,因为用户被迫在每个页面上重新下载相同的依赖项。如果我们有纪律地不使用不必要的依赖项来膨胀我们的微前端,或者如果我们知道用户通常只停留在应用程序中的一个或两个页面上,那么我们甚至可能在性能方面获得净收益,即使存在重复的依赖项。
上一段中有很多“可能”和“也许”,这突出了这样一个事实,即每个应用程序始终具有其独特的性能特征。如果你想确切地知道特定更改对性能的影响,那么除了进行实际测量之外别无他法,最好是在生产环境中进行测量。我们已经看到团队为几千字节的额外 JavaScript 代码而苦苦挣扎,结果却下载了几兆字节的高分辨率图像,或者对非常慢的数据库运行昂贵的查询。因此,虽然考虑每个架构决策对性能的影响很重要,但请确保你知道真正的瓶颈在哪里。
环境差异
我们应该能够开发单个微前端,而无需考虑其他团队开发的所有其他微前端。我们甚至可能能够在“独立”模式下运行我们的微前端,在一个空白页面上,而不是在生产环境中容纳它的容器应用程序中。这可以使开发变得更加简单,尤其是在真正的容器是一个复杂的遗留代码库时,当我们使用微前端从旧世界逐步迁移到新世界时,这种情况经常发生。但是,在与生产环境截然不同的环境中进行开发存在风险。如果我们的开发时容器的行为与生产容器不同,那么我们可能会发现我们的微前端已损坏,或者在部署到生产环境时行为不同。特别令人担忧的是容器或其他微前端可能带来的全局样式。
这里的解决方案与我们必须担心环境差异的任何其他情况并没有什么不同。如果我们在本地开发的环境与生产环境不同,我们需要确保定期将我们的微前端集成并部署到类似生产环境的环境中,并且我们应该在这些环境中进行测试(手动和自动),以便尽早发现集成问题。这并不能完全解决问题,但最终它是我们必须权衡的另一个权衡:简化的开发环境带来的生产力提升是否值得冒集成问题的风险?答案将取决于项目!
运营和治理复杂性
最后一个缺点与微服务直接相关。作为一种更分布式的架构,微前端不可避免地会导致管理更多东西——更多存储库、更多工具、更多构建/部署管道、更多服务器、更多域等等。因此,在采用这种架构之前,您应该考虑以下几个问题
- 您是否已到位足够的自动化来切实可行地配置和管理所需的额外基础设施?
- 您的前端开发、测试和发布流程是否能够扩展到多个应用程序?
- 您是否对工具和开发实践方面的决策变得更加分散和难以控制感到满意?
- 您将如何确保在您众多独立的前端代码库中保持最低限度的质量、一致性或治理?
我们可能可以写另一篇文章来讨论这些主题。我们想强调的要点是,当您选择微前端时,您实际上是在选择创建许多小东西而不是一个大东西。您应该考虑您是否具备采用这种方法所需的技术和组织成熟度,而不会造成混乱。
结论
随着前端代码库在未来几年变得越来越复杂,我们看到对更可扩展的架构的需求越来越大。我们需要能够划清明确的界限,在技术和领域实体之间建立适当的耦合和内聚级别。我们应该能够跨独立的、自主的团队扩展软件交付。
虽然微前端并非唯一的方法,但我们已经看到了许多现实案例,微前端在这些案例中带来了这些好处,并且我们能够随着时间的推移逐渐将该技术应用于遗留代码库和新代码库。无论微前端是否适合您和您的组织,我们都希望这将成为一种持续趋势的一部分,在这种趋势中,前端工程和架构将得到我们所知道的应有的重视。
致谢
衷心感谢 Charles Korn、Andy Marks 和 Willem Van Ketwich 对本文的详细审阅和反馈。
还要感谢 Bill Codding、Michael Strasser 和 Shirish Padalkar 在 Thoughtworks 内部邮件列表中提供的意见。
感谢 Martin Fowler 的反馈,以及将本文收录在他的网站上。
最后,感谢 Evan Bottcher 和 Liauw Fendy 的鼓励和支持。
重大修订
2019 年 6 月 19 日:发布关于缺点的最终部分
2019 年 6 月 17 日:发布包含示例的部分
2019 年 6 月 13 日:发布包含从样式到测试的部分
2019 年 6 月 11 日:发布关于集成方法的部分
2019 年 6 月 10 日:发布第一部分:涵盖优势