微前端(译)

by Teobler on 25 / 11 / 2020

views

微前端

好地前端开发是很难的。扩展前端开发,使许多团队能够同时在一个庞大而复杂的产品上工作更是难上加难。在这篇文章中,我们将介绍最近的一个趋势,即把前端单体分解成许多更小的、更容易管理的部分,以及这种架构如何提高团队治理前端代码的效率并提升效益。除了谈论各种好处和弊端之外,我们还将介绍一些可用的实现方案,并将深入到一个完整的示例应用中去展示这一技术。

本文作者

Cam Jackson -- 就职于 ThoughtWorks 的全栈工程师和咨询顾问,对大型企业如何扩展其前端开发流程和实践特别感兴趣。他曾与多个行业和国家的客户合作,帮助他们更有效地交付 Web 应用程序。

前言

近年来,微服务的发展呈爆炸式增长,许多组织使用这种架构风格来避免大型单体后端的限制。虽然关于这种软件架构风格的代码在服务端已经有很多优秀案例,但许多公司仍然在单体前端代码库中挣扎。

也许你想构建一个渐进式或响应式的 Web 应用程序,但找不到一个合适的地方将这些功能集成到现有代码中。也许你想试试新的 JavaScript 特性(或那些可以编译成 JavaScript 的语言),但你没办法将必要的构建工具融入到你现有的构建流程中。或者你只是想扩大开发规模,让多个团队可以同时开发一个产品,但现有单体中的耦合性和复杂性意味着每个人都在互相踩踏。这些都是真实存在的问题,都会对你高效向客户提供高质量优体验程序的能力产生负面影响。

最近,我们看到越来越多的人开始关注复杂的现代 Web 应用开发所必需的整体架构和组织结构。特别是,我们看到了将前端单体分解成更小、更简单的模块的模式出现,这些模块可以独立开发、测试和部署,同时仍然作为一个单一的内聚的产品出现在客户面前。我们将这种技术称为微前端,我们将其定义为:

一种将能够独立交付的前端应用程序组合成一个更大整体的架构风格

在2016年11月的 ThoughtWorks 技术雷达中,我们将微前端列为组织应该评估的技术。后来我们将其推广到试用,最后推广到采用,这意味着我们将其视为一种经过验证的解决方案,在合适的时候你应该使用。

A screenshot of micro frontends on the       ThoughtWorks tech radar

我们从微前端中看到的一些关键的好处是:

  • 更小,更内聚且可维护性更高的代码库
  • 更可扩展的组织,拥有独立自主的团队
  • 能够以更多的方式升级、更新、甚至重写前端的某些部分

这些引人注目的优势并非巧合,这些优势也是微服务能够提供的一些优势。

当然,在软件架构方面,天下没有免费的午餐,一切都要付出代价。一些微前端的实现可能会导致重复的依赖,加载应用时增加用户必须下载的字节数。此外,团队自主性的大幅提高会导致你的团队工作方式的碎片化。尽管如此,我们相信这些风险是可以控制的,而且微前端的好处往往大于成本。

微前端带来的好处

我们不从具体的技术方法或实现细节来定义微前端,而是强调其属性及这些属性带来的好处。

渐进式升级

对于许多组织来说,这是他们为什么采用微前端的原因。他们那些旧的大型前端单体被老旧的技术栈或是在交付压力下编写的垃圾代码所拖累,已经到了需要完全重写的地步。为了避免全面重写带来的风险,我们更愿意把旧的应用一块一块地掐死,同时继续向客户提供新的功能,而不被单体拖累。

这往往引出了微前端架构。一旦一个团队有了对旧代码几乎不做修改就能将一个新功能全部投入生产环境的经验,其他团队也会想加入这个新世界。现有的代码仍然需要维护,在某些情况下,继续在其中添加新功能可能是有意义的,但现在有别的选择了。

这里的最终目的是,我们获得了更多的自由,可以对产品的个别部分进行逐一决策,并对我们的架构、依赖关系和用户体验进行渐进式升级。如果我们的主框架有重大的突破性变化,每一个微前端都可以在任何时候进行升级,而不是被迫宕机,一次性升级所有的东西。如果我们想尝试新的技术,或者新的交互模式,我们可以用比以前更解耦的方式去做。

简单且解耦的代码库

根据微前端的定义,每个单独的微前端的源代码将比单个单体前端的源代码小得多。这些较小的代码库往往更简单,也更容易为开发人员所用。特别是,我们避免了本不应该相互了解的组件之间无意的、不恰当的耦合所产生的复杂性。通过在应用程序的限界上下文周围画更粗的线,我们使这种意外的耦合更难产生。

