65.9K
CodeProject 正在变化。 阅读更多。
Home

微前端 - 从零到英雄

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (7投票s)

2019 年 11 月 20 日

CPOL

17分钟阅读

viewsIcon

18989

微前端是架构师解耦工作的最新补充——它们值得为之奋斗吗?

图片来自 Michael GaidaPixabay

目录

引言

微前端并非新生事物,但无疑是近期的一个趋势。该模式于2016年首次提出,随着大型Web应用程序开发中问题的不断增多,它逐渐获得了普及。在本文中,我们将探讨创建微前端的各种模式、它们的优缺点,以及每种方法的实现细节和示例。我还会论证,微前端带来了一些固有的问题,这些问题可以通过更进一步的探索来解决——进入一个根据视角不同,可以称之为“模块化单体”(Modulith)或“无站点UI”(Siteless UI)的领域。

但让我们一步一步来。我们从历史背景开始。

背景

当Web(即,以HTTP为传输,HTML为表示)开始时,没有“设计”或“布局”的概念。取而代之的是文本文档的交换。<img>标签的引入改变了这一切。与<table>一起,设计师们可以向品味宣战。尽管如此,一个问题很快就出现了:如何在多个站点之间共享通用布局?为此,提出了两种解决方案:

  1. 使用程序动态生成HTML(速度较慢,但尚可——尤其是利用CGI标准强大的功能)
  2. 利用Web服务器已有的机制来替换通用部分。

前者催生了C和Perl的Web服务器,它们后来演变成了PHP和Java,接着转换为C#和Ruby,最后出现了Elixir和Node.js,而后者在2002年之后并没有真正发展。Web 2.0也要求更复杂的工具,这就是为什么服务器端渲染使用完整的应用程序主导了相当长一段时间。

直到Netflix出现,并告诉大家开发更小的服务,让云厂商更富裕。讽刺的是,虽然Netflix已经准备好自建数据中心,但它们仍然严重依赖AWS等云厂商,而AWS也托管了包括Amazon Prime Video在内的大部分竞争对手。

微前端的模式

接下来,我们将探讨一些可以实际实现微前端架构的模式。“这取决于”(it depends)实际上是当有人问“实现微前端的最佳方式是什么?”时的正确答案。这很大程度上取决于我们想要达到的目标。

每个章节都包含一些示例代码和一个非常简单的代码片段(有时使用框架),用于实现该模式的概念验证或MVP。最后,我将尝试提供一个简短的总结,根据我个人的感觉,指出目标受众。

无论您选择哪种模式,在集成独立项目时,保持UI的一致性始终是一个挑战。使用像BitGithub)这样的工具,可以在不同的微服务之间共享和协作UI组件。

Working on a Pattern Library with Bit

Web方法

实现微前端最简单的方法是部署一组小型网站(最好是单个页面),并将它们链接在一起。用户通过链接导航到提供内容的各个服务器,从而在网站之间跳转。

为了保持布局一致,可以在服务器上使用模式库。每个团队都可以按照自己的意愿实现服务器端渲染。模式库也必须能够在不同的平台上使用。

Pattern: The Web Approach

使用Web方法可以像将静态网站部署到服务器一样简单。如下使用Docker镜像即可完成:

FROM nginx:stable
COPY ./dist/ /var/www
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
CMD ["nginx -g 'daemon off;'"]

显然,我们不局限于使用静态网站。我们也可以应用服务器端渲染。将nginx基础镜像更改为,例如,ASP.NET Core,可以让我们使用ASP.NET Core来生成页面。但这与前端整体有什么不同?在这种场景下,例如,我们会获取一个通过Web API公开的给定微服务(即返回类似JSON的内容),并将其更改为返回渲染的HTML。

逻辑上,在这个世界里,微前端只不过是表示API的另一种方式。我们不再返回“裸”数据,而是已经生成了视图。

在这个领域,我们发现了以下解决方案:

