[译] 微前端:将来前端开发的新趋势 — 第二部分

微前端:将来前端开发的新趋势 — 第二部分

作好前端开发不是件容易的事情,而比这更难的是扩展前端开发规模以便于多个团队能够同时开发一个大型且复杂的产品。本系列文章将描述一种趋势,能够将大型的前端项目分解成许多个小而易于管理的部分,也将讨论这种体系结构如何提升前端代码团队工做的有效性和效率。除了讨论各类好处和代价以外,咱们还将介绍一些可用的实现方案和深刻探讨一个应用该技术的完整示例应用程序。javascript

建议按照顺序阅读本系列文章:css

示例

想象一个网页,消费者能够在上面点外卖。表面上看起来这是一个很简单的概念,可是若是想把它作好,有很是多的细节须要考虑。html

  • 应该有一个引导页,消费者能够在这里浏览和搜索餐厅。这些餐厅能够经过任意数量的属性搜索或者过滤,包括价格、菜系或先前订单。
  • 每个餐厅都须要它本身的页面来显示菜单,并容许消费者选择他们想点什么,并有折扣、套餐、特殊要求这些选项。
  • 消费者应该有一个用户页面来查看订单历史、追踪外卖并自定义支付选项。

一个餐饮外卖网站的线框图

图 4:一个餐饮外卖网页可能会有几个至关复杂的页面。前端

每一个页面都足够复杂到须要一个团队来完成。每一个团队都理应可以独立地开发他们负责的页面。他们须要可以开发、测试、部署、维护他们的代码,并没有需担忧与其余团队的冲突与协调。咱们的消费者,看到的仍然应该是一个完整、无缝的网页。java

在文章接下来的部分里,当咱们须要示例代码或者情景时,咱们将会使用这个应用做为例子。android


集成方式

根据前文相对宽松的定义,多种方法都能被叫作微前端。在这一节中咱们会看一些例子并讨论它们的优劣。这些方法中共有一个相对天然的架构 —— 整体上讲,应用中的每个页面都有一个微前端,而后还有惟一一个容器应用,用于:ios

  • 渲染公用页面元素,如页眉页脚
  • 解决跨页面的一些需求,如受权和导航
  • 将多个微前端集成到页面上,并告知每一个微前端什么时候在哪渲染本身

一个用方框画出不一样部分的网页。一个方框包含了整个页面,标记为“容器应用”,另外一个方框包括了主要内容(全局页面标题和导航除外),标记为“浏览微前端”

图 5:你一般能够从页面结构推出你的架构nginx

服务端模板编写

咱们从一个很常见的前端开发方法开始 —— 在服务器端基于一些模板和代码片断渲染 HTML 页面。咱们有一个 index.html 文件,包含全部公用的页面元素,而后咱们用服务器端的 includes 来加入从 HTML 文件片断提取的页面内容:git

<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 匹配。github

server {
    listen 8080;
    server_name localhost;

    root /usr/share/nginx/html;
    index index.html;
    ssi on;

    # 重定向 / 至 /browse
    rewrite ^/$ http://localhost:8080/browse redirect;

    # 根据URL肯定要插入哪一个 HTML 片断
    location /browse {
      set $PAGE 'browse';
    }
    location /order {
      set $PAGE 'order';
    }
    location /profile {
      set $PAGE 'profile'
    }

    # 全部位置都应经 index.html 渲染
    error_page 404 /index.html;
}
复制代码

这是一个相对标准的服务端组合。咱们可以将其称为微前端的缘由是,咱们将代码分离,这样每一部分代码都是一个自我包含的领域概念,并可以被一个独立的团队开发。咱们没有看到的是,这些不一样的 HTML 文件最后如何到了服务器端,可是咱们假设每个页面都有它们本身的部署流程,容许咱们对一个页面部署修改,同时不影响或者无需考虑其余页面。

对于更大的独立性,每个微前端均可以由独立的服务器来负责渲染,并由一个服务器负责向剩下的发送请求。使用精心设计的缓存来存储响应,这种实施方案不会影响延迟。

一个流程图,展现浏览器向“容器应用服务器”发送请求,该服务器随后向“浏览微前端服务器”或“订单微前端服务器”发送请求