当然,一个单一的、高层次的架构决策(即 "让我们来做微前端吧"),并不能代替老式的 "clean code"。我们并不是要免除自己对代码的思考和对其质量的努力。相反,我们是想通过更难做坏的决定,和更容易做简单的决定,让自己掉进成功的坑里。例如,跨边界上下文共享领域模型变得更加困难,所以开发人员不太可能这样做。同样,微前端促使你对数据和事件如何在应用的不同部分之间流动进行明确和慎重的考虑,这也是我们无论如何都应该做的事情!

独立部署

就像微服务一样,微前端能否独立部署是关键。独立部署减少了部署的限制,从而降低了相关风险。无论你的前端代码如何托管或在哪里托管,每个微前端都应该有自己的持续交付流水线,它能够独立构建、测试和部署到生产环境。我们应该能够独立部署每个微前端,而很少考虑其他代码库或流水线的当前状态。不管老的单体前端代码库是不是在一个固定的、手动的、季度性的发布周期,或者隔壁的团队已经把一个半成品或坏掉的功能推送到他们的主分支中,都不应该对你的微前端造成影响。如果一个给定的微前端已经准备好投入生产环境,它应该能够达到上述要求,这个是否部署的决定权也应该由构建和维护它的团队来做。

A diagram showing 3 applications independently going from source control, through build, test and deployment to production

团队自治

在我们将代码库和发布周期与大的单体前端脱钩后有一个更高层次的好处,我们拥有了一个完全独立的团队,这些团队可以拥有一个产品的一部分,从构思到生产,甚至更多。团队可以完全拥有为客户提供价值所需的一切,这使他们能够快速有效地前进。要做到这一点,我们的团队需要围绕业务功能的垂直切片来组建,而不是围绕技术能力。一个简单的方法是根据最终用户将看到的内容来划分产品,因此每个微前端封装了应用的一个页面,并由一个团队端到端拥有。与围绕技术或 "横向 "关注问题(如样式、表单或验证)组建团队相比,这带来了团队工作更高的内聚。

A diagram showing teams formed around 3 applications, and warning against forming a 'styling' team

小结

简而言之,微前端就是把大而可怕的东西切成更小、更容易管理的碎片,然后明确它们之间的依赖关系。我们的技术选择、我们的代码库、我们的团队和我们的发布流程都应该能够相互独立运行和发展,而不需要过多的协调。

示例

想象一下,在一个网站上,顾客可以定外卖。这是一个相当简单的概念,但如果你想做好它,有不少令人惊讶的细节:

  • 应该有一个登陆页面,顾客可以在这里浏览和搜索餐厅。餐馆应该可以被搜索,并且可以通过任何数量的属性进行过滤,包括价格、菜品或顾客以前点过的东西等
  • 每家餐厅都需要有自己的页面,显示其菜单,并允许顾客选择他们想吃的东西,并提供折扣、用餐优惠等特殊要求
  • 客户应该有一个个人资料页面,在这里他们可以看到他们的订单历史,跟踪送货情况,并调整他们的付款方式

A wireframe of a food delivery website

每一个页面都有足够的复杂性,我们可以为每一个页面建立一个专门的团队,而且这些团队中的每一个都应该能够独立于所有其他团队在他们自己的页面上工作。他们应该能够开发、测试、部署和维护他们的代码,而不用担心与其他团队的冲突或协调。然而,我们的客户仍然应该看到一个单一的、无缝的网站。

在本文的其余部分,我们将在任何需要示例代码或场景讲解的地方使用这个示例应用程序。

集成方式

鉴于上述相当宽泛的定义,有许多架构都可以合理地称为微前端。在本节中,我们将展示一些例子并讨论它们的取舍。所有的架构都有一个相当自然的模式 -- 一般来说,应用程序中的每个页面都有一个微前端,并且有一个单一的容器应用程序,它应该:

  • 渲染常见的页面元素,如页眉和页脚
  • 解决认证和导航等跨领域问题
  • 将不同的微前端汇集到页面上,并告诉每个微前端在何时何地进行渲染

A web page with boxes drawn around different sections. One box wraps the whole page, labelling it as the 'container application'. Another box wraps the main content (but not the global page title and navigation), labelling it as the 'browse micro frontend'

服务端模板组合