这种方法的优缺点是什么?

  • 优点:完全隔离
  • 优点:最灵活的方法
  • 优点:最简单的方法
  • 缺点:基础设施开销
  • 缺点:用户体验不一致
  • 缺点:内部URL暴露给外部

服务器端组合

这才是真正的微前端方法。为什么?正如我们所见,微前端本应在服务器端运行。因此,整个方法肯定可以独立运行。如果我们为每个小的前端片段都有一个专用的服务器,我们就可以真正称之为前端。

以图示形式,我们可能会得到如下草图:

Pattern: Server-Side Composition

这个解决方案的复杂性完全在于反向代理层。如何将不同的较小站点组合成一个站点可能很棘手。尤其是缓存规则、跟踪和其他棘手的问题会在夜间困扰我们。

在某种意义上,这为第一种方法增加了一种网关层。反向代理将不同的源组合成一个单一的交付。虽然棘手的问题当然需要(并且可以)以某种方式解决。

http {
  server {
    listen 80;
    server_name www.example.com;
    location /api/ {
      proxy_pass http://api-svc:8000/api;
    }
    location /web/admin {
      proxy_pass http://admin-svc:8080/web/admin;
    }
    location /web/notifications {
      proxy_pass http://public-svc:8080/web/notifications;
    }
    location / {
      proxy_pass /;
    }
  }
}

更强大一些的是使用Varnish反向代理之类的东西。

此外,我们发现这也是ESI(Edge-Side Includes的缩写)的绝佳用例——它是历史悠久的服务器端包含(SSI)的一个(更加灵活的)后继者。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Demo</title>
  </head>
  <body>
    <esi:include src="http://header-service.example.com/" />
    <esi:include src="http://checkout-service.example.com/" />
    <esi:include
      src="http://navigator-service.example.com/"
      alt="http://backup-service.example.com/"
    />
    <esi:include src="http://footer-service.example.com/" />
  </body>
</html>

在Project Mosaic的一部分,Tailor后端服务也可以看到类似的设置。

在这个领域,我们发现了以下解决方案:

这种方法的优缺点是什么?

  • 优点:完全隔离
  • 优点:对用户来说看起来是嵌入式的
  • 优点:非常灵活的方法
  • 缺点:强制组件之间耦合
  • 缺点:基础设施复杂性
  • 缺点:用户体验不一致

客户端组合

此时,有人可能会问:我们需要反向代理吗?既然这是一个后端组件,我们可能想完全避免它。解决方案是客户端组合。最简单形式是使用<iframe>元素。不同部分之间的通信通过postMessage方法完成。

Pattern: Client-Side Composition

注意:在<iframe>的情况下,JavaScript部分可能会被“浏览器”替换。在这种情况下,潜在的交互性肯定有所不同。

正如名称所暗示的,这种模式试图避免反向代理带来的基础设施开销。相反,由于微前端已经包含了“前端”一词,整个渲染工作就留给了客户端。其优势在于,从这种模式开始,可能实现无服务器。最终,整个UI可以上传到,例如,GitHub Pages存储库,一切都能正常工作。

如上所述,组合可以使用相当简单的方法完成,例如,仅仅是一个<iframe>。然而,主要痛点之一是这样的集成对最终用户来说看起来是什么样的。在资源需求方面的重复也很可观。与模式1的混合是绝对可能的,其中不同的部分被放置在独立运行的Web服务器上。

尽管如此,在这种模式下,知识再次变得必要——因此组件1已经知道组件2存在,并且需要被使用。它甚至可能需要知道如何使用它。

