关于微前端:微前端Micro-Frontend-落地实施的一些具体例子

4次阅读

共计 17408 个字符,预计需要花费 44 分钟才能阅读完成。

前文微前端概述 (Micro Frontends) 以及相比单体利用,微前端能带来什么益处 简略介绍了微前端的概念,本文来看一个具体的利用例子。

原文地址

设想一个网站,客户能够在其中订购外卖食品。从外表上看,这是一个相当简略的概念,但如果你想做得好,还有惊人的细节:

  • 应该有一个登陆页面,客户能够在其中浏览和搜寻餐馆。餐厅应该能够通过任意数量的属性进行搜寻和过滤,包含价格、美食或客户之前订购的货色
  • 每家餐厅都须要本人的页面来显示其菜单项,并容许客户抉择他们想吃的货色,包含折扣、餐饮优惠和特殊要求
  • 客户应该有一个个人资料页面,他们能够在其中查看他们的订单历史记录、跟踪交付和自定义他们的付款选项

每个页面都有足够的复杂性,咱们能够轻松地证实每个页面都有一个专门的团队,并且每个团队都应该可能独立于所有其余团队在他们的页面上工作。他们应该可能开发、测试、部署和保护他们的代码,而不用放心与其余团队发生冲突或协调。然而,咱们的客户依然应该看到一个无缝的网站。

在本文的其余部分,咱们将在须要示例代码或场景的任何中央应用此示例应用程序。

Integration approaches

鉴于下面相当涣散的定义,有许多办法能够正当地称为微前端。在本节中,咱们将展现一些示例并探讨它们的衡量。所有这些办法都有一个相当天然的架构——通常应用程序中的每个页面都有一个微前端,并且有一个容器应用程序,它:

  • 出现常见的页面元素,例如页眉和页脚
  • 解决跨畛域问题,如身份验证和导航
  • 将各种微前端放在页面上,并通知每个微前端何时何地渲染本人

Server-side template composition

咱们从一种相对不新鲜的前端开发办法开始——用多个模板或片段在服务器上渲染 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 服务器上,但假如它们每个都有本人的部署管道,这容许咱们将更改部署到一个页面,而不会影响或思考任何其余页面。

为了取得更大的独立性,能够有一个独自的服务器负责出现和服务每个微前端,其中一个服务器位于前端,向其余服务器发出请求。通过认真缓存响应,这能够在不影响提早的状况下实现。

这个例子展现了微前端不肯定是一种新技术,也不肯定很简单。只有咱们小心咱们的设计决策如何影响咱们的代码库和咱们团队的自治,无论咱们的技术堆栈如何,咱们都能够取得许多雷同的益处。

Build-time integration

咱们有时看到的一种办法是将每个微前端作为一个包公布,并让容器应用程序将它们全副蕴含为库依赖项。以下是容器的 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 包,容许咱们从各种应用程序中删除常见的依赖项。然而,这种办法意味着咱们必须从新编译和公布每个微前端,以便公布对产品任何单个局部的更改。就像微服务一样,咱们曾经看到这种锁步公布过程造成的苦楚曾经够多了,咱们强烈建议不要应用这种微前端办法。

正因为经验了将咱们的应用程序划分为能够独立开发和测试的离散代码库的过程中,遇到了这些麻烦,咱们决定不要在公布阶段从新引入所有这些耦合。咱们应该找到一种办法在运行时而不是在构建时集成咱们的微前端。

Run-time integration via 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>

正如服务器端蕴含选项一样,应用 iframe 构建页面并不是一项新技术,而且可能看起来并不那么令人兴奋。但如果咱们从新扫视后面列出的微前端的次要益处,iframe 大多符合要求,只有咱们小心咱们如何宰割应用程序和构建咱们的团队。

