技术渣的碎片 2019-06-20
想做好前端很难,做出可扩展的前端,从而让多个团队可以同时投身于一项复杂的大型产品项目就更难了。本文将介绍前端领域最近的一项变革:单体前端架构正在过渡到许多较小、较易管理的前端架构。我们还会展示这种新的体系结构怎样提升前端团队的效率和表现。除了讨论这种新趋势的好处与代价外,我们还将介绍一些可行的实现方案,并深入分析一个完整的微前端应用案例。
微服务近年来大受欢迎,许多组织转向了微服务以克服大型单体后端架构的局限。但虽然微服务在服务端很流行,很多企业在前端代码库上仍然在沿用问题多多的单体架构。
也许你想构建一个渐进式或响应式的 Web 应用,但却找不到一种将这些功能集成进现有代码中的简单途径;也许你想尝试 JavaScript 语言的新功能(或者是其他可以编译为 JS 的某种语言),但你却无法将关键的构建工具融入已有的构建流程;或者你只是想扩展开发流程,让多个团队可以同时开发一种产品,但现有单体架构中的耦合度与复杂性让团队间的合作变得磕磕绊绊。这些都是很现实的问题,都会影响你们向客户交付高质量体验的能力。
微前端的定义
最近业界越来越关注复杂的现代化 Web 开发需要怎样的整体架构和组织结构这个问题。于是我们开始看到单体前端正在分解为更小、更简单的模块,这些模块可以各自独立开发、测试和部署,而它们组合在一起仍然对客户表现为一件单一完整的产品。我们将这种技术称为 微前端,其定义为:
“微前端是一种架构风格,其中众多独立交付的前端应用组合成一个大型整体。”
我们认为微前端的主要好处有:
这些核心优势与微服务的优势基本一致,这也不是什么巧合。
当然,软件架构领域没有免费的午餐:一切都要付出代价。一些微前端实现可能导致重复依赖,使用户不得不下载更多内容。此外,大幅提升的团队自治水平可能会让各个团队的工作愈加分裂。只不过我们认为这些风险都能控制在合理水平上,微前端终究还是利大于弊的。
好处
我们不会从具体的技术方法或实施细节角度来定义微前端,而是重点关注它的属性和好处。
增量升级
对于许多组织来说,追求增量升级就是他们迈向微前端的第一步。对他们来说,老式的大型单体前端要么是用老旧的技术栈打造的,要么就充斥着匆忙写成的代码,已经到了该重写整个前端的时候了。一次性重写整个系统风险很大,我们更倾向一点一点换掉老的应用,同时在不受单体架构拖累的前提下为客户不断提供新功能。
为了做到这一点,解决方案往往就是微前端架构了。一旦某个团队掌握了在几乎不影响旧世界的同时为生产环境引入新功能的诀窍,其他团队就会纷纷效仿。现有代码仍然需要继续维护下去,但在某些情况下还要继续添加新功能,现在总算有了解决方案。
到最后,我们就能更随心所欲地改动产品的各个部分,并逐渐升级我们的架构、依赖关系和用户体验。当主框架发生重大变化时每个微前端模块都可以按需升级,不需要整体下线或一次性升级所有内容。如果我们想要尝试新的技术或互动模式,也能在隔离度更好的环境下做试验。
简洁、解耦的代码库
微前端体系下,每个小模块的代码库要比一个单体前端的代码库小很多。对开发者来说这些较小的代码库处理起来更简单方便。而且微前端还能避免无关组件之间不必要的耦合,让代码更简洁。我们可以在应用的限界上下文(详见下方链接)处划出更明显的界限,更好地避免无意间造成的这类耦合问题。
当然,只靠架构更迭本身(比如说“我们改成微前端吧”)并不能自动为以往的优质代码生成替代品。我们要做的是设法让糟糕的决策难以露头,而让正确的决策畅通无阻,从而进入迈向成功的良性循环。例如,现在很难跨越限界上下文共享域模型,所以开发者就不太可能这样做了。类似地,微前端会让开发者更审慎地把握数据和事件在应用的各个部分之间流动的方式,其实就算没有微前端我们本来也应该这样做的!
独立部署
就像微服务一样,微前端的一大优势就是可独立部署的能力。这种能力会缩减每次部署涉及的范围,从而降低了风险。不管你的前端代码是在哪里托管,怎样托管,各个微前端都应该有自己的持续交付管道;这些管道可以将微前端构建、测试并部署到生产环境中。我们在部署各个微前端时几乎不用考虑其他代码库或管道的状态;就算旧的单体架构采用了固定、手动的按季发布周期,或者隔壁的团队在他们的主分支里塞进了一个半成品或失败的功能,也不影响我们的工作。如果某个微前端已准备好投入生产,那么它就能顺利变为产品,且这一过程完全由开发和维护它的团队主导。
自治团队
解藕代码库、分离发布周期还能带来一个高层次的好处,那就是大幅提升团队的独立性;一支独立的团队可以自主完成从产品构思到最终发布的完整流程,有足够的能力独立向客户交付价值,从而可以更快、更高效地工作。为了实现这一目标需要围绕垂直业务功能,而非技术功能来打造团队。一种简单的方法是根据最终用户将看到的内容来划分产品模块,让每个微前端都封装应用的某个页面,并分配给一个团队完整负责。相比围绕技术或“横向”问题(如样式、表单或验证)打造的团队相比,这种团队能有更高的凝聚力。
小结
简而言之,微前端是将庞大复杂的整体分割为更小、更易于管理的模块,然后明确它们之间的依赖关系。我们的技术决策、代码库、团队和发布流程都应该彼此独立,无需过多协调工作就能自主运行并发展。
案例
假设要做一个食品外卖的网站。乍一看这种网站好像很好做,但想要做好需要在诸多细节上下足功夫:
每个页面都非常复杂,都应该分配一个专门团队来负责,并且每个团队都应该有足够的独立性。各个团队都应该能独立开发、测试、部署和维护自己的代码,而不会与其他团队发生冲突或需要其他团队配合。但在客户这里,整个网站仍然应该是一个无缝的整体。
下面我们就会围绕这个案例来展示代码与场景示例。
集成方法
前文对微前端的定义相当松散,所以有很多方法都可以划入这个范畴。本节将展示一些示例并讨论它们的优劣。这些方法在架构上有共通之处——通常应用中的每个页面都有一个微前端,还有一个 容器应用,它有以下功能:
服务器端模板组合
先来介绍一种非常新颖的前端开发方法——就是在服务器上使用多个模板或片段呈现 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 服务器上呈现,实际上它们都有自己的部署管道,改动某个页面并不会影响其他内容。
想要更高独立性的话,可以为每个微前端单独安排一个服务器负责呈现和服务,再安排一个服务器专门向其他服务器发出请求。如果能缓存好各个响应就不会增大延迟。
这个例子说明微前端不一定是一种新技术,也不一定很复杂。只要我们的设计决策能为代码库和团队赋予更多自主权,那么不管怎样的技术栈都能为我们带来类似的收益。
构建时集成
还有一种方法是将每个微前端作为一个包来发布,并让容器应用将它们全部作为库依赖包含进去。下面展示了容器的 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>
就像前文提到的服务器端包含方法一样,用 iframe 构建页面并不是一种激动人心的新技术。但只要我们能精心分割好应用并组建好团队,那么用 iframe 就能实现前面提到的一系列好处。
很多人不喜欢 iframe,它也的确有一些缺陷。上面提到的简单隔离方式确实降低了它的灵活性。用 iframe 在应用的各个部分之间构建集成可能会很困难,从而让路由、历史记录和深层链接变得更加复杂;它还会影响页面的响应速度。
通过 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 包,或者在呈现微前端时传递出入数据。
这种方法同时具备灵活性与独立可部署能力,是我们的首选方案。后文将详细探讨这个方法。
通过 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 本质上是一种全局、继承和级联的语言,传统 CSS 也没有模块系统、命名空间或封装;有些功能现在也可用了,但往往缺乏浏览器支持。在微前端领域,这些问题往往变得更为严重。例如,如果一个团队的微前端有一个样式表,上面写着 h2 { color: black; },另一个微前端的样式表却写着 h2 { color: blue; },并且这两个选择器都附加到了同一个页面,后果就很严重了!这类问题古已有之,但在微前端体系中由于这些选择器是由不同团队在不同时间编写的,而且代码可能分散在不同的存储库中,所以就更难发现了。
多年来业界发明了很多方法来更好地管理 CSS。有些人会使用严格的命名约定(例如 BEM 规范:http://getbem.com/)以确保选择器只在正确的位置起作用。还有人会使用 SASS(https://sass-lang.com/)之类的预处理器,其嵌套的选择器可以用作一种命名空间。一种较新的方法是使用 CSS 模块或某种 CSS-in-JS 库(详见下方链接),以编程方式应用所有样式,确保样式只会直接应用在开发者想要的位置上。此外还有 shadow DOM(详见下方链接),它也提供样式隔离。
具体用哪种方法并不重要,只要让开发者可以独立编写样式,然后各个样式集成到同一个应用中时不起冲突就行了。
共享组件库
前文提到微前端的视觉一致性是很重要的。一种实现方法是开发一个共享的、可重用的 UI 组件库。创建这样一个库的主要好处是通过重用代码和来减少工作量,同时实现视觉一致性。此外,这个组件库可以当作样式指南来用,它可以是开发者和设计人员之间的一个很好的协作桥梁。
但最容易犯的一个错误就是过早地搞出来一大堆组件。我们都想创建一个基础框架,其中包含所有应用所需的所有常见视觉效果。但其实我们很难提前判断组件应该用什么 API,结果很多组件都是在白费功夫。所以我们更愿意让团队在需要的时候再去创建自己的组件,就算因此产生了一些重复工作也没关系。应该顺其自然,等组件的 API 都确定下来以后再把重复代码收集到共享库里,这样就不会徒劳无功了。
最常见的共享组件是“无声”的视觉基本元素,如图标、标签和按钮等。我们还可以共享可能包含大量 UI 逻辑的复杂组件,例如自动完成、下拉搜索字段等;或者是可排序、可过滤的分页表。但是,请确保共享组件仅包含 UI 逻辑,而不包含业务或域逻辑。将域逻辑放入共享库会给应用之间带来高度耦合,改动起来也更困难。例如,一般来说不该共享 ProductTable,因为它会包含关于“产品”的定义及表现的各种内容。这种域建模和业务逻辑应该属于微前端的应用代码,不应该放到共享库里。
作为一种内部共享库来说,它的所有权和管理也自然存在一些棘手的问题。一种管理模式是将其视为共享资产,让“每个人”都拥有它——实践中这通常意味着没人能真正拥有它。这个共享库很快就会变成一堆不一致的代码集合,也没有明确的约定或技术愿景。反过来说,如果共享库的开发工作完全中心化,那么组件的创建者与使用者之间就会严重脱节。最好的模式应该是允许所有人为库做贡献,但要有一个保管人(一个人或一个团队)负责确保这些贡献的质量、一致性和有效性。维护共享库需要强大的技术技能,还需要能协调众多团队的管理技能。
跨应用通信
关于微前端最常见的一个问题是如何让这些微前端互相通信。一般来说,我们建议尽可能减少这类通信需求,因为它通常会重新引入我们本想避免的耦合度。
换句话说,某种程度的跨应用通信是必要的。自定义事件(详见下方链接)允许微前端之间间接通信,从而尽量减少直接耦合;但它也会让各个微前端之间已有的合约更难确定和增强。另一种方法是将回调和数据向下传递的 React 模型(这里是从容器应用向下传递到微前端),它能让模块之间的合约更加明确。第三种方法是使用地址栏作为通信机制,我们将在后面详细介绍。
如果你在使用 redux,常见方案是为整个应用提供单个全局共享存储。但如果每个微前端都是自包含的应用,那么它们都应该有自己的 redux 存储。Redux 文档甚至给出了“在大型应用中将 Redux 应用隔离为组件”的说明。
无论选择哪种方法,我们都希望微前端可以彼此发送消息或事件来通信,同时避免任何共享状态。就像在微服务之间共享数据库一样,一旦我们共享了数据结构和域模型就会引入大量的耦合,改动起来也会更困难。
这里也有几种不错的方案选项。最重要的是要时刻考虑你正在引入怎样的耦合,以及如何持续维持模块之间的合约。就像微服务之间的集成一样,你需要在不同应用和团队之间协调升级流程,才能对集成做出重大改动。
你还应该考虑如何自动验证集成的工作状态。一种方法是功能测试,但它们的实施和维护成本很高。或者你可以实现某种形式的消费者驱动合约(详见下方链接),这样一来,无需在浏览器中集成全部微前端并运行应用,就能让每个微前端确定其他微前端需要哪些内容。
后端通信
前端应用开发倒是分配给各个独立团队了,可后端呢?这里就是全栈团队的价值所在了,他们从可视代码到 API 开发及数据库和基础架构代码都能自己搞定。有一种不错的模式叫 BFF 模式,其中每个前端应用都有一个对应的后端,后者只用来满足前者的需求。
这里有很多变量需要考虑。BFF 可能是自包含的,具有自己的业务逻辑和数据库,或者它可能只是下游服务的聚合器。负责微前端及其 BFF 的团队是否应该负责一部分下游服务也是个问题。如果微前端只有一个与之对话的 API,并且该 API 相当稳定,那么可能就不用构建 BFF 了。这里的指导原则是,构建某个微前端的团队不应该依赖其他团队为他们构建内容。因此,如果每个添加到微前端的新功能都需要改动后端,那么让同一个团队负责 BFF 就很合适了。
另一个常见问题是,如何通过服务器对微前端应用的用户进行身份验证和授权操作?显然,客户应该只需进行一次身份验证过程,因此身份验证往往是跨领域问题,应该由容器应用负责。容器可能有某种登录形式,我们通过它获得某种令牌。该令牌将由容器控制,并且可以在初始化时注入各个微前端。最后,微前端可以把令牌及其发送的请求发给服务器,而服务器可以按需完成验证操作。
测试
在测试方面,单体前端和微前端之间没有太大区别。一般来说在单体前端上使用的测试策略都能用在各个微前端上;也就是说每个微前端都应该有自己的自动化全面测试套件,以确保代码的质量和正确性。