考虑以下父级(即,已交付的应用或网站):

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Microfrontends Shell</title>
  </head>
  <body>
    <h1>Parent</h1>
    <p><button id="message_button">Send message to child</button></p>
    <div id="results"></div>

    <script>
      const iframeSource = "https://example.com/iframe.html";
      const iframe = document.createElement("iframe");
      const messageButton = document.querySelector("#message_button");
      const results = document.querySelector("#results");

      iframe.setAttribute("src", iframeSource);
      iframe.style.width = "450px";
      iframe.style.height = "200px";

      document.body.appendChild(iframe);

      function sendMessage(msg) {
        iframe.contentWindow.postMessage(msg, "*");
      }

      messageButton.addEventListener("click", function(e) {
        sendMessage(Math.random().toString());
      });

      window.addEventListener("message", function(e) {
        results.innerHTML = e.data;
      });
    </script>
  </body>
</html>

我们可以编写一个页面来启用直接通信路径:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Microfrontend</title>
  </head>
  <body>
    <h1>Child</h1>
    <p><button id="message_button">Send message to parent</button></p>
    <div id="results"></div>

    <script>
      const results = document.querySelector("#results");
      const messageButton = document.querySelector("#message_button");

      function sendMessage(msg) {
        window.parent.postMessage(msg, "*");
      }

      window.addEventListener("message", function(e) {
        results.innerHTML = e.data;
      });
      messageButton.addEventListener("click", function(e) {
        sendMessage(Math.random().toString());
      });
    </script>
  </body>
</html>

如果我们不考虑框架作为选项,我们也可以选择Web Components。在这里,通信可以通过DOM使用自定义事件完成。然而,此时,考虑客户端渲染而不是客户端组合可能是有意义的;因为渲染意味着需要一个JavaScript客户端(这与Web Components方法一致)。

在这个领域,我们发现了以下解决方案:

这种方法的优缺点是什么?

  • 优点:完全隔离
  • 优点:对用户来说看起来是嵌入式的
  • 优点:可能实现无服务器
  • 缺点:强制组件之间耦合
  • 缺点:用户体验不一致
  • 缺点:可能需要JavaScript / 无无缝集成

客户端渲染

虽然客户端组合可以在没有JavaScript的情况下工作(例如,只使用不依赖于与父级或彼此通信的框架),但客户端渲染在没有JavaScript的情况下会失败。在这个空间里,我们已经开始在组合应用程序中创建一个框架。所有引入的微前端都需要遵守这个框架。至少,它们需要使用它才能正确挂载。

该模式看起来如下:

Pattern: Client-Side Rendering

与客户端组合非常相似,对吧?在这种情况下,JavaScript部分可能不会被替换。重要的区别是服务器端渲染通常被排除在外。取而代之的是交换数据片段,然后将其转换为视图。

取决于设计的或使用的框架,数据片段可以决定渲染片段的位置、时间和交互性。使用这种模式实现高度的交互性不是问题。

在这个领域,我们发现了以下解决方案:

这种方法的优缺点是什么?

  • 优点:强制关注点分离
  • 优点:提供组件的松散耦合
  • 优点:对用户来说看起来是嵌入式的
  • 缺点:客户端需要更多逻辑
  • 缺点:用户体验不一致
  • 缺点:需要JavaScript

SPA组合

为什么我们要止步于使用单一技术的客户端渲染?为什么不直接获取一个JavaScript文件并在所有其他JavaScript文件旁边运行它?这样做的好处是可以使用多种技术并存。

运行多种技术(无论是在后端还是前端——当然,在后端可能“更容易接受”)是好是坏,尚有争议,但确实存在多种技术需要协同工作的场景。

我能想到的有:

  • 迁移场景
  • 支持特定第三方技术
  • 政治因素
  • 团队约束

无论如何,由此产生的模式可以绘制如下:

Pattern: SPA Composition

那么这里发生了什么?在这种情况下,只交付一些JavaScript与应用外壳不再是可选的——相反,我们需要交付一个能够编排微前端的框架。

不同模块的编排归结为生命周期的管理:挂载、运行、卸载。不同的模块可以来自独立运行的服务器,然而,它们的位置必须在应用程序外壳中已知。

实现这样的框架至少需要一些配置,例如,一个要包含的scripts映射:

const scripts = [
  'https://example.com/script1.js',
  'https://example.com/script2.js',
];
const registrations = {};