咱们常常看到很多人不违心抉择 iframe。尽管有些不愿意仿佛是由直觉认为 iframe 有点“蹩脚”所驱动的,但人们有一些很好的理由防止应用它们。下面提到的简略隔离的确会使它们不如其余选项灵便。在应用程序的不同局部之间构建集成可能很艰难,因而它们使路由、历史记录和深层链接更加简单,并且它们为使您的页面齐全响应提出了一些额定的挑战。

Run-time integration via 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 包,或者在渲染微前端时传入和传出数据。

这种办法的灵活性与独立的可部署性相结合,使其成为咱们的默认抉择,也是咱们在理论我的项目中最常看到的抉择。

Run-time integration via 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 style

CSS 作为一种语言实质上是全局的、继承的和级联的,传统上没有模块零碎、命名空间或封装。其中一些性能当初的确存在,但通常不足浏览器反对。在微前端环境中,这些问题中的许多问题都会加剧。例如,如果一个团队的微前端有一个样式表,下面写着 h2 color: black;,另一个说 h2 color: blue;,并且这两个选择器都附加到同一个页面,那么有人会感到悲观!这不是一个新问题,但因为这些选择器是由不同团队在不同工夫编写的,并且代码可能扩散在不同的存储库中,因而更难发现,这一事实使状况变得更糟。

多年来,人们创造了许多办法来使 CSS 更易于治理。有些抉择应用严格的命名约定,例如 BEM,以确保选择器仅实用于预期的中央。其余不喜爱独自依赖开发人员纪律的人应用预处理器,例如 SASS,其选择器嵌套可用作命名空间的一种模式。一种较新的办法是应用 CSS 模块或各种 CSS-in-JS 库之一以编程形式利用所有款式,以确保仅在开发人员想要的地位间接利用款式。或者对于更基于平台的办法,shadow DOM 还提供款式隔离。

您抉择的办法并不重要,只有您找到一种办法来确保开发人员能够独立地编写他们的款式,并确信他们的代码在组合成单个应用程序时的行为是可预测的。

Shared component libraries

咱们在下面提到过微前端的视觉一致性很重要,一种办法是开发一个共享的、可重用的 UI 组件库。总的来说,咱们认为这是一个好主见,尽管很难做好。创立这样一个库的次要益处是通过重用代码和视觉一致性来缩小工作量。此外,您的组件库能够作为一个活泼的款式​​指南,它能够成为开发人员和设计人员之间的一个很好的合作点。

最容易出错的事件之一就是过早地创立过多的这些组件。创立一个具备所有应用程序所需的所有公共视觉效果的根底框架是很迷人的。然而,教训通知咱们,在理论应用组件之前,很难甚至不可能猜想它们的 API 应该是什么,这会导致组件晚期生命周期中的大量散失。出于这个起因,咱们更违心让团队依据须要在他们的代码库中创立本人的组件,即便这最后会导致一些反复。让模式天然呈现,一旦组件的 API 变得显著,您就能够将反复的代码收集到共享库中,并确信您曾经证实了一些货色。

最显著的共享候选对象是“愚昧的”视觉原语,例如图标、标签和按钮。咱们还能够共享可能蕴含大量 UI 逻辑的更简单的组件,例如主动实现的下拉搜寻字段。或者一个可排序、可过滤、分页的表格。然而,请留神确保您的共享组件仅蕴含 UI 逻辑,而不蕴含业务或域逻辑。当域逻辑被放入共享库时,它会在应用程序之间产生高度耦合,并减少更改的难度。因而,例如,您通常不应该尝试共享 ProductTable,其中蕴含无关“产品”到底是什么以及应该如何体现的各种假如。这样的域建模和业务逻辑属于微前端的利用程序代码,而不是共享库。

