头图

微前端(Micro Frontend ) 落地实施的一些具体例子

JerryWang_汪子熙

前文微前端概述(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的原创文章,尽在:"汪子熙":

阅读 400

Jerry Wang的SAP技术专栏
SAP成都研究院开发专家,SAP社区导师,SAP中国技术大使

Jerry 2007年从电子科技大学计算机专业硕士毕业后进入SAP成都研究院工作至今, SAP社区导师,SAP中国技术...

719 声望
997 粉丝
0 条评论
你知道吗?

Jerry 2007年从电子科技大学计算机专业硕士毕业后进入SAP成都研究院工作至今, SAP社区导师,SAP中国技术...

719 声望
997 粉丝
宣传栏