function activityCheck(name) {
  const current = location.hash;
  const registration = registrations[name];

  if (registration) {
    if (registration.activity(current) !== registration.active) {
      if (registration.active) {
        registration.lifecycle.unmount();
      } else {
        registration.lifecycle.mount();
      }

      registration.active = !registration.active;
    }
  }
}

window.addEventListener('hashchange', function () {
  Object.keys(registrations).forEach(activityCheck);
});

window.registerApp = function(name, activity, lifecycle) {
  registrations[name] = {
    activity,
    lifecycle,
    active: false,
  };
  activityCheck(name);
}

scripts.forEach(src => {
  const script = document.createElement('script');
  script.src = src;
  document.body.appendChild(script);
});

生命周期管理可能比上面的script更复杂。因此,用于这种组合的模块需要具有一定的结构——至少是导出的mountunmount函数。

在这个领域,我们发现了以下解决方案:

这种方法的优缺点是什么?

  • 优点:强制关注点分离
  • 优点:给开发者很大的自由度
  • 优点:对用户来说看起来是嵌入式的
  • 缺点:强制重复和开销
  • 缺点:用户体验不一致
  • 缺点:需要JavaScript

无站点UI

这个主题值得单独写一篇文章,但既然我们要列出所有模式,我不想在这里省略它。采取SPA组合的方法,我们只缺少脚本源与服务的分离(或独立集中),以及共享运行时。两者都是有原因的。

这两者都是有原因的:

  • 分离确保UI和服务的职责不混淆;这还支持无服务器计算。
  • 共享运行时是解决前一种模式中资源密集型组合问题的良方。

两者的结合为前端带来了“无服务器函数”为后端带来的好处。它们也伴随着类似的挑战:

  • 运行时不能随意更新——它必须保持/保持与模块一致。
  • 本地调试或运行模块需要运行时的模拟器。
  • 并非所有技术都得到同等支持。

无站点UI的图示如下:

Pattern: Siteless UIs

这种设计的主要优点是支持共享有用或通用的资源。共享模式库非常有意义。

对于模式库,像Bit这样的工具不仅有助于设置,还能与其他开发人员或团队协作开发组件。这使得在微前端中维护一致的UI变得更加容易,而无需投入时间和精力来构建和维护UI组件库。

总而言之,架构图与前面提到的SPA组合非常相似。然而,feed服务和与运行时的耦合带来了额外的优点(以及任何相关框架需要解决的挑战)。最大的优点是,一旦这些挑战被攻克,开发体验将是卓越的。用户体验可以完全定制,将模块视为灵活的可选功能。因此,可以在功能(各自的实现)和权限(访问功能的权利)之间实现清晰的分离。

这种模式最简单的实现之一如下:

// app-shell/main.js
window.app = {
  registerPage(url, cb) {}
  // ...
};

showLoading();

fetch("https://feed.piral.io/api/v1/pilet/sample")
  .then(res => res.json())
  .then(body =>
    Promise.all(
      body.items.map(
        item =>
          new Promise(resolve => {
            const script = document.createElement("script");
            script.src = item.link;
            script.onload = resolve;
            document.body.appendChild(script);
          })
      )
    )
  )
  .catch(err => console.error(err))
  .then(() => hideLoading());

// module/index.jsx
import * as React from "react";
import { render } from "react-dom";
import { Page } from "./Page";

if (window.app !== undefined) {
  window.app.registerPage("/sample", element => {
    render(<Page />, element);
  });
}

这使用了一个全局变量来共享来自应用外壳的API。然而,我们已经看到了使用这种方法的几个挑战:

  • 如果一个模块崩溃了怎么办?
  • 如何共享依赖项(以避免像简单实现那样将它们捆绑到每个模块中)?
  • 如何获得正确的类型?
  • 这如何被调试?
  • 如何进行正确的路由?