与任何共享的外部库一样,其所有权和治理也存在一些辣手的问题。一种模型认为,作为共享资产,“每个人”都领有它,但实际上这通常意味着没有人领有它。它能够很快成为没有明确约定或技术愿景的不统一代码的大杂烩。在另一个极其,如果共享库的开发齐全集中,那么创立组件的人和应用它们的人之间就会呈现很大的脱节。咱们见过的最好的模型是任何人都能够为图书馆做出奉献的模型,但有一个保管人(一个人或一个团队)负责确保这些奉献的品质、一致性和有效性。保护共享库的工作须要弱小的技术技能,还须要造就跨多个团队的合作所需的人员技能。

Cross-application communication

对于微前端最常见的问题之一是如何让它们互相交互。一般而言,咱们倡议让他们尽可能少地交互,因为这通常会从新引入咱们最后试图防止的那种不适当的耦合。

也就是说,通常须要某种程度的跨应用程序通信。自定义事件容许微前端间接通信,这是最小化间接耦合的好办法,只管它的确使确定和执行微前端之间存在的契约变得更加艰难,想想 SAP Spartacus Popover Component 和 Directive 的事件通信?或者,向下传递回调和数据的 React 模型(在这种状况下从容器应用程序向下传递到微前端)也是一个很好的解决方案,它使合同更加明确。第三种抉择是应用地址栏作为通信机制,稍后咱们将更具体地探讨这种机制。

如果您应用的是 redux,通常的办法是为整个应用程序创立一个繁多的、全局的、共享的存储。然而,如果每个微前端都应该是本人独立的应用程序,那么每个微前端都有本人的 redux 存储是有意义的。redux 文档甚至提到“将 Redux 应用程序隔离为更大应用程序中的一个组件”作为领有多个商店的正当理由。

无论咱们抉择哪种办法,咱们都心愿咱们的微前端通过互相发送音讯或事件来进行通信,并防止任何共享状态。就像跨微服务共享数据库一样,一旦咱们共享了咱们的数据结构和域模型,就会产生大量耦合,并且很难进行更改。

与款式一样,有几种不同的办法能够在这里很好地工作。最重要的事件是认真思考你要引入什么样的耦合,以及你将如何随着工夫的推移放弃这种契约。就像微服务之间的集成一样,如果没有跨不同应用程序和团队的协调降级过程,您将无奈对集成进行重大更改。

您还应该思考如何主动验证集成不会中断。功能测试是一种办法,但因为实现和保护它们的老本,咱们更违心限度咱们编写的功能测试的数量。或者,您能够实现某种模式的消费者驱动的契约,以便每个微前端能够指定它对其余微前端的要求,而无需在浏览器中理论集成和运行它们。

Backend communication

如果咱们有独立的团队在前端应用程序上独立工作,那么后端开发呢?咱们深信全栈团队的价值,他们负责从可视化代码到 API 开发以及数据库和基础架构代码的利用程序开发。在这里有帮忙的一种模式是 BFF 模式,其中每个前端应用程序都有一个相应的后端,其目标只是为了满足该前端的需要。尽管 BFF 模式最后可能意味着每个前端渠道(网络、挪动等)的专用后端,但它能够很容易地扩大为每个微前端的后端。

这里有很多变量须要解释。BFF 可能是自蕴含其本人的业务逻辑和数据库,或者它可能只是上游服务的聚合器。如果有上游服务,对于领有微前端及其 BFF 的团队来说,领有其中一些服务可能有意义也可能没有意义。如果微前端只有一个与之通信的 API,并且该 API 相当稳固,那么构建 BFF 可能基本没有多大价值。这里的领导准则是,构建特定微前端的团队不应该期待其余团队为他们构建货色。因而,如果增加到微前端的每个新性能也须要后端更改,那么对于由同一团队领有的 BFF 来说,这是一个强有力的案例。

另一个常见的问题是,微前端应用程序的用户应该如何通过服务器进行身份验证和受权?显然,咱们的客户只须要对本人进行一次身份验证,因而身份验证通常属于容器应用程序应领有的横切关注点类别。容器可能有某种登录表单,通过它咱们能够取得某种令牌。该令牌将由容器领有,并且能够在初始化时注入到每个微前端。最初,微前端能够将令牌连同它向服务器收回的任何申请一起发送,服务器能够执行任何须要的验证。

