详解微前端

原创
2021/04/14 13:57
阅读数 2.1W
AI总结

好的前端开发很难。扩展前端开发,使许多团队可以同时处理大型复杂产品,这变得更加困难。在本文中,我们将描述将前端整体拆分成许多更小,更易管理的片段的最新趋势,以及该体系结构如何提高处理前端代码的团队的效率和效率。在讨论各种收益和成本的同时,我们还将介绍一些可用的实现选项,并且将深入研究一个演示该技术的完整示例应用程序。

近年来,微服务已迅速普及,许多组织都使用这种架构风格来避免大型,整体后端的局限性。尽管有关构建服务器端软件这种风格的文章已很多,但许多公司仍在与整体式前端代码库作斗争。

也许您想构建一个渐进式或响应式Web应用程序,但是找不到一个轻松的地方来开始将这些功能集成到现有代码中。也许您想开始使用新的JavaScript语言功能(或可以编译为JavaScript的多种语言之一),但是您无法在现有的构建过程中使用必要的构建工具。或者,也许您只是想扩展您的开发,以便多个团队可以同时处理一个产品,但是现有整体中的耦合和复杂性意味着每个人都在互相踩脚。这些都是真正的问题,都会对您有效地向客户提供高质量体验的能力产生负面影响。

最近,我们看到越来越多的注意力集中在复杂的现代Web开发所必需的总体体系结构和组织结构上。特别是,我们看到了将前端整体分解为更小,更简单的块的模式,这些块可以独立开发,测试和部署,同时仍然对客户而言是一个具有凝聚力的产品。我们称这种技术为微前端,我们将其定义为:

“一种架构风格,可独立交付的前端应用程序组成了一个更大的整体”

在ThoughtWorks技术雷达的2016年11月号中,我们列出了微前端作为组织应评估的一种技术。后来我们将其推广到试用版,最后推广到采用,这意味着我们认为它是一种行之有效的方法,应在合理的情况下使用。

图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来提供此文件,并$PAGE通过与所请求的URL进行匹配来配置变量:

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>

就像使用服务器端include选项一样,从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组件进行运行时集成

对前一种方法的一种变体是为每个微前端定义一个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组件规范,并且喜欢使用浏览器提供的功能的想法,那么这是一个不错的选择。如果您希望在容器应用程序和微前端之间定义自己的接口,那么您可能更喜欢前面的示例。


Styling

CSS作为一种语言固有地是全局的,继承的和级联的,传统上没有模块系统,命名空间或封装。这些功能中的某些功能现在确实存在,但通常缺乏浏览器支持。在微前端环境中,许多问题都变得更加严重。例如,如果一个团队的微前端的样式表为h2 { color: black; },而另一个团队的则为h2 { color: blue; },而这两个选择器都附加在同一页面上,那么某个人会很失望的!这不是一个新问题,但是由于这些选择器是由不同的团队在不同的时间编写的,并且使代码可能分散在不同的存储库中,因此使发现变得更加困难,这使情况变得更糟。

多年来,已经发明了许多方法来使CSS更易于管理。有些选择使用严格的命名约定,例如BEM,以确保选择器仅在需要的地方应用。其他一些人则不想单独依赖开发人员纪律,而是使用预处理器,例如SASS,其选择器嵌套可以用作命名空间的一种形式。一种较新的方法是通过CSS模块或各种CSS-in-JS库之一以编程方式应用所有样式,以确保仅将样式直接应用于开发人员想要的位置。或者,对于更基于平台的方法,shadow DOM还提供了样式隔离。

只要您找到一种方法来确保开发人员可以彼此独立地编写样式,并确信将其代码组合到一个应用程序中便可以预测其行为,那么您选择的方法就没什么大不了的。


共享组件库

上面我们提到,跨微前端的视觉一致性很重要,一种解决方法是开发一个共享的,可重复使用的UI组件库。总的来说,我们认为这是一个好主意,尽管很难做到。创建这样一个库的主要好处是通过重复使用代码减少了工作量,并实现了视觉一致性。此外,您的组件库可以充当生活风格指南,并且可以是开发人员和设计师之间进行协作的重要方面。

最容易出错的事情之一就是太早地创建了太多这些组件。试图创建一个Foundation Framework,并具有所有应用程序所需的所有常见视觉效果。但是,经验告诉我们,在现实世界中使用组件之前,很难(即使不是不可能)猜测组件的API应该是什么,这会导致组件的早期使用大量混乱。因此,我们希望让团队根据需要在代码库中创建自己的组件,即使这最初会导致某些重复。允许模式自然出现,并且一旦组件的API变得很明显,您就可以将重复的代码收集到共享库中,并确信您已经证明了这一点。