图 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 包,和往常同样,容许咱们从咱们多样的应用中解耦公用依赖。然而,这个方法意味着,为了在产品任意一个部分发布修改,咱们必须从新编译和发布每个微前端。如同微服务同样,咱们已经体会过了这种因循守旧的发布流程带来的痛苦,以致于咱们强烈反对在微前端使用一样的方法。

踩过了将应用分为离散的、可独立开发测试的代码库带来的全部的坑,咱们就再也不介绍发布阶段的耦合问题了。咱们须要找到一个在运行时集成微前端的方法,而非构造时方法。

经过 iframes 运行时集成

将应用组合到浏览器的一个最简便的方法即是使用 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>
复制代码

服务端的引入选项同样,用 iframes 构建页面不是一个新的技术,并且可能不是很使人兴奋。但若是咱们重温以前提过的微前端的好处,iframes 几乎都有,只要咱们仔细考虑如何将应用分红独立部分、如何构建团队。

咱们常常看到不少人不肯意选择 iframes。虽然部分缘由彷佛是直觉感受 iframe 有点“糟糕”,但人们也有很好的理由不使用它们。上面提到的简单隔离确实会使它们比其余选项更不灵活。在应用程序的不一样部分之间构建集成可能很困难,所以它们使路由,历史记录和深层连接变得更加复杂,而且它们对使页面彻底响应性提出了一些额外的挑战。

经过 JavaScript 运行时集成

咱们将要讨论的下一个方法多是最灵活的、团队采用最频繁的一个。每个微前端都用 <script> 标签放入页面,在加载时会暴露一个全局函数做为入口。容器应用接下来决定挂载哪一个微前端,并调用相关函数告诉一个微前端什么时候在哪渲染。