实现所有这些功能是其本身的主题。关于调试,我们应该遵循所有无服务器框架(例如AWS Lambda、Azure Functions)的方法。我们只需提供一个模拟器,该模拟器将来表现得像真实的东西一样;除了它在本地运行并且可以离线工作。

在这个领域,我们发现了以下解决方案:

这种方法的优缺点是什么?

  • 优点:强制关注点分离
  • 优点:支持资源共享以避免开销
  • 优点:一致且嵌入式的用户体验
  • 缺点:共享资源需要严格的依赖管理
  • 缺点:需要另一个(可能已管理的)基础设施
  • 缺点:需要JavaScript

微前端框架

最后,我们应该看看如何使用一个提供的框架来实现微前端。我们选择Piral,因为我最熟悉它。

接下来,我们从两个方面着手。首先,我们从一个模块(即,在这种上下文中的微前端)开始。然后,我们将介绍如何创建一个应用外壳。

对于模块,我们使用了我的Mario5玩具项目。这是一个多年前以JavaScript实现的Super Mario“Mario5”开始的项目。随后是TypeScript教程/重写名为“Mario5TS”,并且一直保持最新。

对于应用外壳,我们使用了Piral示例实例。它一举展示了所有概念。它也一直保持最新。

让我们从一个模块开始,在Piral框架中称为pilet。其核心是一个JavaScript根模块,通常位于src/index.tsx

一个Pilet

从一个空的pilet开始,我们得到以下根模块:

import { PiletApi } from "sample-piral";

export function setup(app: PiletApi) {}

我们需要导出一个特殊命名的函数setup。这个函数稍后将用于集成我们应用程序的特定部分。

例如,使用React,我们可以注册一个菜单项或一个总是显示的磁贴:

import "./Styles/tile.scss";
import * as React from "react";
import { Link } from "react-router-dom";
import { PiletApi } from "sample-piral";

export function setup(app: PiletApi) {
  app.registerMenu(() => <Link to="/mario5">Mario 5</Link>);

  app.registerTile(
    () => (
      <Link to="/mario5" className="mario-tile">
        Mario5
      </Link>
    ),
    {
      initialColumns: 2,
      initialRows: 2
    }
  );
}

由于我们的磁贴需要一些样式,我们也为pilet添加了一个样式表。很好,到目前为止一切顺利。所有直接包含的资源将始终在应用外壳中可用。

现在是时候集成游戏本身了。我们决定将其放在一个专门的页面上,尽管一个模态对话框也可能很酷。所有代码都在mario.ts中,并直接使用DOM——还没有React。

由于React也支持对托管节点的操纵,我们使用引用钩子来附加游戏。

import "./Styles/tile.scss";
import * as React from "react";
import { Link } from "react-router-dom";
import { PiletApi } from "sample-piral";
import { appendMarioTo } from "./mario";

export function setup(app: PiletApi) {
  app.registerMenu(() => <Link to="/mario5">Mario 5</Link>);

  app.registerTile(
    () => (
      <Link to="/mario5" className="mario-tile">
        Mario5
      </Link>
    ),
    {
      initialColumns: 2,
      initialRows: 2
    }
  );

  app.registerPage("/mario5", () => {
    const host = React.useRef();
    React.useEffect(() => {
      const gamePromise = appendMarioTo(host.current, {
        sound: true
      });
      gamePromise.then(game => game.start());
      return () => gamePromise.then(game => game.pause());
    });
    return <div ref={host} />;
  });
}

理论上,我们还可以添加进一步的功能,例如恢复游戏或延迟加载包含游戏的侧边打包。目前,只有声音通过调用import()函数进行延迟加载。

启动pilet将通过以下方式完成:

npm start

...这在后台使用Piral CLI。Piral CLI始终是本地安装的,但也可以全局安装,以直接在命令行中获取pilet debug等命令。

也可以使用本地安装来构建pilet。

npm run build-pilet

应用外壳

现在是时候创建一个应用外壳了。通常,我们已经有一个应用外壳(例如,上面的pilet已经为示例应用外壳创建),但在我看来,了解模块的开发过程更重要。