共享最明显的候选对象是“哑”的视觉原语,例如图标,标签和按钮。我们还可以共享更复杂的组件,这些组件可能包含大量的UI逻辑,例如自动完成的下拉搜索字段。或可排序,可过滤的分页表格。但是,请注意确保共享的组件仅包含UI逻辑,而不包含业务或域逻辑。将域逻辑放入共享库后,它将在应用程序之间建立高度的耦合,并增加了更改的难度。因此,例如,您通常不应该尝试共享一个ProductTable,其中包含有关“产品”的确切含义和行为方式的各种假设。这样的域建模和业务逻辑属于微前端的应用程序代码,而不是共享库中。

与任何共享内部库一样,围绕其所有权和治理也存在一些棘手的问题。一种模式是说,“所有人”都拥有它作为共享资产,尽管实际上这通常意味着没有人拥有它。如果没有明确的约定或技术远见,它很快就会成为不一致代码的大杂烩。在另一个极端,如果完全集中共享库的开发,则在创建组件的人员和使用这些组件的人员之间将存在很大的脱节。我们看到的最好的模型是任何人都可以为图书馆做出贡献的模型,但是有一个托管人(一个人或一个团队)负责确保这些贡献的质量,一致性和有效性。维护共享库的工作需要强大的技术技能,但也需要培养许多团队之间的协作所必需的人员技能。


跨应用程序通信

关于微前端的最常见问题之一是如何让它们彼此交谈。通常,我们建议让他们尽可能少地进行交流,因为这通常会重新引入我们一开始要避免的那种不适当的耦合。

也就是说,经常需要某种程度的跨应用程序通信。定制事件允许微前端进行间接通信,这是使直接耦合最小化的一种好方法,尽管这样做确实使确定和执行微前端之间存在的合同变得更加困难。另外,向下传递回调和数据(在这种情况下,从容器应用程序向下传递到微前端)的React模型也是使合同更加明确的一种很好的解决方案。第三种选择是使用地址栏作为一种通信机制,我们将在后面详细探讨。

如果您使用的是redux,则通常的方法是为整个应用程序使用单个全局共享存储。但是,如果每个微前端都应该是自己的独立应用程序,那么每个微前端都有自己的redux存储是有意义的。Redux文档甚至提到“将Redux应用程序隔离为更大的应用程序中的组件”是拥有多个商店的有效理由。

无论我们选择哪种方法,我们都希望我们的微前端通过彼此发送消息或事件进行通信,并避免具有任何共享状态。就像跨微服务共享数据库一样,一旦我们共享数据结构和域模型,我们就会创建大量的耦合,并且进行更改变得极为困难。

与样式一样,这里有几种不同的方法可以很好地起作用。最重要的是,要认真思考正在引入的耦合类型,以及随着时间的推移如何维护该合同。就像微服务之间的集成一样,如果没有跨不同应用程序和团队的协调升级过程,您将无法对集成进行重大更改。

您还应该考虑如何自动验证集成没有中断。功能测试是一种方法,但是由于实现和维护它们的成本,我们更倾向于限制编写的功能测试的数量。或者,您可以实施某种形式的消费者驱动的合同,以便每个微前端可以指定它对其他微前端的要求,而无需实际将它们全部集成在一起并在浏览器中运行。


后端通讯

如果我们有独立的团队在前端应用程序上独立工作,那么后端开发又如何呢?我们坚信全栈团队的价值,他们拥有从可视代码一直到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"
}

在版本1中react-scripts,可能有多个应用程序共存于一个页面上而没有冲突,但是版本2使用了一些webpack功能,当两个或多个应用程序试图在一个页面上呈现自己时,这些功能会导致错误。因此,我们使用react-app-rewired覆盖的一些内部webpack配置react-scripts。这样可以解决这些错误,并让我们继续依靠它react-scripts来管理构建工具。

从依赖关系react和react-scripts,我们可以得出结论,这是与创建React.js应用create-react-app。更有趣的是没有什么:我们将一起组成最终应用程序的任何微前端的提及。如果我们在这里将它们指定为库依赖项,那么我们将走在构建时集成的道路上,如前所述,构建时集成往往会在我们的发布周期中引起问题耦合。

要查看如何选择和显示微前端,让我们看一下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对于微前端是唯一的。这是我们告诉微前端进行渲染的地方。我们使用ReactcomponentDidMount作为下载和安装微前端的触发器:

componentDidMount 是React组件的生命周期方法,在第一次将组件实例“安装”到DOM后,框架便会调用该方法。

类 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);
      });
  }