Testing

在测试方面,咱们认为单体前端和微前端之间没有太大区别。一般来说,您用来测试单体前端的任何策略都能够在每个独自的微前端中重现。也就是说,每个微前端都应该有本人全面的自动化测试套件,以确保代码的品质和正确性。

显著的差距将是各种微前端与容器应用程序的集成测试。这能够应用您首选的性能 / 端到端测试工具(例如 Selenium 或 Cypress)来实现,但不要太过分;功能测试应该只涵盖无奈在测试金字塔的较低级别进行测试的方面。咱们的意思是,应用单元测试来笼罩您的低级业务逻辑和出现逻辑,而后应用功能测试来验证页面是否正确组装。例如,您能够在特定 URL 加载齐全集成的应用程序,并断言相干微前端的硬编码题目存在于页面上。

如果有逾越微前端的用户旅程,那么您能够应用功能测试来笼罩这些,但让功能测试专一于验证前端的集成,而不是每个微前端的外部业务逻辑,这应该曾经被单元测试笼罩。如上所述,消费者驱动的契约能够帮忙间接指定微前端之间产生的交互,而无需集成环境和功能测试的脆弱性。

Demo 网址:https://demo.microfrontends.com/

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 应用程序。更乏味的内容并没有在这里呈现在 package.json 的字面上:任何提及咱们将组合在一起以造成最终应用程序的微前端。如果咱们在这里将它们指定为库依赖项,咱们将走上构建时集成的路线。如前所述,这往往会在咱们的公布周期中导致耦合问题。

要理解咱们如何抉择和显示微前端,让咱们看看 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.microfron…。

在 App.js 中抉择了一个微前端,当初咱们将在 MicroFrontend.js 中渲染它,它只是另一个 React 组件:

class MicroFrontend extends React.Component {render() {return <main id={`${this.props.name}-container`} />;
  }
}

渲染时,咱们所做的就是在页面上搁置一个容器元素,其 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);
      });
  }

首先,咱们查看是否曾经下载了具备惟一 ID 的相干脚本,在这种状况下,咱们能够立刻渲染它。如果没有,咱们从适当的主机获取 asset-manifest.json 文件,以查找主脚本资产的残缺 URL。一旦咱们设置了脚本的 URL,剩下的就是将它附加到文档,应用一个出现微前端的 onload 处理程序:

renderMicroFrontend = () => {const { name, history} = this.props;

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

在下面的代码中,咱们调用了一个名为 window.renderBrowse 之类的全局函数,它由咱们刚刚下载的脚本搁置在那里。咱们将微前端应该出现的 main 元素的 ID 和一个历史对象传递给它,咱们将很快解释。这个全局函数的签名是容器应用程序和微前端之间的要害契约。这是任何通信或集成应该产生的中央,因而放弃相当轻量级使其易于保护,并在将来增加新的微前端。每当咱们想要做一些须要更改此代码的事件时,咱们应该认真思考它对咱们的代码库的耦合以及合约的保护意味着什么。

还有最初一件,就是解决清理。当咱们的 MicroFrontend 组件卸载(从 DOM 中删除)时,咱们也想卸载相干的微前端。为此,每个微前端定义了一个相应的全局函数,咱们从相应的 React 生命周期办法中调用它:

 componentWillUnmount() {const { name} = this.props;

    window[`unmount${name}`](`${name}-container`);
  }

就其本身的内容而言,容器间接出现的只是站点的顶级题目和导航栏,因为它们在所有页面中都是不变的。这些元素的 CSS 已被认真编写,以确保它只会为题目中的元素设置款式,因而它不应与微前端中的任何款式代码抵触。

这就是容器应用程序的完结!这是相当高级的,但这为咱们提供了一个 shell,能够在运行时动静下载咱们的微前端,并将它们粘合在一起,造成一个页面上的内聚力。这些微前端能够始终独立部署到生产环境,而无需对任何其余微前端或容器自身进行更改。

The micro frontends

持续这个故事的合乎逻辑的中央是咱们始终提到的全局渲染函数。咱们应用程序的主页是一个可过滤的餐馆列表,其入口点如下所示:

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>

从这一点开始,微前端大多只是一般的旧 React 应用程序。“浏览”应用程序从后端获取餐厅列表,提供用于搜寻和过滤餐厅的 input 元素,并出现导航到特定餐厅的 React Router Link 元素。那时咱们将切换到第二个“订单”微前端,它会出现一个带有菜单的餐厅。

从这一点开始,微前端大多只是一般的旧 React 应用程序。“浏览”应用程序从后端获取餐厅列表,提供用于搜寻和过滤餐厅的 <input> 元素,并出现导航到特定餐厅的 React Router <Link> 元素。那时咱们将切换到第二个“订单”微前端,它会出现一个带有菜单的餐厅。

Cross-application communication via routing

咱们之前提到过,跨应用程序通信应该放弃在最低限度。在这个例子中,咱们惟一的要求是浏览页面须要通知餐厅页面加载哪个餐厅。在这里,咱们将看到如何应用客户端路由来解决这个问题。

这里波及的所有三个 React 应用程序都应用 React Router 进行申明式路由,但以两种略有不同的形式初始化。对于容器应用程序,咱们创立了一个 BrowserRouter,它会在外部实例化一个历史对象。这与咱们之前始终在覆盖的历史对象雷同。咱们应用这个对象来操作客户端历史,咱们也能够应用它来将多个 React Router 链接在一起。在咱们的微前端中,咱们像这样初始化路由器:

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

在这种状况下,咱们不是让 React Router 实例化另一个历史对象,而是为它提供容器应用程序传入的实例。所有 Router 实例当初都已连贯,因而在其中任何一个实例中触发的路由更改都将反映在所有实例中。这为咱们提供了一种通过 URL 将“参数”从一个微前端传递到另一个微前端的简略办法。例如在浏览微前端,咱们有一个这样的链接:

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

单击此链接时,容器中的路由将更新,容器将看到新的 URL 并确定应装置和出现餐厅微前端。而后,该微前端本人的路由逻辑将从 URL 中提取餐厅 ID 并出现正确的信息。

心愿这个示例流程展现了不起眼的 URL 的灵活性和弱小性能。除了对共享和书签有用之外,在这个特定的架构中,它能够成为跨微前端交换用意的有用形式。为此目标应用页面 URL 有许多长处:

  • 它的构造是一个定义明确的凋谢规范
  • 页面上的任何代码都能够全局拜访它
  • 其无限的大小激励仅发送大量数据
  • 它是面向用户的,激励忠诚地建模畛域的构造
  • 它是申明性的,而不是强制性的。也就是说,“这就是咱们所在的中央”,而不是“请做这件事”
  • 它迫使微前端间接通信,而不是间接相互了解或依赖

当应用路由作为微前端之间的通信模式时,咱们抉择的路由形成了一个契约。在这种状况下,咱们确定了能够在 /restaurant/:restaurantId 处查看餐厅的想法,并且咱们无奈在不更新所有援用它的应用程序的状况下更改该路由。鉴于此合约的重要性,咱们应该进行自动化测试来查看合约是否失去恪守。

Common content

尽管咱们心愿咱们的团队和咱们的微前端尽可能独立,但有些事件应该是独特的。咱们之前写过共享组件库如何帮忙实现跨微前端的一致性,但对于这个小演示,组件库会有点过分。因而,咱们领有一个小型公共内容存储库,包含图像、JSON 数据和 CSS,这些内容通过网络提供给所有微前端。

咱们能够抉择在微前端之间共享另一件事:库依赖项。正如咱们将很快形容的那样,反复依赖是微前端的一个常见毛病。只管跨应用程序共享这些依赖项有其本身的一系列艰难,但对于这个演示应用程序,值得探讨如何做到这一点。

第一步是抉择要共享的依赖项。对咱们编译的代码的疾速分析表明,大概 50% 的包是由 react 和 react-dom 奉献的。除了它们的大小之外,这两个库是咱们最“外围”的依赖项,因而咱们晓得所有微前端都能够从提取它们中受害。最初,这些是稳固、成熟的库,通常会在两个次要版本之间引入重大更改,因而跨应用程序降级工作应该不会太艰难。

至于理论的提取,咱们须要做的就是在咱们的 webpack 配置中将库标记为内部库,咱们能够通过相似于后面形容的从新布线来实现。

module.exports = (config, env) => {
  config.externals = {
    react: 'React',
    'react-dom': 'ReactDOM'
  }
  return config;
};

而后咱们向每个 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>

跨团队共享代码总是一件很难做好的事件。咱们须要确保咱们只分享咱们真正心愿成为共同点的货色,并且咱们心愿一次在多个中央扭转。然而,如果咱们审慎看待咱们分享的内容和不分享的内容,就会取得真正的益处。

Infrastructure

该应用程序托管在 AWS 上,具备外围基础设施(S3 存储桶、CloudFront 调配、域、证书等),应用 Terraform 代码的集中存储库一次性配置。而后,每个微前端在 Travis CI 上都有本人的源存储库和本人的继续部署管道,它构建、测试并将其动态资产部署到这些 S3 存储桶中。这在集中式基础设施治理的便利性和独立部署的灵活性之间获得了均衡。

请留神,每个微前端(和容器)都有本人的存储桶。这意味着它能够自由支配那里的内容,咱们无需放心来自其余团队或应用程序的对象名称抵触或抵触的拜访治理规定。

Downsides

在本文结尾,咱们提到微前端须要衡量,就像任何架构一样。咱们提到的益处的确须要付出代价,咱们将在此处介绍。

Payload size

独立构建的 JavaScript 包会导致常见依赖项的反复,从而减少咱们必须通过网络发送给最终用户的字节数。例如,如果每个微前端都蕴含本人的 React 正本,那么咱们将迫使咱们的客户下载 React n 次。页面性能和用户参与度 / 转化率之间存在间接关系,世界上大部分地区运行在互联网基础设施上的速度比高度发达城市所习惯的要慢得多,因而咱们有很多理由关怀下载大小。

这个问题并不容易解决。咱们心愿让团队独立编译他们的应用程序以便他们能够自主工作,而咱们心愿以一种他们能够共享公共依赖项的形式构建咱们的应用程序,这两者之间存在外在的缓和关系。一种办法是从咱们编译的包中内部化公共依赖项,正如咱们在演示应用程序中形容的那样。不过,一旦咱们沿着这条路走上来,咱们就从新引入了一些构建时耦合到咱们的微前端。当初它们之间有一个隐含的契约,下面写着“咱们都必须应用这些依赖项的这些确切版本”。如果依赖项产生重大变动,咱们最终可能须要大量协调降级工作和一次性锁步公布事件。这就是咱们首先试图防止应用微前端的所有!

这种固有的缓和是一个艰难的问题,但也不全是坏消息。首先,即便咱们抉择对反复的依赖不做任何解决,每个页面的加载速度依然可能比咱们构建一个繁多的前端更快。起因是通过独立编译每个页面,咱们无效地实现了咱们本人的代码拆分模式。在经典的单体利用中,当加载应用程序中的任何页面时,咱们通常会一次性下载每个页面的源代码和依赖项。通过独立构建,任何单个页面加载都只会下载该页面的源代码和依赖项。这可能会导致初始页面加载速度更快,但后续导航会更慢,因为用户被迫在每个页面上从新下载雷同的依赖项。如果咱们有纪律地不让咱们的微前端因不必要的依赖而收缩,或者如果咱们晓得用户通常只关注应用程序中的一两个页面,咱们很可能会在性能方面取得净收益,即便存在反复的依赖。

上一段中有很多“可能”和“可能”,这突出了一个事实,即每个应用程序始终都有本人独特的性能特色。如果您想确定特定更改会对性能产生什么影响,则无奈代替进行理论测量,最好是在生产中。咱们曾经看到团队为多出几 KB 的 JavaScript 而苦恼,后果却是去下载数兆字节的高分辨率图像,或者对十分慢的数据库运行低廉的查问。因而,只管思考每个架构决策对性能的影响很重要,但请确保您晓得真正的瓶颈在哪里。

Environment differences

咱们应该可能开发单个微前端,而无需思考其余团队正在开发的所有其余微前端。咱们甚至能够在空白页面上以“独立”模式运行咱们的微前端,而不是在将其搁置在生产环境中的容器应用程序中运行。这能够使开发变得更加简略,特地是当真正的容器是一个简单的遗留代码库时,当咱们应用微前端逐渐从旧世界迁徙到新世界时,通常就是这种状况。然而,在与生产环境齐全不同的环境中进行开发存在相干危险。如果咱们的开发时容器的行为与生产容器的行为不同,那么咱们可能会发现咱们的微前端已损坏,或者在部署到生产时的行为有所不同。特地值得关注的是可能由容器或其余微前端带来的全局款式。

这里的解决方案与咱们必须放心环境差别的任何其余状况没有什么不同。如果咱们在一个非生产环境中进行本地开发,咱们须要确保咱们定期将咱们的微前端集成和部署到相似生产的环境中,并且咱们应该在这些环境中进行测试(手动和自动化)以尽早发现集成问题。这不会齐全解决问题,但最终这是咱们必须衡量的另一个衡量:简化开发环境的生产力晋升是否值得冒集成问题的危险?答案将取决于我的项目!

Operational and governance complexity

最初一个毛病是与微服务间接并行。作为一个更加分布式的架构,微前端将不可避免地导致须要治理更多的货色——更多的存储库、更多的工具、更多的构建 / 部署管道、更多的服务器、更多的域等等。所以在采纳这样的架构之前,你有几个问题应该思考:

  • 您是否有足够的自动化来切实可行地配置和治理额定的所需基础设施?
  • 您的前端开发、测试和公布流程是否能够扩大到许多应用程序?
  • 您是否对围绕工具和开发实际变得更加扩散和不那么可控的决策感到称心?
  • 您将如何确保泛滥独立前端代码库的品质、一致性或治理达到最低水平?

咱们可能能够编写另一篇残缺的文章来探讨这些主题。咱们心愿提出的次要观点是,当您抉择微前端时,依据定义,您抉择创立许多小东西而不是一件大货色。您应该思考您是否具备在不造成凌乱的状况下采纳这种办法所需的技术和组织成熟度。

Conclusion

多年来,随着前端代码库变得越来越简单,咱们看到对更具可扩展性的架构的需要一直增长。咱们须要可能划清界限,在技术实体和畛域实体之间建设正确的耦合和内聚级别。咱们应该可能跨独立、自治的团队扩大软件交付。

尽管远不是惟一的办法,但咱们曾经看到了许多真实世界的案例,其中微前端提供了这些益处,并且随着工夫的推移,咱们曾经可能将该技术逐步利用于遗留代码库和新代码库。无论微前端是否适宜您和您的组织,咱们只能心愿这将成为继续趋势的一部分,前端工程和架构失去咱们认为应得的认真对待。

更多 Jerry 的原创文章,尽在:” 汪子熙 ”:

正文完
 0