使用Piral创建应用外壳就像安装piral一样简单。为了使其更加简单,Piral CLI还支持脚手架一个新的应用外壳。

无论如何,我们很可能会得到这样的结果:

import * as React from "react";
import { render } from "react-dom";
import { createInstance, Piral, Dashboard } from "piral";
import { Layout, Loader } from "./layout";

const instance = createInstance({
  requestPilets() {
    return fetch("https://feed.piral.io/api/v1/pilet/sample")
      .then(res => res.json())
      .then(res => res.items);
  }
});

const app = (
  <Piral instance={instance}>
    <SetComponent name="LoadingIndicator" component={Loader} />
    <SetComponent name="Layout" component={Layout} />
    <SetRoute path="/" component={Dashboard} />
  </Piral>
);

render(app, document.querySelector("#app"));

在这里,我们做了三件事:

  1. 我们设置了所有导入和库。
  2. 我们创建了Piral实例;提供了所有功能选项(最重要的是声明pilet的来源)。
  3. 我们使用组件和我们定义的自定义布局来渲染应用外壳。

实际渲染由React完成。

构建应用外壳很简单——最终,它是一个标准的打包器(Parcel)来处理整个应用程序。输出是一个包含所有文件的文件夹,可以放置在Web服务器或静态存储上。

关注点

创造“无站点UI”这个术语可能需要一些解释。我将尝试从名字开始解释:如前所述,这是对“无服务器计算”的直接引用。虽然无服务器技术可能也是所用技术的一个好术语,但它也可能具有误导性且不正确。UI通常可以部署在无服务器基础设施上(例如,Amazon S3、Azure Blob Storage、Dropbox)。这是“在客户端渲染UI”而不是进行服务器端渲染的好处之一。然而,我想要遵循“拥有一个没有宿主就无法生存的UI”这样的想法。与无服务器函数一样,它们需要一个运行在某个地方的运行时,否则无法启动。

让我们比较一下相似之处。首先,让我们先假设微前端对于前端UI应该如同微服务对于后端服务一样。在这种情况下,我们应该有:

  • 可以独立启动
  • 提供独立的URL
  • 具有独立的生命周期(启动、回收、关闭)
  • 可独立部署
  • 定义独立的用户/状态管理
  • 在某个地方作为专用Web服务器运行(例如,作为Docker镜像)
  • 组合在一起时,我们使用一种类似网关的结构。

很好,当然有些地方适用,但请注意,其中一些与SPA组合相矛盾,因此也与无站点UI相矛盾。

现在让我们将其与一个类比进行比较,如果我们能把推测改为:无站点UI对于前端UI应该如同无服务器函数对于后端服务一样。在这种情况下,我们有:

  • 需要一个运行时来启动
  • 在运行时提供的环境中提供URL
  • 耦合到运行时定义的生命周期
  • 可独立部署
  • 定义部分独立、部分共享(但受控/隔离)的用户/状态管理
  • 运行在其他地方的非托管基础设施上(例如,在客户端)

如果您同意这听起来像是一个完美的类比——太棒了!如果不是,请在下面的评论中提供您的观点。我仍然会尝试看看这将走向何方,以及这是否似乎是一个很好的遵循的想法。非常感谢!

延伸阅读

以下帖子和文章可能对您全面了解有所帮助:

结论

微前端并非适合所有人。甚至不是为了解决所有问题。但什么技术是呢?它们肯定填补了一个空白。根据具体问题,其中一种模式可能是适用的。很高兴我们有许多多样且富有成效的解决方案。现在唯一的问题就是选择——明智地选择!

历史

  • v1.0.0 | 初始发布 | 2019年11月20日
  • v1.1.0 | 添加了更多关于Bit的信息 | 2019年11月22日
  • v1.2.0 | 添加了目录 | 2019年11月25日
  • v1.3.0 | 添加了更多链接 | 2019年12月8日
© . All rights reserved.