<html>
  <head>
    <title>Feed me!</title>
  </head>
  <body>
    <h1>Welcome to Feed me!</h1>

    <!-- 这些脚本不会当即渲染任何元素 -->
    <!-- 相反它们将每个入口函数挂载在 `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">
      // 这些全局函数会经过上面的脚本挂在 window 对象上
      const microFrontendsByRoute = {
        '/': window.renderBrowseRestaurants,
        '/order-food': window.renderOrderFood,
        '/user-profile': window.renderUserProfile,
      };
      const renderFunction = microFrontendsByRoute[window.location.pathname];

      // 决定好入口函数以后,咱们如今调用它,给它提供元素的 ID 来告诉它在哪里渲染
      renderFunction('micro-frontend-root');
    </script>
  </body>
</html>
复制代码

以上显然是一个比较初始的例子,但它演示了基本技术。与构建时集成不一样,咱们能够独立部署每一个 bundle.js 文件。与 iframe 不一样,咱们有充分的灵活性来以咱们偏好的方式构建微前端之间的集成。咱们能够经过多种方式扩展上述代码,例如,只根据须要下载每一个 JavaScript 包,或者在呈现微前端时传入和传出数据。

这一方法的灵活性,与独立部署性结合,使它成为了咱们的默认选择,而且是最为常见的一种选择。当咱们到了完整示例时咱们将会探索只这面的更多细节。

经过网页组件运行时集成

前面这种方法的一个变种就是,对于每个微前端,定义一个 HTML 自定义元素让容器来构建,而非定义一个全局函数来让容器调用。

<html>
  <head>
    <title>Feed me!</title>
  </head>
  <body>
    <h1>Welcome to Feed me!</h1>

    <!-- 这些脚本不会当即渲染任何元素 -->
    <!-- 相反它们每个都定义一个自定义元素类型 -->
    <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">
      // 这些元素类型是由上述脚本定义的
      const webComponentsByRoute = {
        '/': 'micro-frontend-browse-restaurants',
        '/order-food': 'micro-frontend-order-food',
        '/user-profile': 'micro-frontend-user-profile',
      };
      const webComponentType = webComponentsByRoute[window.location.pathname];

      // 决定了正确的网页组件,咱们如今建立了一个实体并把它挂在 document 上
      const root = document.getElementById('micro-frontend-root');
      const webComponent = document.createElement(webComponentType);
      root.appendChild(webComponent);
    </script>
  </body>
</html>
复制代码

这里的最终结果与前面的示例很是类似,主要区别在于选择以 “网页组件方式” 进行操做。若是您喜欢网页组件规范,而且您喜欢使用浏览器提供的功能,那么这是一个不错的选择。若是你更喜欢在容器应用程序和微前端之间定义本身的接口,那么你可能更喜欢前面的示例。


样式

CSS 做为一种语言本质上是全局的,继承和级联的,传统上没有模块系统,命名空间或封装。其中一些功能确实存在,但一般缺少浏览器支持。在微观前沿领域,许多这些问题都在恶化。例如,若是一个团队的微前端有一个样式表,上面写着 h2 { color: black; },另外一我的说 h2 { color: blue; },而且这两个选择器都附加到同一页面,而后有人就会不高兴了!这不是一个新问题,但因为这些选择器是由不一样团队在不一样时间编写的,并且代码可能分散在不一样的代码库中,所以更难以发现。

近几年来,人们想出了不少解决方案来使 CSS 更易于管理。有些人选择使用严格的命名规范,好比 BEM,来确保选择器只会在想要的地方起做用。另一部分人则选择不只仅依赖于开发者规则,使用一个预处理器,如 SASS,其选择器嵌套能够用作一种命名空间。一种更新的解决方案是将全部样式以程序的方式,用 CSS modules 或者众多 CSS-in-JS 库的一个来应用,以保证样式只应用在开发者想要的地方。或者,shadow DOM 也以一种更加基于平台的方式提供样式分离。

你选择的方法并不重要,只要你找到一种方法来确保开发人员能够彼此独立地编写样式,并确信他们的代码在组合到单个应用程序中时能够预测。


共享组件库

咱们在上面提到过,微前端的视觉一致性很重要,其中一种方法是开发一个共享的,可重用的 UI 组件库。总的来讲,咱们认为这是一个好主意,虽然很难作好。建立这样一个库的主要好处是经过重用代码减小工做量,并提供视觉一致性。此外,你的组件库能够做为一个样式​​指南,它能够是开发人员和设计人员之间的一个很好的协做点。

最容易出错的地方之一就是过早地建立太多这些组件。建立一个包含全部应用程序所需的全部常见视觉效果的基础框架颇有吸引力,可是,经验告诉咱们,在实际使用它们以前,很难(若是不是不可能的话)猜想组件的API应该是什么,这会致使组件早期的大量波动。出于这个缘由,咱们更愿意让团队在他们须要的时候在他们的代码库中建立本身的组件,即便这最初会致使一些重复。容许模式天然出现,一旦组件的API变得明显,你可使用 harvest 将重复的代码放入共享库中并确信这些已经被证实有效。

最明显的可供分享的组件是比较“傻”的视觉基元,如图标,标签和按钮。咱们也能够共享一些复杂组件,他们可能会包含大量的 UI 逻辑,如自动补全和下拉菜单搜索框。或者是可排序、可过滤的分页表。可是,请务必确保共享组件仅包含 UI 逻辑,而且不包含业务或域逻辑。将域逻辑放入共享库时,它会在应用程序之间建立高度耦合,并增长更改的难度。所以,例如,一般不该该尝试共享一个 ProductTable,它会包含关于“产品”到底是什么以及应该如何表现的各类假设。这种域建模和业务逻辑属于微前端的应用程序代码,而不是共享库中。

与任何共享的内部库同样,围绕其全部权和治理存在一些棘手的问题。一种模式是说做为共享资产,“每一个人”拥有它,但在实践中,这一般意味着没有人拥有它。它很快就会充满杂乱的风格不一致的代码,没有明确的约定或技术愿景。另外一方面,若是共享库的开发彻底集中化,那么建立组件的人与使用它们的人之间将存在很大的脱节。咱们看到的最好的模型是任何人均可觉得库作出贡献的模型,可是有一个保管人(一我的或一个团队)负责确保这些贡献的质量,一致性和有效性。维护共享库的工做须要强大的技术技能,还须要培养许多团队之间协做所需的人员技能。

建议按照顺序阅读本系列文章:

若是发现译文存在错误或其余须要改进的地方,欢迎到 掘金翻译计划 对译文进行修改并 PR,也可得到相应奖励积分。文章开头的 本文永久连接 即为本文在 GitHub 上的 MarkDown 连接。


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOS前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索