componentDidMount 是React组件的生命周期方法,在第一次将组件实例“安装”到DOM后,框架便会调用该方法。

首先,我们检查是否已经下载了具有唯一ID的相关脚本,在这种情况下,我们可以立即对其进行渲染。如果不是,我们asset-manifest.json从适当的主机获取文件,以查找主脚本资产的完整URL。设置脚本的URL后,剩下的就是将其附加到文档,并带有一个onload呈现微前端的处理程序:

我们必须从资产清单文件中获取脚本的URL,因为react-scripts输出的编译JavaScript文件的文件名中带有哈希值以方便缓存。

类 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生命周期方法中调用该函数:

类 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>搜索和过滤餐厅元素,并呈现阵营路由器<Link>元素,导航到特定餐厅。到那时,我们将切换到第二个“订单”微前端,该前端将显示一个带有菜单的餐厅。

图10:这些微前端仅通过路由更改进行交互,而不是直接进行交互

关于我们的微前端,最后值得一提的是它们都styled-components用于所有样式。通过CSS-in-JS库,可以轻松地将样式与特定组件相关联,因此我们保证微前端的样式不会泄漏并影响容器或其他微前端。

通过路由进行跨应用程序通信

前面我们提到过,应将跨应用程序通信保持在最低限度。在此示例中,我们唯一的要求是浏览页面需要告诉餐厅页面要加载哪个餐厅。在这里,我们将看到如何使用客户端路由来解决此问题。

这里涉及的所有三个React应用程序都使用React Router进行声明式路由,但是以两种略有不同的方式进行初始化。对于容器应用程序,我们创建一个<BrowserRouter>,它会在内部实例化一个history对象。这是history我们之前讨论过的相同对象。我们使用该对象来处理客户端历史记录,也可以使用它来将多个React Router链接在一起。在我们的微前端中,我们按以下方式初始化路由器:

<Router history={this.props.history}>

在这种情况下,我们没有为React Router实例化另一个历史对象,而是为它提供了容器应用程序传入的实例。<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;
};

然后,我们script向每个index.html文件添加几个标签,以从共享内容服务器中获取两个库。

<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>

在团队之间共享代码始终是一件棘手的事情。我们需要确保我们只共享我们真正希望成为共同的东西,并且希望一次在多个地方进行更改。但是,如果我们对共享的内容和不共享的内容保持谨慎,则将获得真正的好处。

基础设施

该应用程序托管在具有核心基础架构(S3存储桶,CloudFront发行版等)的AWS上,并使用Terraform代码的集中式存储库一次进行配置。然后,每个微前端都有自己的源存储库,并在Travis CI上具有自己的连续部署管道,该管道将静态资产构建,测试并部署到这些S3存储桶中。这在集中式基础架构管理的便利性与独立部署性的灵活性之间取得了平衡。

请注意,每个微前端(和容器)都有自己的存储桶。这意味着它可以自由支配其中的内容,而我们不必担心来自另一个团队或应用程序的对象名称冲突或访问管理规则冲突。


缺点

在本文的开头,我们提到了与任何前端一样的微前端折衷。我们提到的好处确实伴随着成本,我们将在这里介绍。

有效负载大小

独立构建的JavaScript捆绑包可能导致重复的公共依赖关系,从而增加了我们必须通过网络发送给最终用户的字节数。例如,如果每个微前端都包含自己的React副本,那么我们将迫使客户下载n次React 。页面性能和用户参与/转换之间存在直接关系,世界上许多地方的互联网基础设施运行速度远比高度发达城市的互联网基础设施慢,因此我们有很多理由在乎下载大小。

这个问题不容易解决。在我们希望团队独立地编译应用程序以使其能够自主工作的渴望与我们在构建我们的应用程序以共享共同依赖关系的愿望之间存在着内在的张力。一种方法是从我们的编译包中外部化常见的依赖关系,如我们所述用于演示应用程序。但是,一旦走上这条路,我们就重新引入了一些构建时耦合到我们的微前端的方法。现在,它们之间存在一个隐式契约,其中规定:“我们所有人都必须使用这些依赖项的这些确切版本”。如果依赖项发生重大变化,我们可能最终需要进行大量的协调升级工作并一次性完成锁步释放事件。这就是我们最初尝试使用微前端时要避免的一切!

