微前端的六种模式
由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!
微前端是架构师们在解耦方面做出的最新努力——它们值得我们为此付出努力吗?
微前端并非新生事物,但无疑是近来兴起的趋势。该概念于 2016 年提出,并随着大型 Web 应用开发中问题的日益增多而逐渐流行起来。本文将探讨创建微前端的不同模式,分析它们的优缺点,并提供每种方法的实现细节和示例。此外,本文还将论证微前端本身存在一些固有问题,而这些问题可以通过更进一步——进入模块化或无站点 UI 领域(取决于视角)——来解决。
但我们一步一步来。我们先从历史背景讲起。
背景
当网络(即以HTTP作为传输协议,HTML作为数据表示形式)诞生之初,并没有“设计”或“布局”的概念。当时人们交换的是文本文件。标签的引入<img>彻底改变了这一切。设计师们可以借此<table>向传统审美宣战。然而,一个问题很快浮现:如何在多个网站之间共享统一的布局?为此,人们提出了两种解决方案:
- 使用程序动态生成 HTML(速度较慢,但还可以接受——尤其是在 CGI 标准强大的支持下)
- 利用 Web 服务器中已集成的机制,将通用部件替换为其他部件。
前者催生了 C 和 Perl 网络服务器,随后发展为 PHP 和 Java,之后又演变为 C# 和 Ruby,最终出现了 Elixir 和 Node.js,而后者在 2002 年之后才真正出现。Web 2.0 也需要更复杂的工具,因此使用功能齐全的应用程序进行服务器端渲染在相当长的一段时间内占据主导地位。
直到 Netflix 出现,并号召大家创建规模更小的服务,好让云服务商赚得盆满钵满。讽刺的是,尽管 Netflix 完全有能力建立自己的数据中心,但他们仍然与 AWS 等云服务商紧密绑定,而 AWS 也为包括 Amazon Prime Video 在内的大多数竞争对手提供服务。
微前端模式
接下来,我们将探讨一些实现微前端架构的可行模式。我们会发现,当有人问“实现微前端的正确方法是什么?”时,“视情况而定”才是真正的答案。这很大程度上取决于我们的目标。
每个章节都包含一段示例代码和一个非常简单的代码片段(有时会用到框架),用于实现概念验证甚至最小可行产品(MVP)。最后,我会根据个人感受,简要总结一下目标受众。
无论你选择哪种模式,在集成不同项目时,保持用户界面的一致性始终是一个挑战。使用像Bit(GitHub)这样的工具,可以在不同的微服务之间共享和协作开发用户界面组件。
网络方法
实现微前端最简单的方法是部署一组小型网站(理想情况下只有一个页面),这些网站之间通过链接连接。用户通过指向不同服务器的链接在各个网站之间切换,这些服务器提供不同的内容。
为了保持布局一致性,服务器端可以使用模式库。每个团队可以根据自身需求实现服务器端渲染。此外,该模式库必须能够在不同的平台上使用。
使用 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 暴露给外部
服务器端合成
这才是真正的微前端方法。为什么呢?正如我们所见,微前端原本就应该在服务器端运行。因此,整个方法肯定可以独立运行。当我们为每个小型前端代码片段都配备一个专用服务器时,我们才能真正称之为微前端。
用示意图的形式,我们最终可能会得到如下图所示的草图。
这个方案的复杂性完全在于反向代理层。如何将不同的小型站点合并成一个站点可能很棘手。尤其是缓存规则、跟踪和其他一些棘手的问题,会让我们夜不能寐。
从某种意义上说,这为第一种方法增加了一个网关层。反向代理将不同的数据源合并成一个单一的交付源。当然,其中的棘手部分肯定需要(也能够)以某种方式解决。
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(边缘端包含)的完美用例——它是历史悠久的服务器端包含 (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>
Tailor 后端服务也采用了类似的设置,它是 Project Mosaic 的一部分。
在此空间中,我们找到了以下解决方案:
这种方法的优点和缺点是什么?
- 优点:完全隔离
- 优点:对用户来说像是嵌入式的。
- 优点:非常灵活的方法
- 缺点:强制组件之间耦合
- 缺点:基础设施复杂性
- 缺点:用户体验不一致
客户端合成
此时,有人可能会问:我们需要反向代理吗?由于这是一个后端组件,我们可能希望完全避免使用它。解决方案是客户端组合。最简单的实现方式是使用<iframe>元素。不同部分之间的通信通过方法完成postMessage。
注意:在某些情况下,JavaScript 部分可以替换为“浏览器” <iframe>。在这种情况下,潜在的交互方式肯定会有所不同。
顾名思义,这种模式旨在避免反向代理带来的基础设施开销。由于微前端本身就包含“前端”一词,因此整个渲染过程都由客户端完成。其优势在于,从这种模式出发,或许可以实现无服务器架构。最终,整个用户界面可以上传到例如 GitHub Pages 代码库,一切即可正常运行。
如前所述,这种组合可以通过非常简单的方法实现,例如,只需一个组件即可<iframe>。然而,主要痛点之一是最终用户如何看待这种集成。资源需求方面的重复也相当显著。当然,也可以采用模式 1 的混合方案,即将不同的部分放置在独立运行的 Web 服务器上。
然而,在这种模式中,知识再次发挥作用——组件 1 已经知道组件 2 的存在以及需要使用。它甚至可能需要知道如何使用组件 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 组件。在 Web 组件中,可以通过自定义事件经由 DOM 进行通信。然而,就目前而言,考虑客户端渲染而非客户端组合可能更有意义;因为渲染需要 JavaScript 客户端(这与 Web 组件的思路一致)。
在此空间中,我们找到了以下解决方案:
这种方法的优点和缺点是什么?
- 优点:完全隔离
- 优点:对用户来说像是嵌入式的。
- 优点:可实现无服务器架构
- 缺点:强制组件之间耦合
- 缺点:用户体验不一致
- 缺点:可能需要 JavaScript / 无法无缝集成
客户端渲染
虽然客户端组件组合可能无需 JavaScript 即可工作(例如,仅使用不依赖于与父组件或其他组件通信的框架),但客户端渲染如果没有 JavaScript 将会失败。因此,我们已经在组件应用程序中开始构建框架。所有引入的微前端都必须遵守此框架。至少,它们需要使用它才能正确挂载。
图案如下所示。
这与客户端合成非常接近,对吧?在这种情况下,JavaScript 部分可能无法替换。重要的区别在于,服务器端渲染通常不适用。取而代之的是,会交换一些数据片段,然后将这些数据转换成视图。
根据所设计或使用的框架,这些数据可以决定渲染片段的位置、时间点和交互性。采用这种模式,实现高度交互性不成问题。
在此空间中,我们找到了以下解决方案:
这种方法的优点和缺点是什么?
- 优点:强调关注点分离
- 优点:提供组件间的松耦合
- 优点:对用户来说像是嵌入式的
- 缺点:需要在客户端添加更多逻辑。
- 缺点:用户体验不一致
- 缺点:需要 JavaScript
SPA成分
为什么我们一定要局限于使用单一技术进行客户端渲染呢?为什么不直接获取一个 JavaScript 文件,并让它与其他 JavaScript 文件一起运行呢?这样做的好处在于可以并排使用多种技术。
运行多种技术(无论在后端还是前端——当然,在后端运行可能更容易被接受)是件好事还是应该避免的事情,还有待商榷;然而,在某些情况下,多种技术需要协同工作。
我随便想想:
- 迁移场景
- 支持特定的第三方技术
- 政治问题
- 团队限制
无论哪种方式,最终形成的模式都可以绘制如下。
那么这里发生了什么?在这种情况下,仅仅在应用程序外壳中提供一些 JavaScript 代码已经不再是可选项——相反,我们需要提供一个能够协调微前端的框架。
不同模块的协调运作归根结底就是对其生命周期的管理:挂载、运行和卸载。这些模块可以来自独立运行的服务器,但它们的服务器位置必须事先在应用程序 shell 中明确知道。
实现这样的框架至少需要一些配置,例如,需要包含的脚本映射:
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);
});
生命周期管理可能比上面的脚本更复杂。因此,此类组合的模块需要应用一些结构——至少需要一个导出mount函数unmount。
在此空间中,我们找到了以下解决方案:
这种方法的优点和缺点是什么?
- 优点:强调关注点分离
- 优点:赋予开发者很大的自由度
- 优点:对用户来说像是嵌入式的
- 缺点:导致重复劳动和额外开销
- 缺点:用户体验不一致
- 缺点:需要 JavaScript
无站点用户界面
这个话题值得单独写一篇文章,但既然我们已经列出了所有模式,就不在这里省略了。采用 SPA 组合方法,我们所缺少的只是脚本源与服务的解耦(或独立集中化),以及共享的运行时环境。
这两件事的发生都有其原因:
- 这种解耦确保了用户界面和服务职责不会混淆;这也有助于实现无服务器计算。
- 共享运行时是解决先前模式下资源密集型组合问题的良方。
这两项技术结合起来,能像“无服务器函数”之于后端一样,为前端带来诸多好处。但它们也面临着类似的挑战:
- 运行时环境不能简单地更新——它必须与模块保持一致。
- 在本地调试或运行这些模块需要运行时环境模拟器。
- 并非所有技术都能得到同等支持。
无站点用户界面的示意图如下所示。
这种设计的主要优势在于支持共享实用或通用资源。共享模式库意义重大。对于模式库,像前面提到的Bit 这样的工具不仅有助于设置,还能帮助与其他开发者或团队协作开发组件。这使得在微前端之间维护一致的 UI 变得更加容易,无需投入时间和精力来构建和维护 UI 组件库。
总的来说,架构图与前面提到的单页应用(SPA)结构非常相似。然而,信息流服务和与运行时的耦合带来了额外的优势(以及任何相关框架都需要解决的挑战)。最大的优势在于,一旦这些挑战得到解决,开发体验将会非常出色。用户体验可以完全自定义,各个模块可以被视为灵活的、可选的功能单元。因此,功能(相应的实现)和权限(访问该功能的权利)之间可以实现清晰的分离。
这种模式最简单的实现方式之一如下:
// 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 实现的超级马里奥游戏,名为“Mario5”。之后,我又开发了一个名为“Mario5TS”的 TypeScript 教程/重写版本,并一直持续更新至今。
对于应用程序外壳,我们使用示例 Piral 实例。它能一目了然地展示所有概念,并且始终保持最新状态。
我们先从一个模块开始,在 Piral 框架中,这个模块被称为pilet。pilet的核心是一个 JavaScript 根模块,它通常位于src/index.tsx。
一堆
从一个空的堆开始,我们得到以下根模块:
import { PiletApi } from "sample-piral";
export function setup(app: PiletApi) {}
我们需要导出一个名为 `<function_name>` 的特殊名称的函数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
}
);
}
由于我们的图块需要一些样式,我们也向组件中添加了一个样式表。很好,目前一切顺利。所有直接包含的资源将始终在应用程序外壳中可用。
现在是时候把游戏本身也整合进去了。我们决定把它放在一个单独的页面上,虽然模态对话框可能也挺酷的。所有代码都放在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()。
堆垛的启动将通过以下方式完成:
npm start
它底层使用了 Piral CLI。Piral CLI 始终安装在本地,但也可以全局安装,以便pilet debug直接在命令行中使用某些命令。
桩基的建造也可以通过本地安装完成。
npm run build-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"));
在这里我们做三件事:
- 我们设置好了所有导入和库。
- 我们创建 Piral 实例;提供所有功能选项(最重要的是声明堆垛的来源)
- 我们使用组件和我们定义的自定义布局来渲染应用程序外壳。
实际渲染是由 React 完成的。
构建应用程序框架非常简单——最终会使用一个标准的打包工具(Parcel)来处理整个应用程序。输出结果是一个包含所有文件的文件夹,这些文件将被放置在 Web 服务器或静态存储设备上。
景点
“Siteless UI”这个术语的提出可能需要一些解释。我先从名称本身说起:顾名思义,它直接来源于“Serverless Computing”(无服务器计算)。虽然“Serverless”(无服务器)这个词本身可能也适用于这项技术,但它也可能具有误导性,甚至并不准确。用户界面通常可以部署在无服务器基础设施上(例如,Amazon S3、Azure Blob Storage、Dropbox)。这是“在客户端渲染用户界面”而非服务器端渲染的优势之一。然而,我想要遵循的是“用户界面必须依附于宿主机才能运行”的理念。这与无服务器函数类似,它们需要一个运行时环境才能启动。
让我们比较一下它们的相似之处。首先,我们先来看一个共同点:微前端之于前端用户界面,应该就像微服务之于后端服务一样。在这种情况下,我们应该有:
- 可以独立启动
- 提供独立URL
- 具有独立的生命周期(启动、回收、关闭)
- 进行独立部署
- 定义一个独立的用户/状态管理
- 以专用 Web 服务器的形式在某处运行(例如,作为 Docker 镜像)
- 如果结合起来,我们会使用类似网关的结构。
很好,当然有些东西在这里适用,但是请注意,其中一些与 SPA 组成相矛盾,因此也与无站点 UI 相矛盾。
现在,让我们将其与一个类比进行比较,假设假设变为:无站点用户界面之于前端用户界面,应该如同无服务器函数之于后端服务。在这种情况下,我们有:
- 需要运行时环境才能启动
- 在运行时环境指定的范围内提供 URL。
- 与运行时定义的生命周期相关。
- 进行独立部署
- 定义了部分独立、部分共享(但受控/隔离)的用户/状态管理
- 在其他地方的非运维基础设施上运行(例如,在客户端)
如果你觉得这个比喻很贴切——太棒了!如果不是,请在下方评论区留言。我会继续思考,看看这个想法是否值得深入探讨。非常感谢!
延伸阅读
以下帖子和文章可能有助于您了解全貌:
- 零碎信息,2019年11月
- dev.to,2019年11月
- LogRocket,2019年2月
- 微前端——一种基于微服务的前端Web开发方法
- 微前端:可用解决方案
- 探索微前端
- 6 种微前端类型直接对比:它们的优缺点(已翻译)
结论
微前端并非适用于所有人。事实上,它们甚至无法解决所有问题。但哪项技术又是万能的呢?它们无疑能填补一些空白。根据具体问题,总有一种模式可能适用。我们拥有如此丰富多样且卓有成效的解决方案,这真是太好了。现在唯一的问题就是如何选择——而且要做出明智的选择!
文章来源:https://dev.to/florianrappl/microfrontends-from-zero-to-hero-3be7