我们首先采用了一种绝对没有新意的前端开发方法 -- 在服务器端用多个模板或片段来渲染 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 服务器上完成的。我们假设它们每个页面都有自己的部署流水线,这使得我们可以在不影响或不考虑任何其他页面的情况下部署对一个页面的更改。

为了获得更多的独立性,可以有一个单独的服务器负责渲染和持有每个微前端,再在前面部署一个服务器,向所有其它服务器发出请求。可以通过对响应的缓存,做到不影响延迟。

A flow diagram showing a browser making a request to a 'container app server', which then makes requests to one of either a 'browse micro frontend server' or a 'order micro frontend server'

这个例子说明了微前端不一定是一种新技术,也不一定要复杂。只要我们谨慎思考设计决策将如何影响我们的代码库和团队的自主性,无论我们的技术栈如何,我们都可以实现由微服务带来的好处。

编译时集成

我们有时会看到的一种方法是将每个微前端作为一个包发布,并让容器应用程序将它们作为库依赖项全部包含在内。下面是前面提到的示例应用的 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,由同一个团队拥有。

A diagram showing three pairs of frontends / backends. The first backend talks only to its own database. The other two backends talk to shared downstream services. Both approaches are valid.

另一个常见的问题是,微前端应用的用户应该如何与服务器进行认证和授权?显然,我们的客户端应该只需要对自己进行一次认证,所以身份验证显然属于多个客户端交叉关注的范畴,应该由容器应用拥有。容器可能有某种登录形式,我们通过它获得 token。这个 token 将由容器拥有,并且可以在初始化时注入到每个微前端中。最后,微前端可以在向服务器发出任何请求时发送 token,服务器可以做任何需要的验证。

测试

在测试方面,我们看不到单体前端和微前端之间有什么区别。一般来说,无论你使用什么策略来测试单体前端,都可以在每个单独的微前端上重现。也就是说,每个微前端都应该有自己全面的自动化测试套件,以确保代码的质量和正确性。

然后,明显的区别来自于各个微前端与容器应用的集成测试。这样的集成测试可以使用你认为最佳的功能/端到端测试工具(如Selenium或Cypress)来完成,但注意集成测试不要过于细致,这样的测试应该只覆盖那些在测试金字塔底层无法覆盖的情况。也就是说,使用单元测试来覆盖你的底层业务逻辑和渲染逻辑,然后功能测试只是为了验证页面的组装是否正确。例如,您可能会在特定的 URL 处加载完全集成的应用程序,并断言相关某个微前端的某个标题存在于页面上。

如果有跨微前端的用户旅程(User Journey),那么你可以使用功能测试(Functional Test)来覆盖这些场景,但要将功能测试集中在验证几个前端的集成上,而不是每个微前端的内部业务逻辑,因为内部业务逻辑单元测试应该已经覆盖了。如上所述,消费者驱动的契约可以帮助我们直接指定微前端之间应该发生的交互,而无需担心集成测试中集成环境和相关功能测试带来的这些烦恼。

详细例子

到这里本文剩下的大部分内容将只是详细解释我们上面提到的示例应用程序的一种实现方式。我们将主要关注容器应用和微前端如何使用 JavaScript 集成在一起,因为这可能是最有趣和最复杂的部分。你可以在 https://demo.microfrontends.com 上看到网页的最终效果,完整的源代码可以在 Github 上看到。

A screenshot of the 'browse' landing page of the full micro frontends demo application

这个 demo 都是使用 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"
}

从 dependencies 中的 react 和 react-scripts 我们可以得出结论,这是一个用 create-react-app 创建的 React.js 应用。有趣的是,其中没有引入我们要一起组成最终应用的微前端依赖。如果我们在这里将它们指定为库依赖,我们就会走上构建时集成的道路,正如前面提到的,这往往会在我们的发布周期中造成问题耦合。

在 react-scripts 的第1版中,可以让多个应用程序在一个页面上共存而不发生冲突,但第2版使用了一些 webpack 特性,当两个或更多的应用程序试图在一个页面上渲染时,会导致错误。为此,我们使用 react-app-rewired 来覆盖 react-scripts 的一些内部 webpack 配置。从而解决了这些错误,并让我们继续依靠 react-scripts 来为我们管理构建工具。

为了了解我们如何切换和显示一个微前端,让我们看看 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。BrowseRestaurant 组件看起来像这样:

const Browse = ({ history }) => (
  <MicroFrontend history={history} name="Browse" host={browseHost} />
);
const Restaurant = ({ history }) => (
  <MicroFrontend history={history} name="Restaurant" host={restaurantHost} />
);