这种内在的紧张是一个困难的局面,但这并不是所有的坏消息。首先,即使我们选择不对重复的依赖项做任何事情,也有可能每个单独页面的加载速度都比构建单个整体式前端要快。原因是通过独立地编译每个页面,我们有效地实现了我们自己的代码分割形式。在经典的Monolith中,当加载应用程序中的任何页面时,我们通常一次下载所有页面的源代码和依赖项。通过独立构建,任何单个页面加载都只会下载该页面的源和依赖项。这可能会导致初始页面加载速度更快,但随后的导航速度会变慢,因为用户被迫在每个页面上重新下载相同的依赖项。如果我们的纪律是不要在不必要的依赖项上膨胀我们的微前端,或者如果我们知道用户通常只停留在应用程序中的一两个页面,那么我们很可能会实现净收入。即使有重复的依赖关系,也可以提高性能。

上一段中有很多“可能的”和“可能的”,这凸显了一个事实,即每个应用程序将始终具有自己独特的性能特征。如果您想确定特定更改对性能的影响,那么最好在生产中进行实际测量是无可替代的。我们已经看到团队苦苦挣扎了超过数千KB的JavaScript,只是去下载许多MB的高分辨率图像,或者对一个非常慢的数据库运行昂贵的查询。因此,尽管考虑每个架构决策对性能的影响很重要,但请确保您知道真正的瓶颈在哪里。

环境差异

我们应该能够开发单个微前端,而无需考虑其他团队正在开发的所有其他微前端。我们甚至可以在空白页上以“独立”模式运行微前端,而不是在将其存储在生产环境中的容器应用程序内部运行。这可以使开发更加简单,尤其是当实际容器是复杂的旧代码库时,当我们使用微前端进行从旧世界到新世界的逐步迁移时,通常就是这种情况。但是,在与生产环境完全不同的环境中进行开发存在风险。如果我们在开发时的容器的行为与生产时的容器不同,那么我们可能会发现我们的微前端已损坏,或者在部署到生产中时的行为有所不同。特别令人关注的是容器或其他微前端可能带来的全局样式。

这里的解决方案与我们不得不担心环境差异的任何其他情况没有什么不同。如果我们在这不是一个环境中本地发展生产样,我们需要确保我们经常集成和我们的微前端部署到环境在这些环境中,如生产,我们应该做的测试(手动和自动),以尽早发现集成问题。这不能完全解决问题,但是最终这是我们必须权衡的另一个权衡:简化开发环境的生产率提高是否值得承担集成问题的风险?答案将取决于项目!

运营和治理复杂性

最后的缺点是与微服务直接相似的缺点。作为一个分布更广泛的体系结构,微前端将不可避免地导致要管理更多的东西-更多的存储库,更多的工具,更多的构建/部署管道,更多的服务器,更多的域等。因此在采用这种体系结构之前,您需要提出一些问题应该考虑:

  • 您是否有足够的自动化措施来可行地配置和管理所需的其他基础架构?
  • 您的前端开发,测试和发布过程是否可以扩展到许多应用程序?
  • 您是否对围绕工具和开发实践的决策变得更加分散和难以控制感到满意?
  • 您将如何确保跨多个独立的前端代码库的最低质量,一致性或治理水平?

我们可能还会再写整篇讨论这些主题的文章。我们要提出的主要观点是,当您选择微前端时,根据定义,您选择创建的是许多小东西,而不是一个大东西。您应该考虑是否具备在不造成混乱的情况下采用这种方法所需的技术和组织成熟度。


结论

多年来,随着前端代码库的不断复杂化,我们看到了对更具可扩展性的体系结构的日益增长的需求。我们需要能够划清界限,以建立技术实体和领域实体之间正确的耦合和凝聚力级别。我们应该能够在独立的自治团队之间扩展软件交付。

尽管远非唯一的方法,但我们已经看到了许多微前端提供这些好处的实际案例,并且随着时间的推移,我们已经能够逐渐将这种技术应用于旧代码库和新代码库。无论微前端对您和您的组织是否是正确的方法,我们只能希望这将成为持续趋势的一部分,在这种趋势下,前端工程和体系结构将得到我们应有的重视。

 


致谢

非常感谢Charles Korn,Andy Marks和Willem Van Ketwich的详尽评论和详细反馈。

也要感谢Bill Codding,Michael Strasser和Shirish Padalkar在ThoughtWorks内部邮件列表中提供的意见。

还要感谢Martin Fowler的反馈,并在他的网站上为本文提供了家。

最后,感谢Evan Bottcher和Liauw Fendy的鼓励和支持。

 

(本文翻译自Cam Jackson的文章《Micro Frontends》,转载请注明出处,原文链接:https://martinfowler.com/articles/micro-frontends.html)

展开阅读全文
加载中
点击加入讨论🔥(5) 发布并加入讨论🔥
5 评论
30 收藏
0
分享
AI总结
返回顶部
顶部