原文地址:martinfowler.com/articles/mi…
做好前端开发是困难的。扩展前端开发,多个前端团队同时开发一个大而复杂的产品,就更困难了。在本文中,我们将介绍一种新的趋势,将巨石应用拆解成多个更小、更易管理小应用。以及解释这种架构是如何提升团队处理前端代码的有效性和效率的。除了介绍各种优劣势,我们还将介绍一些可用的实施选项,并且我们将深入介绍一个使用了此技术的完整示例。
近年来,微服务大受欢迎,很多团队使用此架构来规避单个巨石后端应用的局限。即使用微服务构建的软件已经很多,大量公司仍然继续在与巨石前端代码库做斗争。也许你想构建一个渐进式的或响应式的网页应用,但不知从何下手将这些功能整合到现有代码中。也许你计划开始使用 JavaScript 的新特性(或是可编译成 JavaScript 的无数语言之一),但是你不能将必要的构建工具安装到你已有的构建进程中。又或者你仅仅是想要扩展你的应用,以支持多团队同时开发一个产品,但是现有的耦合、复杂的单体应用会彼此之间互踩脚趾(相互干扰)。这些问题不利于,你高效的为客户提供高质量的用户体验。
近来我们看到越来越多的人关注复杂、现代的 Web 开发所必须的整体架构和组织结构。特别是,我们看到前端巨石应用被分解成更小、更简单 Chunk 的模式兴起,它们可以独立开发、测试、部署,但客户看到的依然是一个整体的产品。我们称这种技术为微前端,我们将其定义为:
“一种由多个独立可交付的前端应用组合而成更庞大整体的架构方式”
在2016年11月出版的 ThoughtWorks 技术雷达上,我们指出微前端需作为组织应该评估的一种技术。后来我们促其被评审,最后被采纳,即我们认为它已经被证明在需要时你可以使用它。
图 1:微前端已多次出现在技术雷达上
我们所看到的微前端关键优势:
- 更小、更有凝聚力和可维护的代码库
- 更易解耦组织依赖,形成独立团队
- 比以往更容易升级、更新或重写部分前端代码
这些优势都是由微服务带来的,这不是巧合。
当然,天下没有免费的午餐 —— 于软件架构而言,是有代价的。一些微前端的实现会导致重复依赖,从而增加用户加载更多字节数。此外单个团队自主的增加,会割裂各团队间的工作。尽管如此,我们依然相信这些风险是可控的,并且微前端的优势是大于劣势的。
优势
对于微前端的定义,相较于其特定的技术实现细节,我们更注重其所拥有的属性和所带来的优势。
增量式升级
对于很多组织来说,这是他们开始微前端之旅的理由。原前端巨石应用被过时的技术栈所拖累,或仅在交付压力下编写代码,并且重写已经达到很诱人的程度。为了避免完全重写,我们更喜欢将老应用一点一点的干掉,并同时继续为我们的客户提供新的功能,而不被巨石拖累。
此时经常引导往微前端架构发展。一旦有一个团队拥有了对旧世界(老代码)做很小的修改的同时新增功能到生产环境的经验,那么其他团队也会想要加入。现有代码仍然需要维护,在某些情况下,对其增加新功能也是有意义的,但现在有了选择。
这里的结局是,我们有了对产品中独立部分的架构、依赖和用户体验是否采用增量升级的决策权。如果我们主框架发生了重大的突破性变化,每个微前端可依据自身需要进行升级,而不用停下所有并一次性做好升级。如果我们想尝试新的技术,或新的交互,我们可以比以往更独立的方式来实现。
简单、解耦的代码库
根据定义,每个微前端的源码要比一个单独的巨石应用小得多。这些更小的代码库对于开发者而言会更简单、更容易。特别是避免了,组件间无意识、不恰当的耦合所产生的复杂性。通过在应用内绘制更清晰的上下文界限,使得意外耦合更不易发生。
当然,一个高阶架构决策(例如“开始微前端”),不会用于替代好的老式的清洁代码。我们并不是试图避免对提升代码质量的思考。相反的,我们试图让我们自己处于更容易做出好决策的环境中。例如,共享跨越有界上下文的领域模型更为麻烦,故而开发者更不愿如此做。类似的,微前端促使你明确谨慎的思考数据和事件如何在应用的不同部分之间流转,这是些我们必须做的事情!
独立部署
就像微服务,微前端的可独立部署性是关键。减少给定部署范围,从而降低关联风险。无论你的前端代码以何种形式在哪里进行托管,每个微前端都需要专属于自己的连续发布管道,构建、测试和发布到生产环境。我们需要能部署每个微前端,而无需顾虑其他代码库或发布管道的状态。不用关心,原巨石应用是固定的、手动的还是周期性的发布,亦或者是隔壁团队把半完成的或有损的功能推送到了他们的主干上。一个特定微前端是否准备好投入生产,这应该由构建和维护它的团队来决定。
图 2: 每个微前端都被独立的部署到生产环境
自主团队
作为高阶优势,相较于解耦代码库和周期发布,拥有完全独立的团队需要走更长的路,其可以承担产品中某一个部分的完善和超越。团队拥有为用户提供价值所需的权力,这使他们可以快速有效的行动。为实现这一目标,我们团队需要形成基于业务功能的垂直切片,而非基于技术能力。一个简单的方式是基于用户最终看到的产品形态进行分割,所以每个微前端囊括一个单页面应用,并由一个团队端到端的拥有。这个比基于技术或者横向关系(如样式、表单或验证)形成的团队,带来更高的内聚性。
图 3: 每个应用应该被单一团队所拥有
简而言之
简言之,微前端是将大而可怕的应用分割成更小更容易管理的应用,并明确他们之间关系的技术。我们的技术选型、代码库、团队和发布过程都需要能独立的操作和发展,而无需过度协调。
示例
想想一个网站,顾客可以订餐并配送到家。从表面上看,这是一个相当简单的概念,但如果要实现它,你会发现有惊人多的细节:
- 应该有个首页,用于顾客浏览和搜索餐厅。餐厅列表需要能够搜索和通过众多属性的过滤,如价格、风味或者顾客之前购买过的食物。
- 每个餐厅需要专属页面展示它的菜单项,并允许顾客选择他们想要吃什么,包括折扣、优惠券及特殊需求。
- 顾客需要一个配置页面,可以看到他们的订单历史,跟踪配送并配置付款方式。
图 4: 一个送餐网站可能有多个相当复杂的页面
每个页面都足够复杂,我们很容易证明每个页面都需要专门的独立于其他团队的团队来支持。他们需要能给开发、测试、部署和维护他们的代码,而不需要为与其他团队的冲突与协调担心。然后,我们的用户依然可以看到一个单一的无缝的网站。在文章的其余部分,我们将在任何需要使用样例代码或场景中使用此示例应用。
整合方法
鉴于前面相当松散的定义,有很多被称为微前端的合理方法。在本章节中,我们将展示一些示例,并讨论他们的利弊。这是一个相当自然的架构——通常应用中的每个页面就是一个微前端,还是一个单容器应用,其中:
- 渲染通用的页面元素,例如页头和页尾
- 解决诸如认证和导航等交叉关系问题
- 将各种微前端放在页面上,并告诉每个微前端何时何地渲染自己
图 5: 您通常可以从页面的视觉结构中推导出架构
服务端模板构成
我们的前端开发始于一种绝对不新颖的方法——在服务端输出多模板或片段渲染HTML。我们有一个包含了通用页面元素的 index.html 文件,然后使用服务端的 include 插入包含了特定页面内容的 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作为服务,配置 $PAGE 变量,根据请求的 URL 进行匹配。
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 服务上显示的,但假定它们有各自的部署管道,这允许我们部署变更到一个页面上,而不影响或考虑其他页面。
为了获得更大的独立性,这里需要分离出独立的服务用于渲染和为每个微前端服务,作为一个服务的输出需要在前端向多个其他服务发送请求。使用缓存,将不存在相应的延迟。
图 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 包,允许我们在各种应用中不重复依赖公共关系。然而,此方法意味着我们必须重新编译和发布每个微前端,以便发布每个独立部分的变更。如同微服务,我们已经在锁定步骤的发布过程中饱受痛苦,以致我们强烈反对在微前端中使用这种方法。
在我们经历了将应用程序分割为可独立开发和测试的代码库的所有麻烦之后,就不要再经受发布耦合的麻烦了。我们应该找到在执行态集成微前端的方法,而不是在构建态。
通过 iframe 在执行态集成
一种在浏览器端组合应用最简的方法是使用简陋的 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>
类似服务端的 include 选项,从 iframe 构建一个页面也不是新技术,或许这并不令人兴奋。但是我们回顾前面罗列的微前端的优势,iframe 符合大部分要求,只要我们谨慎拆分应用和团队组织结构。
我们经常看到很多人不情愿选择 iframe。虽然有些不情愿源于对 iframe 有点“恶心”的直觉,但可以通过一些好的理由规避它们。前面提到的简单隔离相比其他选项确实缺失了一些灵活性。很难在应用程序的不同部分之间构建集成,它们使得路由、历史、深度链接更加复杂,并且对页面的响应提出了额外的挑战。
通过 JavaScript 在执行态集成
我们描述的下一种方法可能是最灵活,也是最常见的一个。每个微前端都使用
<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 包只在需要的时候加载,或者在渲染微前端时输入和输出数据。
因着此方法的灵活性和可独立部署性,它是我们的默认选项,也是最常见的选项。在完整的示例中,我们将探讨更多细节。
通过 Web 组件在执行态集成
前一方法的一种变体是,为每个微前端定义一个 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 作为一种全局的、继承的和级联的语言,传统上没有模块系统、命名空间或封装。其中一些特性目前确实已经存在,但是浏览器经常缺乏支持。在一个微前端中,这些问题更突出了。例如,如果某个微前端有样式表 h2 { color: black; },另一个定义了 h2 { color: blue; },并且两个选择器都被加载到了一个页面中,那么有人就要失望了!这不是一个新问题,但是当不同的团队在不同时候编写时就变得更糟糕了,而且代码很可能被分开存储,这就更难以发现了。
多年来,人们发明了很多方法来使得 CSS 更容易管理。有些人选择使用严格的命名约定,如BEM,以确保选择器只应用于预期的地方。其他的则提倡不仅仅依赖开发人员的自我约束,使用类似 SASS 的预处理器,其选择器的嵌套可以当作命名空间。一种较新的方法是,以编程的方式应用所有的 CSS 样式模块或使用 CSS-in-JS 库,这可以确保样式只应用于开发人员预期的地方。或者一个更基于平台的方法,shadow DMS 还提供了样式隔离。
您采用什么方法并不重要,只要您找到一种确保开发人员能彼此独立编写他们的样式,并且他们有信息代码组合到一个应用后达到预期效果即可。
共享组件库
我们有前面提到,所有微前端的视觉保持一致是重要的,一个方法是开发一个库共享,复用 UI 组件。通常我们认为这是个好主意,尽管这很难做好。创建一个这样的库的主要好处是,即能因复用代码减少工作量,又能保持视觉一致性。此外,您的组件库可以做为日常样式指南,而且它还可以作为开发人员和设计师的重要协作点。
最易犯的错误是在一开始开发创建过多的这样组件。创建一个具备所有应用所需的通用视觉效果基础框架是诱人的。然后,经验告诉我们那是困难的,如果在您有真实需求之前就定义出需要什么样的组件 API, 那么结果会是大量的前期投入白费。基于此,我们更喜欢让团队在他们自己的代码库里开发自己需要的组件,即使最初会出现代码复制。允许样品自然的出现,一旦组件的 API 变得明确,您就可以复制代码到共享库中,并确信已经得到验证。
大部分被共享的候选人是“愚蠢的”视觉还原,例如图标、标签和按钮。我们也可以分享更复杂的组件,其中可能包含大量 UI 逻辑,比如智能提示搜索框。或者可排序、可过滤、可分页的表格。然后,谨慎确认您分享的组件仅包含 UI 逻辑,而没有业务或领域逻辑。当领域逻辑被加入到共享库中时,它会产生跨应用的高度耦合,而且也增加了变更的难度。所以,您不会尝试共享一个产品表,其中包含了所有该产品的定义和行为。这种领域建模和业务逻辑属于微前端内的应用代码,而非共享库。
亦如所有内部共享库,它的所有权和管理存在一些棘手问题。一种模式是将它作为共享资产,属于“每个人”,尽管通常意味着不属于任何人。它很快会变成一堆杂乱无章不一致的代码,没有明确约定和技术愿景。另一个极端是,如果共享库的开发是完全集中化的,那么创建组件的人和使用组件的人之间是脱节的。我们所见过的更好的模式是,任何人可以做为库的贡献者,但有一个管理员(一个人或一个团队),由他确保贡献内容的质量、一致性和有效性。维护共享库的工作需要强大的技术能力,同时也需要培养多团队合作的协调能力。
跨应用通信
微前端最常见的问题之一是,如何让他们之间实现通信。通常,我们建议尽可能不通信,因为它经常会重新引入我们最初试图避免的不合适的耦合。
也就是说,一定级别的跨应用通信是需要的。自定义事件允许微前端直接通信,这是个把耦合降到最低的好方法,尽管它确实更难明确执行微前端之间的约定。或者,通过回调和数据向下传递的 React 模型(这里指从容器应用向微前端传递)是一种能使约定更加明确的解决方案。第三种选择是使用地址栏作为通信机制,后面我们将进一步讨论。
无论我们选择哪一种,我们希望我们的微前端通过信息或事件进行通信,而避免共享任何状态。如同在微服务中共享数据库,一旦我们共享数据结构和领域模型,就造成了很多耦合,然后变更就变得极其困难。
同样式一样,有几种不同的方法都可正常工作。最重要的事情是要长时间认真的思考您会引入何种耦合,以及如何长时间维持约定。就像微服务间的集成,您不能对您跨应用和团队的集成方在没有协调一致的情况下产生变更升级。
您也需要思考,如何自动验证集成没有被破坏。功能测试是一种方法,但我们更喜欢限制一定数量的功能测试,因为实现和维护他们需要一定成本。或者您可以实现某种消费者驱动契约,如此每个微前端都可以指定要求其他微前端,而无需实际集成并在浏览器上运行他们。
后端通信
如果我们在前端应用拆分团队独立工作,那么后端开发如何呢?我们相信全栈团队的价值,他们负责应用的开发,从视觉代码一直到 API 开发,到数据库,再到基础设施代码。BFF 模式在此处有用,每个前端应用都有相应的后端提供对服务。虽然 BFF 模式可能最初意味着每个后端专注于一个前端频道(Web、手机等),他能简单的被扩展成一个后端服务于一个前端。
这里有很多的变量需要解释。BFF 可能包含了自身的业务逻辑和数据库,也可能只是下游服务的聚合器。如果是下游服务,每个团队拥有自己的微前端和 BFF 及其他一些服务,可能有意义,也可能没意义。如果微前端只要一个 API,且这个 API 相当稳定,那么构建 BFF 就没什么价值。这里的指导原则是,构建特定的微前端,不需要等待其他团队为他们构建什么。如果每个新功能的增加,都需要服务端相应变更,那么采用归属于统一团队的 BFF 方案是很好的选择。
图 7: 这里有多种组建前端/后端关系的方法。
另一个常见的问题是,微前端应用的用户如何通过服务进行认证和授权?显然我们的客户只需身份认证一次,那么权限通常属于跨领域问题的范畴,故归属于容器应用。容器大概有某种登录表单,据此我们可以获得某种令牌。该令牌归属于容器,然后可以在初始化时注入到每个微前端中。最后,微前端可以随着请求发送令牌到服务端,然后服务就可以对请求做任何验证。
测试
在测试方面,整体的前端和微前端我们没有看到太多区别。一般来说,您在整体前端上采用任何测试策略, 都可以复制到每个独立的微前端上。也就是说,每个微前端应该专属于它的自动化测试套件,以确保代码的质量和正确性。
明显的差异是在各个微前端和容器应用之间的集成测试。这可以适用您首选的功能/端到端测试工具(例如 Selenium 或 Cypress),但不要走得太远;功能测试只能覆盖金字塔测试中低层次测试不能覆盖的方面。我们的意思是,使用单元测试覆盖低层次业务和渲染逻辑,然后使用功能测试去验证页面是否被正确组装。例如,您可以使用特定 URL 加载集成以后的应用,然后断言相关微前端硬编码的标题出现在了页面上。
如果存在跨越微前端的用户旅程,那么您可以使用功能测试覆盖他们,但请保持功能测试专注在验证微前端间的集成上,而不是微前端的内部业务逻辑,这应该由单元测试覆盖。如上所述,消费者驱动协议能帮助直接指定交互作用,而没有集成环境和功能测试的缺陷。
详细示例
文章剩余的大部分内容将详细介绍我们示例应用的一种实现方式。我们将绝大部分的关注放在,如何用 JavaScript 将容器应用和微前端集成在一起,这可能是最有趣和复杂的部分。您可以在 demo.microfrontends.com 看到最后的部署效果,完整的源码在 Github 上。
图 8: 完整的微前端演示应用的“浏览”首页
这个演示是由 React.js 构建的,所以值得一提的是,React 在这里不是唯一的架构。微前端可以由许多不同的工具或框架来实现。我们这里选择 React 是因为它受欢迎,以及我对它熟悉。
容器
我们将从容器开始,因为它是我们客户的入口。让我们看看,从它的 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-script 版本1中,它可以让多个应用共处而没有冲突,但是版本2使用了一些 webpack 功能,当两个或两个以上的应用尝试在一个页面上同时渲染时,会产生冲突错误。基于此我们使用 react-app-rewired 来覆盖一些 react-scripts 的内部 webpack 配置。它修复了这些问题,并让我们继续依赖 react-scripts 来为我们管理构建工具。
从 react 和 react-scripts 的依赖上,我们得出结论这是个有 create-react-app 创建的 React.js 应用。更有趣的内容不在那里:我们将用提到的微前端组成最终的应用。如果我们要求像依赖库一样依赖他们,我们将走上构建继承的道路,正如前面提到的,会在发布循环中存在有问题的耦合。
要了解我们如何选择显示微前端,请看 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 组件。除了 history 对象(后面将很重要),我们指定唯一的 name 和 可以下载包的 host。这个 URL 驱动配置,将实现类似在本地运行时的 URL 为 http://localhost:3001,而生产环境为 browse.demo.microfrontends.com。
在 App.js 中选择了一个微前端之后,现在我们将在另一个 React 组件 MicroFrontend.js 中渲染:
class MicroFrontend extends React.Component {
render() {
return <main id={`${this.props.name}-container`} />;
}
}
这不是一个完整的类,我们很快将看到更多它的方法。
在渲染时,我们所做的就是在页面上为微前端放置一个带有唯一 ID 的容器元素。这是我们告诉微前端渲染自己的地方。我们使用 React 的 componentDidMount 作为下载和安装微前端的触发器:
class MicroFrontend…
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 的相关脚本是否已经下载,如果已经下载只要立即渲染它即可。如果没有,我们将从 host 获取 asset-manifest.json 文件,以便找到 main 脚本资源的完整 URL 。一旦我们设置了 script 的URL,剩下的就是将它加到文档中,然后在 onload 事件中渲染微前端。
class MicroFrontend…
renderMicroFrontend = () => {
const { name, history } = this.props;
window[`render${name}`](`${name}-container`, history);
// E.g.: window.renderBrowse('browse-container', history);
};
在上面的代码中,我们调用了一个类似 window.renderBrowse 的全局函数,它是由我们刚刚下载的脚本注入的。我们给
元素传递 ID 和 history 对象,这是微前端渲染自己的地方,我们很快将介绍。**全局函数的命名格式是容器应用和微前端之间的关键约定。**这是通信和集成都需要用到的,所以请保持轻量易维护,以及易于追加新的微前端。每当想要变更代码时,我们应该长期认真的思考这对代码库的耦合和约定的维持意味着什么。清理工作是最后一件事。当卸载 MicroFrontend (从 DOM 中移除)时,我们也希望卸载相关的微前端。为此,每个微前端都定义了相应的全局函数,我们在 React 合适的生命周期中调用它。
class MicroFrontend…
componentWillUnmount() {
const { name } = this.props;
window[`unmount${name}`](`${name}-container`);
}
就容器本身的内容里,直接渲染的是站点最顶层的头部和导航条,这些都是在所有页面中重复出现的。这些元素的 CSS 需要写得比较谨慎,以确保只作用于头部的元素,它不应该和微前端内的样式代码产生冲突。
容器应用到此就结束了!它是相当基本的,但它给了我们一个能在运行态动态加载微前端,并将他们整合到一个页面上的外框架。这些微前端可以独立部署到生产环境,无需对其他微前端或容器做变更。
微前端
合乎逻辑继续这个故事的地方是我们持续引用的全局渲染函数。我们应用的首页是餐厅的筛选列表,其入口代码示意如下:
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 元素中进行渲染。对于这个应用,我们需要控制渲染的时间和地点,所以我们将其包裹在一个接收元素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>
_图 9: 每个微前端可以作为脱离容器的单一应用。
从这点开始,微前端大多只是普通的原先的 React 应用。“browser”应用从后端获取餐厅列表,提供用于搜索和筛选餐厅的 元素,渲染 React 路由 元素导航到指定餐厅。在那时,我们切换到第二个“order”微前端,它渲染含有菜单的单个餐厅信息。
_图 10: 这些微前端通过路由切换,而不是直接的
我们的微前端最后值得一提的是,他们的样式都使用了样式组件。这个 CSS-in-JS 库使得特定组件内和样式关联变得容易,所以我们可以保证微前端的样式不会影响容器或其他微前端。
通过路由的跨应用通信
我们在前面提到过,跨应用之间的通信应保持在最低限度。在这个示例中,我们唯一的要求是在浏览页面需要告诉餐厅页面加载哪个餐厅。在这里,我们将看到我们如何使用客户端路由解决这个问题。
这里涉及的三个 React 应用都使用了 React Router 进行声明性路由,但在初始化上略有不同。作为容器应用,我们创建一个 ,它将在内容实例化一个 history 对象。这跟我们前面忽略的是同一个 history 对象。我们使用这个对象处理客户端的历史记录,我们也可以使用它将多个 React Routers 连接到一起。在我们的微前端中,我们初始化路由如下:
<Router history={this.props.history}>
在这个示例中,我们没有让 React Router 另外实例一个 history 对象,而是直接通过容器应用提供了。所有 实例已经连接,当路由被触发都会映射到实例上。这给了我们一个在微前端之间通过 URL 传递“参数”的方法。例如在 browse 微前端,我们有一个 如下 link:
<Link to={`/restaurant/${restaurant.id}`}>
当这个 link 被点击,在容器上路由将被更新,新的 URL 表明 restaurant 微前端应该被安装并渲染。然后微前端的路由逻辑将从 URL 中提取餐厅 ID,并渲染正确的信息。
希望此示例有展示简单 URL 的灵活和强大。它除了对分享和书签有用,在这个特定架构中,还对跨微前端通信意图有用。使用页面 URL 有很多好处:
- 它的结构是定义明确、开放的标准
- 对于页面代码,它是全局可访问的
- 它的有限大小鼓励只发送少量数据
- 它面向用户,鼓励使用真实的领域模型结构
- 他是声明性的,不是命令式的。例如是“这是我们所在地”,而不是“请做这件事”。
- 它促使微前端间接通信,而彼此之间无需了解或依赖。
当使用路由作为微前端通信模式,我们选择的路由构成一个契约。在这种情况下,我们已经明确一个餐厅可以通过 /restaurant/:restaurantId 被查看,在没有更新所有依赖它的应用之前,我们不能变更路由。鉴于此约定的重要性,我们需要一个自动化测试来检查约定是否被遵守。
通用内容
虽然我们希望我们的团队和我们的微前端尽可能独立,但有些事是通用的。我们之前写过关于共享组件库如何帮助微前端保持一致性,但对于这个小演示,使用一个组件库就太过了。因此作为替代,我们有一个小的通用内容的存储库,包含图片、JSON 数据 和 CSS,他们通过网络服务所有微前端。
还有另一件事,我们可以选择跨微前端共享:库的依赖关系。正如我们将很快描述的,重复依赖是微前端常见的缺点。尽管在跨库应用之间共享依赖有他自身的问题,但在这个演示应用中,值得讨论如何实现它。
第一步是选择要共享的依赖内容。对我们编译代码的一个快速分析表明,大约50%的包由 react 和 react-dom 贡献。除了他们的大小,这两个库也是我们最“核心”的依赖,所以我们知道所有微前端可以从提取它们中获益。最后,这些是稳定的、成熟的库,他们通常在两个大版本间引入不兼容变更,因此跨应用升级工作不会太困难。
至于实际的提取,所有我们需要做的是在我们的 webpack 中将库作为 externals 配置,我们可以用前面描述过的类似重新连线的方式完成它。
module.exports = (config, env) => {
config.externals = {
react: 'React',
'react-dom': 'ReactDOM'
}
return config;
};
然后我们向每个 index.html 页面增加一对 script 标签,从共享内容服务中获取两个库。
<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>
跨团队共享代码一直都是件很难做好的事情。我们需要确保只共享了我们希望被共用,并且希望一次性完成多个地方同时变更的内容。然而,如果我们小心于分享什么和不分享什么,那么就会真正获益良多。
基础设施
该应用托管于 AWS,具有核心基础设施(S3 存储、CloudFront 分发、域名、证书等等),并使用 Terraform 代码的集中存储库一次性实现预配置。每个微前端现在已经有用了自己的源存储库,在 Travis CI 上有连续部署管道,用于构建、测试和部署它的静态资源到它的 S3 存储中。这平衡了集中化基础设置管理的便利性和独立部署能力的灵活性。
请注意每个微前端(和容器)拥有自己的存储。这意味着她们可以自由控制其中的内容,并且我们不需要担心来自其他团队或应用的对象名冲突或者管理规则冲突。
缺点
本文的开头,我们提到需要权衡微前端,就像权衡其他框架一样。我们提及的好处确实需要成本,我们将在这里讲述。
有效负载大小
独立构建的 JavaScript 包导致重复公共依赖,增加通过网络发送给最终用户的字节数。例如,如果每个微前端包都包含 React,那么我们迫使我们的客户下载 React n 次。页面性能和用户的参与/转换有直接关系,世界上有很多地方基础设备的速度要比发达城市的慢得多,所以我们有很多理由关心下载内容的大小。
这个问题不容易解决。我们希望团队独立编译他们的应用,以便他们能够自主工作,和我们希望能在构建他们的应用时共享公共依赖之间存在紧张关系。一种方法是从我们的编译包中外部化共用依赖,就像我们在演示应用中表述的。一旦我们走上这条路,我们就让微前端重新处于编译态耦合中。现在他们有一个隐性约定,“我们必须都使用这些依赖关系的特定版本”。如果在依赖中存在破坏性依赖,我们最终可能需要一个大的协调升级和一个一次性锁定步骤的发布事件。这就是最初我们试图使用微前端避免的问题。
解决这种固有的矛盾是困难的,但是它也不总是坏的。首先,即使我们对重复依赖不做任何事情,每个单独的页面也可能依赖比单个巨石应用的加载要快。原始是独立的编译每个页面,我们有效的实现了代码的分割。在经典的巨石中,当加载应用中的任何页面时,我们经常会一次性加载每个页面的所有源码和依赖。通过独立构建,每个单页面加载只会下载该页面的源码和依赖。这个结果就是更快的初始页面加载,但后续的导航会更慢,因为用户被迫在每个页面重新加载同样的依赖项。如果我们遵守在微前端不使用不必要的依赖项,或者如果知道用户通常忠于应用中的一两个页面,我们可能即使有重复依赖,仍能在性能这项受益。
在前一段中有多个“可能的”和“或许的”,它们强调了每个应用总是有自己的独特的性能特性的事实。如果你希望知道如何确认特定的变更是否对性能产生影响,这里没有可以替代在真实的生产环境中测量的方法。我们曾看到过团队为 JavaScript 额外的几 KB 痛苦,而不在意下载很多兆字节的高分辨率图片,或者在很慢的数据库上执行昂贵的查询。因此,虽然性能对架构的决策很重要,但请确保您知道真正的瓶颈在哪里。
环境差异
我们应该能够开发一个独立的微前端,不需要思考任何被其他团队开发的其他微前端。我们甚至可以在“独立”模式下将微前端运行在空白页面上,而不是在生产环境中的容器应用里。这可以使开发变得更简单,尤其是当真正的容器是一个复杂的遗留代码库时,这样的场景经常出现在我们使用微前端将老环境向新环境迁移的时候。然而,在与生产环境完全不相同的开发环境中开发是存在风险的。如果我们的开发态容器行为不同于生产环境,那么我们可能在部署到生产环境时发现,我们的微前端被破坏或者行为有差异。特别令人关注的是,全局样式可能单独被容器或其他微前端引入。
这里的解决方案与其他任何我们担心环境差异的解决方案没有什么不同。如果我们已经在与生产环境不同的本地环境进行开发,我们需要确保我们定期集成和发布我们的微前端到像生产的环境中,并且我们需要测试(手动和自动)在这些环境中,以便尽早解决集成问题。这不能彻底解决问题,但是最后它是另一个必须权衡的问题:简单开发环境的效率提升是否值得冒集成问题的风险?答案将取决于这个项目!
运营和治理的复杂性
最后一个缺点是与微服务直线平行。作为一种更加分布式的架构,微前端将不可避免的需要更多东西来管理——更多的存储库、更多的工具、更多的构建/发布管道、更多的服务、更多的域名等等。所以正式接受这个架构之前,您需要考虑一下几个问题:
- 您是否有足够的自动化来提供和管理额外需要的基础设施?
- 您的前端开发、测试和发布过程是否可以扩展到多个应用中?
- 您是否准备好适应从熟悉的工具和开发模式转变成更分散的和难以控制的状态?
- 如何确保多个独立前端代码库的质保线、一致性或者治理?
我们可能可以另开一篇文章来填补讨论这些话题。我们想要提出的主要观点是,当您选择微前端,您也就选择了创造很多小东西,而不是一个大东西。您需要考虑您是否在技术和组织上具备采取这种方法的能力,而不会造成混乱。
研究讨论
随着前端代码库持续变得复杂,我们看到对更可扩展架构的需求越来越大。我们需要能划定明确的边界,在技术和领域实体之间创建适当的耦合程度和凝聚力。我们应该能够通过跨独立自主团队扩展软件交付能力。
虽然远不是唯一的方法,我们已经看到很多微前端提供这些好处的真实示例,并且我们已经能够逐渐将此技术应用到改造老代码库,不局限于全新的应用。无论微前端对您和您的组织来说是否是正确的方法,我们只希望前端引擎和架构是被认真严肃对待是趋势的一部分。