在这两个组件内,我们渲染了一个 MicroFrontend。除了 history 对象(稍后会变得重要),我们还指定了这两个微前端的唯一名称,以及可以下载其捆绑包的服务器地址。这个由配置驱动的 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`} />;
  }
}

这里的 class 还不是全部内容,稍后我们会一一介绍它的方法

在渲染的时候,我们要做的就是在页面上先放一个容器元素,这个容器元素的 ID 是各个微前端的唯一标识。而这就是我们要告诉每一个微前端自己将要渲染的地方。我们使用 React 的 componentDidMount 生命周期作为下载和挂载微前端的触发器:

  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 Class 组件的一个生命周期方法,在我们的组件实例第一次被 "挂载" 到DOM后,框架就会调用这个方法。

首先,我们检查是否已经下载了带有唯一 ID 标识的某个微前端包,在这种情况下,我们可以立即渲染它。如果没有,我们从相应的服务器中获取 asset-manifest.json 文件,从中查询某个包的完整 URL。一旦我们设置了 JS 脚本的 URL,剩下的就是将其附加到 HTML 文档中,并使用 onload 处理程序来渲染微前端。

  renderMicroFrontend = () => {
    const { name, history } = this.props;
 
    window[`render${name}`](`${name}-container`, history);
    // E.g.: window.renderBrowse('browse-container', history);
  };

在上面的代码中,我们调用了一个类似于 window.renderBrowse 这样的全局函数,它是由我们刚刚下载的 JS 包注入的,我们向它传递了微前端应该渲染的 <main> 元素的 ID,以及 history 对象,为什么需要 history 对象我们很快就会解释。这个全局函数的签名是容器应用程序和微前端之间的关键契约。**所有的通信或集成都应该发生在这里,所以保持这个函数的轻量能够使其易于维护,并能够在未来添加新的微前端。**每当我们要做一些需要改变这段代码的事情时,我们都应该仔细思考加入新的代码是否会增加代码库的耦合性和维护的难度。

最后还有一个部分,就是清理。当我们的 MicroFrontend 组件卸载(从DOM中移除)时,我们要把相关的 Microfrontend 也卸载掉。为此每个微前端都定义了一个相应的全局函数,我们从相应的 React 生命周期方法中调用这个函数。

  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 的调用会在顶层作用域,这意味着一旦这个 JS 文件被加载,它就会立即渲染成一个硬编码的 DOM 元素。对于单个微前端来说,要能够控制什么时候和在哪里渲染,所以我们把它包装在一个以 DOM 元素和 ID 作为参数的函数中,并把这个函数注入到全局 window 对象上。我们还可以看到相应的用于清理的卸载函数。

我们已经看到了当微前端被集成到整个容器应用中时,这个函数是如何被调用的,但这个函数最成功的地方之一是我们可以独立开发和运行微前端。所以每个微前端也都有自己的 index.html,里面有一个内联的 JS 标签,可以在容器之外以 "独立" 的模式渲染。

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

A screenshot of the 'order' page running as a standalone application outside of the container

从这一点来看,微前端大多只是普通的 React 应用。"Browse" 微前端的作用是从后端获取餐厅列表,提供 元素用于搜索和筛选餐厅,并渲染 React Router 元素,导航到特定的餐厅。这时我们会切换到第二个叫做 "Order" 的微前端,它渲染一个餐厅及其菜单:

An architecture diagram that shows the sequence of steps for navigation, as described above

关于我们的微前端,最后需要说明的是,它们都使用 styled-components 来进行所有的样式设计。这个 CSS-in-JS 库可以很容易地将样式与特定的组件关联起来,所以我们可以保证一个微前端的样式不会泄露出去,不会影响到容器,或者另一个微前端。

通过 routing 跨应用通信

前面我们提到过,跨应用的通信应该尽量减少。在这个例子中,我们唯一的需求就是浏览页面需要告诉餐厅页面要加载哪个餐厅。这里我们将看到如何使用客户端路由来解决这个问题。

这里涉及到的三个 React 应用都是使用 React Router 进行声明式路由定义,但初始化的方式略有不同。对于容器应用,我们创建一个 ,它内部会实例化一个 history 对象。这就是我们之前一直在提及的那个 history 对象。我们用这个对象来操作客户端路由的历史记录,我们也可以用它将多个 React Routers 链接在一起。在我们的微前端里面,我们这样初始化Router:

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

在这种情况下,我们不是让 React Router 实例化一个新的 history 对象,而是向每一个微前端提供容器应用程序创建的实例。所有的 实例(其实是同一个)现在都被连接起来了,所以在其中任何一个实例中触发的路由变化都会反映在所有的实例中。这为我们提供了一种简单的通信方法,通过 URL 将 "参数" 从一个微前端传递到另一个微前端。例如,在浏览微前端中,我们有一个这样的链接:

<Link to={`/restaurant/${restaurant.id}`}>

当这个链接被点击后,容器中的路由会被更新,容器会拿到新的 URL,并确定应该挂载和渲染 "restaurant" 微前端。新的微前端自己的路由逻辑就会从 URL 中提取餐厅 ID,并呈现正确的信息。

希望这个例子流程能展现人们常识中无用的 URL 别样的灵活性和力量。URL 除了可以分享和当做书签保存之外,在这个特定的架构中,它还可以成为跨微前端传达信息的方式。使用页面 URL 来达到通信这个目的,可以说是一举多得:

  • 它的结构是一个明确的、开放的标准
  • 页面上的任何代码都可以在顶级作用域内访问它
  • 其有限的尺寸鼓励只发送少量的数据
  • 它是面向用户的,这样的方式鼓励采用忠于领域模型的结构
  • 这是声明式的,不是命令式的。即 "我们在这里",而不是 "请做这件事"
  • 它迫使微前端间接沟通,而不是直接了解或依赖对方

当使用路由作为我们在微前端之间的通信模式时,我们选择的路由构成了一种契约。在本例中,我们已经设定了一个想法,即一个餐厅可以在 /restaurant/:taurantId 处被查看,而且我们不能在不更新所有引用它的应用程序的情况下更改该路由。考虑到这个契约的重要性,我们应该有自动化测试来检查契约是否被遵守。

通用内容

虽然我们希望我们的团队和我们的微前端尽可能的独立,但有些东西应该是通用的。我们在前面写过关于共享组件库如何帮助实现微前端的一致性,但对于这个小例子来说,一个组件库是多余的。因此,我们有一个小型的通用内容库,包括图片、JSON 数据和 CSS,通过网络提供给所有的微前端。

还有一个东西,我们可以选择在微前端之间共享:库依赖。正如我们稍后将描述的那样,依赖性的重复是微前端的一个常见缺点。尽管跨应用程序共享这些依赖关系有其自身的困难,但对于这个演示应用程序来说,值得谈论如何做到这一点。

第一步是选择共享哪些依赖关系。对我们的编译代码进行快速分析后发现,大约 50% 的依赖大小是由 react 和 react-dom 贡献的。虽然它们体积过于庞大,但这两个库是我们最 "核心 " 的依赖,所以我们知道将其从所有的微前端中抽取出来将大大减小整个应用的体积。最后,这些都是稳定、成熟的库,通常只会在两个主要版本之间引入无法兼容的突破性的变化,所以跨应用的升级工作应该不会太困难。

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 buckets、CloudFront分布、域、证书等)都使用 Terraform 代码一次性提供。然后,每个微前端都有自己的源码仓库,在 Travis CI 上有自己的持续部署流水线,它构建、测试并将其静态资源部署到 S3 中。这样的做法平衡了集中式基础设施管理的便利性和独立部署的灵活性。

请注意,每个微前端(包括容器)都有自己的 S3。这意味着它可以自由支配那里的所有内容,我们不需要担心来自其他团队的影响或是别的应用程序的对象名称冲突。

弊端

在本文的开头,我们提到了微前端是有权衡的,任何架构都是如此。我们之前提到的好处确实是有代价的,我们将在下面一一介绍。

网络包体积

独立构建的 JavaScript 捆绑包会造成常见依赖关系的重复,增加我们通过网络向终端用户发送的字节数。例如,如果每个微前端都包含自己的 React 副本,那么我们就会强迫客户下载 React n次。页面性能和用户参与度/转化率之间有直接的关系,世界上大部分地区运行的互联网基础设施比那些高度发达城市的用户习惯的速度要慢得多,所以我们有很多理由关心下载量。

这个问题是不容易解决的。我们希望让团队独立编译他们的应用程序,这样他们就可以自主工作,而我们又希望一次性构建我们的应用程序,使他们可以共享共同的依赖关系,这两者之间存在着内在的矛盾。一种方法是将编译后的捆绑包中的共同依赖关系外部化,就像我们在演示程序中描述的那样。但是,一旦我们走上这条道路,我们就会重新引入一些构建时的耦合到我们的微前端中。这样的话它们之间就有一个隐含的契约,它说:"我们都必须使用这些依赖的确切版本"。如果某个依赖关系发生了无法兼容的突破性变化,我们可能最终需要一个大的协调升级工作和一个一次性的发布事件。这正是我们当初使用微前端所要避免的。

这种内在的矛盾是一个困难的问题,但也不全是坏消息。首先,即使我们选择不做任何关于重复依赖的事情,每个单独的页面仍然有可能比我们构建一个单一的前端更快地加载。原因是,通过独立编译每个页面,我们有效地实现了自己的代码分割。在经典的单体前端应用中,当应用中的任何一个页面被加载时,我们往往会一次性下载所有页面的源代码和依赖关系。通过独立构建,任何一个单页加载都只会下载该页的源代码和依赖项。这可能会使得初始页面加载更快,但随后的页面切换速度较慢,因为用户被迫在每个页面上重新下载相同的依赖项。如果我们能够严格遵守约定,不引入不必要的依赖关系增加微前端的体积,或者如果我们知道用户在应用程序中通常只停留在某一个或两个页面上,那么即使是重复的依赖关系,我们也可以获得性能上的净增长。

上一段中有很多 "可能",这突出了一个事实,即每个应用总是有其独特的性能特征。如果你想确定某项变化对性能的影响,最好的办法是在生产环境中进行实际测量,没有任何别的方式可以替代。我们已经看到有的团队为超过目标基准几千字节的 JavaScript 捆绑包而苦恼,却要去下载很多兆字节的高分辨率图像,或者对着一个非常慢的数据库执行昂贵的查询。因此,虽然考虑架构的决策对性能的影响很重要,但要确保你知道真正的瓶颈在哪里。

环境因素

我们应该能够开发一个单一的微前端,而不需要考虑其他团队正在开发的所有其他微前端。我们甚至可以以 "独立" 的模式,在空白页面上运行我们的微前端,而不是在生产环境中容纳它的容器应用中运行。这可以使开发变得更加简单,特别是当真正的容器是一个复杂的、遗留的代码库时,当我们使用微前端来做从旧代码到新代码的逐步迁移时,往往会出现这种情况。然而,在一个与生产环境截然不同的环境中进行开发是有风险的。如果我们开发时的容器与生产时的容器表现不同,那么我们可能会发现我们的微前端功能被破坏了,或者当我们部署到生产环境时行为表现不同。特别值得关注的是全局样式,这些样式可能是由容器或其他微前端带来的。

解决方案与其他任何我们需要担心环境差异的情况没有太大区别。如果我们在一个不像生产的环境中进行本地开发,我们需要确保定期将我们的微前端集成并部署到像生产一样的环境中,我们应该在这些环境中进行测试(手动和自动),以尽早发现集成问题。这并不能完全解决这个问题,但最终这又是一个我们必须权衡的问题:简化开发环境对生产力的提升是否值得冒集成问题的风险?答案将取决于项目的权衡!

操作和维护的复杂性

最后一个缺点是与微服务相似的缺点。作为一个更加分布式的架构,微前端将不可避免地导致有更多的东西需要管理 -- 更多的代码仓库、更多的工具、更多的构建/部署流水线、更多的服务器、更多的域等等。所以在采用这样的架构之前,有几个问题是你应该考虑的:

  • 您是否有足够的自动化方案来可行地提供和管理额外所需的基础设施?
  • 您的前端开发、测试和发布流程是否可以扩展到许多应用程序?
  • 您是否能接受工具和开发实践的决策变得更加分散和更难控制?
  • 您将如何确保您的许多独立前端代码库的质量、一致性或治理水平?

我们可能会用另一篇文章来讨论这些话题。我们想说的主要观点是,当你选择微前端时,根据定义,你应该创建许多小的应用程序,而不是一个大的应用程序。你应该考虑你是否有必要的技术和组织上的成熟度来采用这样的方法而不造成额外的混乱。

结论

随着这些年前端代码库不断变得更加复杂,我们看到可扩展架构的需求越来越大。在这之前,我们需要有能力划出清晰的边界,在技术和领域实体之间建立正确的耦合和内聚。我们应该能够在独立、自主的团队中扩展软件交付。

虽然这不是唯一的方法,但我们已经看到了许多微前端带来好处的实际案例,而且我们已经能够随着时间的推移将该技术逐步应用到传统的代码库以及新的代码库中。无论微前端是否是适合你和你的组织的解决方案,我们希望这将是一个持续的趋势,在这个趋势中,前端工程和架构也应该被认真对待,我们知道它是值得的。