继之间关于微前端的文章之后(1和2), 现在是时候来谈谈如何去编排微前端。
首先也是最重要的,有两种思考微前端应该是怎么样的思想流派,正如在之前的文章中所阐述的关于微前端的不同实现,有些实现里面,一个微前端对应了用户界面的一部分区域,也有的实现方式中,微前端就是一个SPA或者一个单独的页面。
当我们基于应用的不同逻辑区域(比如header,footer,支付表单等等)来考虑微前端的实现的时候,我们将面临不同的挑战:
哪个团队将负责组合聚合后的视图?
我们怎么样能做到每个团队对外部的依赖?
哪个团队应当为聚合视图的某个issue负责?
我们怎么保证应用的一个特定区域,不与父容器紧密耦合?
我们怎么保证依赖之间没有冲突? 我们在运行时还是编译时进行聚合?
如果我们决定在运行时创建页面,我们的应用服务器层是否可扩展?
内容是否可缓存,可以缓存多久?
我们如何保证开发流程不因为团队过于分散而受到影响?
以上以及许多其他问题(技术上或组织上的),可以让我们的生活复杂得多。 有趣的是,这种方式在扩展性方面没有为Spotify提供期望中的收益, 他们回退到一种基于SPA的、更多“传统的”架构上去了。
为了有利于这篇文章进行更清晰的阐述,我们先定义一下我们的微前端:使用SPA或单独的页面,在编译期生成,以避免在组合层可能发生的任何可能与预想不符之处。
然而,这种方式也有一些挑战,可能最主要的就是理解我们要将怎么去编排微前端,这也是我们这篇文章的焦点。
编排层可以存在于客户端、服务器端或者边缘端(edge-side,译者注)。最后的解决方案取决于编排层对于我们的应用来说是否够“智能”。
服务器端/边缘端的编排
一个服务器端或边缘端的编排,意味着任何访问我们的域名的深层链接或者自然流量,都会被应用服务器或者边缘结案方案(例如lambda@edge)所分析。在这两种形式下,我们需要去维护URL和对应的静态HTML文件(也包括微前端)之间的映射。
举个例子,如果一个用户从我们的应用中登出,我们应该卸载已经鉴权通过的微前端,并加载登录/注册的微前端,因此应用服务器或在边缘上运行的代码应该知道应该为每一个URL提供哪个HTML文件,或者当我们使用SPA时,应该知道该SPA对应的一组URL.
鉴于我们能够在对客户端没有任何影响的情况下,在服务器上迅速修改微前端与URL的映射,这种方法使用起来没有任何问题。但是现在有一些潜在的挑战,例如考虑到在浏览器内存储上有一些限制,如何在多个微前端之间共享数据;并且在服务器太多次来回传递数据,尤其是当网络连接慢的情况下,并不是一种理想的方案。
另一个挑战是找到初始化应用的解决方案,鉴于我们通过微前端,将一个庞然大物般的应用拆分成了多个子域,我们是否要在每一次一个新的微前端加载成功时都要去初始化应用?我们是否要用服务端渲染在HTML页面中储存配置信息?我们怎么在多个微前端之间通讯?我们怎么去突发流量的场景下扩容我们的应用服务器?
以上这些是实现一个服务端或边缘端时,会遇到的一些挑战。
客户端编排
另一种可能的途径可以是创建一个客户端的编排,它负责:
- 初始化应用
- 将应用的配置信息共享给所有的微前端
- 根据用户状态去加载/卸载单个微前端
- 在微前端之间进行路由导航
- 暴露一个用于在微前端和客户端编排之间交互的API
这种解决方案的优点之一是:你对于应用初始化的过程会有更多的掌控。
如果有良好的设计,客户端的编排不需要经常变动,因此,它将会是非常的稳定。
它提供了额外的功能,可以被各种微前端使用,但它不是领域特定的。如果我们的目标是将微前端从它们运行的平台上抽象出来(例如浏览器,而不是移动设备或智能电视的时候),这也会是一种很好的解决方案。
这种解决方案的主要缺点是需要识别哪些功能应该由编排来提供,因为这里可能潜藏着巨大的风险。这一层的bug可以摧毁真个应用以及新功能的实现。如果协调不力,会减缓创建跨团队依赖的其他的团队的速度。
在DAZN,我们倾向于一种客户端的编排,我们称之为bootstrap
bootstrap负责上面列出的职责,以及与我们的案例相关的另外一种职责,事实上,boostrap抽象了应用所运行的平台上的I/O API,通过这种方式,每个微前端完全不需要关心它是在哪个平台中加载的。
通过这项技术,我们能在多种智能电视、控制终端或机顶盒中复用微前端,而不需要给特定设备重新实现,除非原来的实现存在内存泄漏或者性能问题。
每一次当用户在浏览器中输入我们的域名或者在智能电视上打开我们的应用的时候,boostrap就会提供服务,它将在整个用户会话周期内永远存在,并不会被卸载。
让我们来进一步展开关于bootstrap,以理解它的主要思想:
初始化应用
bootstrap应该负责设置应用上下文,首先要了解用户是否已经通过了身份验证。基于应用的初始化,我们可以加载正确的微前端。应用需要用来设置上下文的任何有意义的信息,都应该在这个阶段进行管理。
它可以是静态配置(JSON),或者是需要使用api的动态配置。无论哪种方式,前端的外部配置允许我们改变系统的某些行为,而不需要释放bootstrap.
例如,配置可以为应用程序生命周期提供有价值的信息,比如特性切换、用户界面的本地化标签等等。
微前端路由
bootstrap整体负责微前端之间的路由挑战。在我们的实现中,我们在bootstrap和每个微前端之间有两个路由。
bootstrap不拥有我们应用的完整的URL映射,相反,它基于用户状态和通过用户交互或深链接请求这两个维度,在内存中加载了应该加载的微前端的映射。这两个维度允许我们加载正确的微前端,并将处理URL请求的微前端代码留给组成它的不同视图来管理。
一个经验法则是,为微前端分配特定的二级路径,这样将更容易去定位微前端的范围。例如,当用户输入mydomain/account/时,应该加载身份验证的微前端;而当用户点击mydomain/suport/*之类的连接时,应该加载帮助页面的微前端。
在每个单独的微前端内部,我们可以决定使用额外的路径,例如mydomain.com/support/help-page-A或mydomain.com/support/help-page-B, 通过这种方式,域名信息将被保留在微前端内部,而不会在应用的多个部分之间传播。
这里的要点是,我们在使用客户端编排的微前端内部有两种类型的路由,一种是bootstrap级别的全局路由,一种是微前端内部的本地路由。
微前端生命周期
如前所述,每个微前端都应该bootstrap进行加载,但是如何加载呢?
例如,Single-spa, 使用一个JavaScript文件作为安装新的微前端的入口点。
在DAZN,我们采用了不同的方法,因为只使用一个JavaScript文件来加载微前端,会排除掉在编译时使用服务端渲染的可能性——而这对我们在从微前端过渡到另外一个微前端的时候提供更快的反馈来说,是一个有趣的选项。
考虑到HTML文件基本上是一个具有特定模式的XML文件,bootstrap可以使用DOMParser(这是用于解析XML或HTML字符串的标准接口)来加载和解析文件,以及附加所有相关的节点,来加载微前端。
任何存在于body或head标签内的内容,都可以加入到bootstrap的DOM树中。
我们还可以决定为所有需要附加的标签定义特定的属性,以便能快速的选择它们。
总之,整体思想是加载一个HTML文件,并将它附加到bootstrap中,以加载微前端所需的内容,因此,微前端HTML文件中出现的任何外部依赖项(如JavaScript或CSS文件)都将会被附加到这个HTML文件中,并被浏览器加载。
这种简洁的方法的一个巨大好处是,它不会自以为是,任何人都可以开始开发一个微前端,而无需学习我们决定如何处理去处理微前端的方法。因为最终,只要微前端输出前端的三位一体:HTML,JavaScript和CSS文件。
我拍摄了一段调整连接的视频,以展示bootstrap如何在其内部附加DOM元素,你将会看到有4个阶段:
- 确定要加载的微前端
- 加载该微前端的HTML
- 解析该HTML
- 附加相应的标签,以在页面中显示微前端
这真是非常简单但是有效的机制!
一个添加到每个微前端的附加功能是,可以在加载或卸载前后执行一些操作,这样,微前端可以执行任何逻辑来清理附加到window上的任何对象或在之前提交的4种生命周期方法之一中执行任何其他逻辑。 bootstrap负责触发微前端的生命周期方法,并在加载下一个微前端前清理内存,这个操作确保了在不同的微前端内使用同一个库的不同或相同版本时,也不会发生冲突。
bootstrap的内存和依赖管理
是时候深入研究一下微前端的内存管理了,考虑到bootstrap每次只加载一个微前端,正如在上一篇文章中所述,而且每个微前端不与另外一个微前端共享任何库或依赖项,我们最终可能会遭遇到一种情况:一个微前端正在加载React v.16, 下一个则在加载React v.16.
同时,我们希望能够自由选择每个微前端中的任何技术和库的版本,因为拥有业务和技术知识的开发团队应该做出最好的实现选择,而不是像我们处理一个单页应用时经常发生的那样,在整个应用的开发过程中进行不断的权衡。
在这个阶段,我香型大家都能很容易的才到我们面临的挑战,因为微前端使用的任何库或框架都会在全局窗口中添加对象,而在JavaScript中我们不能控制垃圾回收器,但是我们可以通过移除给定对象的所有引用和实例,来便利对元素的处理。
为了实现这个目标,boostrap的一个附加职责是记录由任何微前端附加到窗口对象上的任何对象,并在卸载该微前端之后、加载新的微前端之前清理窗口对象(这就是JavaScript中元编程的乐趣)。
bootstrap对附加到窗口对象上的所有key保存快照,并在加载一个新的微前端之前将其删除,通过这种方式,我们可以最终需要删除的内容,而不需要复制内存中的任何对象。通过对这个数组的简单迭代,我们可以删除窗口对象内被卸载的微前端所使用的全部对象。
bootstrap和微前端之间通讯的api层
最后,值得一提的是bootstrap通过窗口对象进行暴露的api层。
如果你问自己怎么在微前端之间共享数据和通信,那么bootstrap就是答案!
请记住,我们的实现是基于这样的假设:我们每次总是只加载一个微前端,并基于应用程序的子域来拆分微前端,你很快将意识到如果在定义所有子域的初始阶段如果工作良好,跨微前端的数据共享并不会经常发生。
在微前端之间共享数据非常容易,bootstrap分享一些用于存储和检索任何微前端都可以访问的信息的api,这取决于那种存储对你的实现来说更方便,以及你希望在本地存储的对象中添加什么样的限制。
考虑到boostrap是在平台和微前端之间,用vanilla JavaScript编写的一个很小的层,并且它在初始化应用程序,因此我们需要暴露一个api层来抽象I/O层,以便在微前端中存储或检索信息。需要在多个设备上使用应用,需要使用不同的api来存储和检索文件,因为web存储的api在这些平台上并不总是一致的。
需要强调的另外一个重要部分是从静态的JSON文件或者从API来获取配置信息,这个配置信息通常要与所有的微前端共享,以便了解它们运行的上下文(例如,共享基于国家或语言的特定配置)。
当我们设计bootstrap暴露的api的时候,最重要的是尝试进行前瞻性思考,因为bootstrap应该作为一个在每次发布时都不会改变的层,除非你可能破坏一些与微前端的约定,以及将微前端与bootstrap的功能耦合。而这可能会危及到将业务拆分到多个子域中所做的出色工作。
总结
在这篇文章中,我们探讨了编排微前端的可能性,深入研究了在DAZN被称为bootstrap的客户端编排,尤其是,我们已经看到这种方法的好处和挑战,以及我们如何去设法应对这些挑战。 特别是,我们看到引导程序有3个主要职责:
- 在微前端之间进行路由导航(加载,卸载和生命周期方法)
- 初始化应用
- 暴露供微前端通信和web存储的api层
在分享这些文章后,我经常收到的一个问题是,是否考虑及何时将bootstrap开源,答案是我们正在考虑这个问题,但是目前我们无法承诺一个事件表(这也是我没在这篇文章中分享代码的原因,再次抱歉)。
我真的希望你对构建下一个微前端项目有一个更清晰的想法。如果没有的话,请随时和我联系,这样我就会可以为下一篇文章提供思考的素材了!
原文地址
Orchestrating micro-frontends, luca mezzalira