微前端概述
什么是微前端?
在前端领域,微前端是一个 "新" 概念,其主要思想是把一个大型应用拆分成很多个可以独立开发、独立部署、独立维护的小型应用。
事实上,它远没有那么 "新"。它其实就是已经火爆了一阵子的微服务概念在前端领域的应用,诚然,前端和后端之间表面看是有差异的,但是其深层原理和实践却有着很多共同点。
所以,要深入思考微前端,就不要让自己局限于前端领域,而要把它代入一个更广阔的领域中进行理解。
谁需要微前端?
在我看来,微前端最终的需求根源是 "康威定律"(1967 年),它是这样描述的:
Any organization that designs a system (defined broadly) will produce a design whose structure is a copy of the organization's communication structure.
对于(广义)系统的设计组织,其设计产物的结构就是该组织内沟通结构的副本。
把它应用到开发领域就是:开发组织的架构是什么样,其产出的软件系统的架构就是什么样。
经验表明,那些违反康威定律的组织都付出了相应的代价,所以,什么样的组织需要微前端?可以按照下面的检查表自检一下:
这个产品是否包含很多个开发组织?
注意,由于某些原因,可能有的产品会把四五十人组织成一个项目组,但不要把它当成一个开发组织。这只是表象,因为四五十人的团队是没法沟通的。这个大型项目组中会事实上分成若干 10 人以下的全功能团队。这种组织需要微前端,甚至需求会比正常的多个小型项目组更迫切。
这个产品是否大量依赖来自其它项目的模块?
有时候,这个产品的项目组本身并不大,但使用大量的外部应用。比如,对于一个门户/工作台/仪表盘型产品,它会集成很多来自不同应用中的功能,以便为业务提供一个全景图、集成式入口。
这时候你需要微前端,微前端技术可以让这种集成机制标准化,减少沟通成本。
这个产品的前端技术栈是否异构的?
由于历史遗留因素,某些产品的技术栈是异构的。有些部分是十年前的技术,有些是五年前的技术,有些则是刚刚兴起的技术。这种情况下,产品将被迫引入各种各样的黑科技,而这通常会伤害产品的可维护性。
这时候你需要微前端,微前端可以通过一些标准化的技术,来消除对黑科技的需求。
微前端的挑战
天下没有免费的午餐,微前端获得这些好处的同时也要付出相应的代价。微前端带来的挑战有:
故障排查问题
子应用通常不会是一个完整的前端应用,需要与外壳应用及很多子应用协同工作。这种情况就会对开发调试和生产环境上的故障排查带来新的挑战。
这个挑战不会很大,但可能要求所有相关的前端程序员都掌握一些新知识。
最头疼的问题是故障定位。如果不能提供足够强大的故障排查机制,那么出现了错误之后就可能需要追查很多子应用才能定位问题,而这个过程中往往会出现扯皮和踢皮球现象。
而这些,本质上都是管理挑战,管理者要有一颗强大的心脏。
版本管理问题
由于各个子应用都会独立演化,因此如果各个子应用之间有耦合,那么其版本发布体系将面临严峻的考验。
比如某个应用中会链接到另一个应用中的地址,那么另一个应用的 URL 发生破坏性变更时,将会导致一系列问题,当每个应用都可能多版本共存时,可能会陷入版本地狱。要解决这个问题,无论在技术上还是管理上都需要做出一些努力。
性能问题
每个子应用都是独立的,因此,其框架代码及框架环境会存在很多份,这可能会在下载、内存占用、CPU 负载等方面带来性能问题。
这些内存占用以及全局事件处理函数,在宿主页面中都可能会成为不可承受之重。固然可以用一些黑科技共享框架环境,但除非框架本身非常严格地遵守了 semver 规范,否则又会导致版本冲突等问题。
样式问题
每个传统应用都会有自己的全局样式和局部样式,当它们被改造成子应用时,全局样式就会互相冲突(即使是完全相同的样式文件也可能因为多次引入而出错);如果局部样式封装不严,也可能会互相冲突。
样式冲突问题可以通过 Shadow DOM、CSS Module 或 Angular 的 Component Styles 等样式封装技术来解决,但除此之外还有一个更棘手的样式统一问题。
应用是一个整体,我们要为应用赋予统一的外观和感觉,但是那些子应用未必愿意一直遵循我们的设计规范,即使愿意,其代价也可能会变得不可承受。更严重的是,它和样式冲突问题有着深层次的矛盾:一个要尽量独立,免得互相影响;一个要统一,希望影响所有子应用。
所以,你到底要闹哪样?
全局对象问题
在浏览器中,存在着很多全局对象,比如 window、body、location、history 等等。应用难免要和它们打交道,但在微前端环境中,这可是个技术活儿。
比如我们给 body 添加/移除 class。
我们经常要在弹出对话框时给 body 增加一个 class,以禁止它滚动,弹出框关闭时移除。在同一个应用中,我们可以控制弹出框的时序,但是在多个应用中,A 弹出对话框(添加 class)后,B 也弹出对话框(再添加 class - 这没有负面影响),然后 B 关闭对话框(移除 class),这时候虽然 A 的对话框还在显示着,但是它需要的 class 已经被移除了。
这是一个典型的 PV 问题,要想解决它需要付出不小的代价。更重要的是,跨部门沟通成本会相当高。
而最坑的大概是 location 对象,因为几乎每个带路由的 SPA 应用都会试图控制它。如果大家一起争夺它的控制权,那么其混乱程度就可想而知了。
兼容性问题
每个传统应用通常都会带有自己的 polyfills 来抹平浏览器之间的差异,但当它们变成微前端的子应用时,这种方式也会面临挑战。
- 这些 polyfills 该由谁来引入?
- 在那么多 polyfills 之间该如何选择一个让所有人都满意的?
- 多个 polyfills 之间出现了冲突怎么办?
- 引入之后是否要把所有子应用的所有线上版本都测试一遍?
- 需要升级 polyfills 版本时怎么办?
通讯问题
多个子应用之间可能会需要通讯,但通讯需要契约,而每个子应用都可能会多版本并存,要如何保证不同版本契约的兼容性?
除此之外,还需要定义一套标准的通讯机制,否则如果把这些工作都抛给每个子应用的开发者,不但增加了工作量,而且也容易让技术体系混乱(浏览器中的通讯机制至少有五种候选项),影响可维护性。
开发工具问题
IDE 通常不能很好地支持多个项目,比如在同一个项目中重构时,WebStorm 对 Angular 的支持可以在修改组件选择器(selector)时同步精确地改好其各个引用点,但跨多个项目就不行了(除非用内联 Library 的形式)。
IDE 是为常规场景开发的,所以,即使在可预见的将来也不大可能增加这种支持。你要想好这部分代价是否远小于其带来的好处。
你可能要自己承担这部分代价,必要时,可能需要自行编写辅助工具。
子应用加载方式
微前端的技术核心是子应用加载技术,可选的加载方式有。
iframe
我知道很多前端一看到 iframe 就一脸鄙夷,因为它是将近二十年前的 "老" 技术,但事实上,在微前端领域,只要与合适的技术搭配使用,iframe 的能量超乎想象。
我们知道,微服务 "硬化" 了服务的边界,从传统的库边界,演变成进程边界,再演变成虚拟机/容器边界。这种硬边界保障了微服务的隔离性,从而更好地适应了康威定律的要求。
那么,在前端领域,最 "硬" 的边界是什么呢?是浏览器。但是实操中我们几乎无法利用浏览器边界,因为它们是独立的窗口,无法给用户提供一体化的体验。
次硬的边界呢?那就是 iframe 了。iframe 有自己的样式表(而且允许自己的背景透明),有自己的 js 运行空间,有自己的全局对象,有自己的整体内存回收机制(直接销毁 iframe 就全释放了)。它还对应用隔离、资源控制、版本管理等都提供了天然的支持,而且还提供了最重要的基础设施 —— 安全沙箱。
所以,iframe 是微前端框架下最理想的硬边界。
当然,iframe 也有自己的缺点。
首先,它的着陆页性能堪忧。如果每个 iframe 初始化时都要完整的初始化一遍才能显示出内容,那么它可能要持续一两秒钟。这对于用户体验将是很大的伤害。
不过,服务端渲染(SSR)技术可以很好地解决这个问题。当加载 iframe 时,服务端会给出一个预先渲染好的 HTML,不需要等下载完 js,就可以呈现出完整的外观,这个过程通常只需要下载几千个字节即可完成。在宽带网络下这通常可以在 100ms 内渲染完毕(我们这些迟钝的人类是察觉不到的),同时,可以后台下载js,再无缝切换到完整版。甚至,Angular 还支持一种命令式切换的功能,也就是说这个 iframe 可以一直保持在 SSR 版本,直到你从某种迹象判断用户接下来很可能和这个子应用交互了,再把它切换到完整版,这样可以进一步节省其内存占用。
其次,它的高度自适应功能需要通过编程方式进行实现。与内联的方式不同,iframe 是固定高度的,大多数情况下这无伤大雅,但有时候确实需要这种特性,这就要借助微前端框架来实现了。
最后,iframe 对于样式刺穿(也就是说在父应用中影响子应用的样式)非常不友好,事实上你几乎无法进行任何样式刺穿,它只提供了一种背景透明化机制,可以在非常有限的范围内模拟穿透效果。但样式刺穿是很强的需求,因此需要框架来提供统一的样式刺穿机制。
内联加载
所谓内联加载就是把子应用直接加载到页面中。所有的子应用都运行在同一个内存空间。
内联加载本质上就是模拟应用在浏览器中的启动过程。对于大多数的前端应用,其启动过程都是一样的:指定一个 DOM 节点作为应用的根节点,然后调用应用中的某个启动函数,以这个根节点为起点,构建出整个可交互的应用。一个设计良好的应用通常不会访问其根节点树之外的任何节点(除了 body)。
所以,我们要做的就是模拟这个过程。看似很简单,不过坑也不少。
首先,前端程序的根节点通常具有一个默认的名称,比如 Angular 的根节点叫 app-root
,这样,如果要同时加载多个 Angular 应用,显然它们将无法正常启动。但是如果要求每个应用都使用不同的节点名,那么这个名字的协调、分配工作就会变成一个管理难题。怎么解决这种冲突呢?只要引入一个 MF_APP_ID
宏就可以了。当下载完子应用的文件之后,我们可以对每个文件进行一次字符串替换,把 MF_APP_ID
宏替换成一个随机字符串,这个随机字符串以后就作为这个子应用的唯一 ID 了,以后进行应用间通讯时仍然会用到它。
其次,子应用的全局变量可能会互相冲突。不过在现代应用中倒是不用担心,比如 webpack 打包时,会把模块内的变量都封在闭包中,唯一要小心的是刻意放在 window 上的变量,如果实在无法避免,请用 window['varName_MF_APP_ID'] 的形式进行定义。需要担心的是那些非模块化的遗留应用,它们可能会导致严重的冲突,而且没有什么通用的解决方案。
最后,子应用的各个 js 文件必须连续、顺序加载,中间不能插入其它应用的 js,这才能真正模拟浏览器启动它的过程。否则由于多个子应用都运行在同一个内存空间中,其加载顺序的问题可能会导致加载失败。但是这样做也意味着不能并行加载多个子应用,会让你难以优化启动时间。
Web Components
Web Components 是实现微前端的理想技术,它已经推出了 v1 版本,而 Angular 也在第一时间提供了支持。不过它仍然略显生僻,浏览器的支持程度也较差。
通过把每个子应用都抽象成一个 Web Component,我们就可以用一种统一的方式来整合它们。
但主要的挑战在于,很多既有应用无法很好地改造成 Web Components,特别是在某些不能良好支持 Web Components 的框架/库(如 jQuery)中。这个探索和重构过程会花费不少精力,对于中小型项目组不一定值得这么做。
当然,仅凭 Web Components 并不能解决前述的很多挑战,要把它跟很多其它技术合用才行。
解决方案的构成
这个解决方案需要包括下列几个部分:
加载器
这是整个方案的基本外壳,负责引导主应用,并支持以多种加载方式加载子应用。加载其它服务,并负责对子应用进行清理。
一个更高级的加载器,还应该支持惰性加载机制,也就是说:当一个子应用的任何一部分都没有出现在可见区的时候,它就不应该被实例化。这样可以进一步解决性能问题。
统一路由
用于解决全局对象 location 的冲突问题。目前一些开源微前端框架主要做的就是这项工作。其实现原理也很简单,就是劫持浏览器 location 的特定事件,并按需转发给相关的子应用。
但并不是所有的微前端应用都需要使用统一路由,比如门户型微前端,它的每个子应用都不应该和 location 打交道,而应该做成一个纯粹的组件。
设计器
对于门户型微前端应用,往往需要赋予最终用户有限定制界面的能力。这时候就需要一个可视化设计器。
可视化设计器的终极形态是一个 IDE,但是在真实的应用中并不需要这么强大。况且,越是强大、灵活的设计器,往往对最终用户越不友好(想象一下最终用户使用 IDE 的场景)。
事实上,实现一个微前端框架的关键点之一就在于如何限定设计器的需求范围,防止需求蔓延。
应用发布系统
应用发布系统包括一套服务端代码以及供加载器调用的客户端模块,但远不止如此。事实上,最大的挑战来自版本管理策略的制定及确保实施,它需要整合灰度发布、蓝绿部署、A/B 测试等很多最佳实践(参见微服务)。还需要引入或定制很多工具来确保整合后的应用质量。
传统的前端往往在工程化方面具有一些短板,这在单体前端中问题不大,但在微前端中却可能是致命的。
公共服务
就像微服务体系催生了很多公共服务一样,微前端也同样需要很多公共服务,这也是减少子应用开发成本的关键。比如:
诊断
前面说过,故障排查在微前端体系中是一个大问题。
如果你不想陷入无尽的扯皮当中,那么就需要设计一个完善的诊断服务,这个诊断服务不仅包括开发期间的,还包括运行期间的。当发现一个问题时,应该有一套自动化的机制来抓取相关的上下文,特别是内联型应用尤其困难。还有一个挑战是它本身的质量,作为一个全局性的服务,它需要很高的质量,要考虑到并处理好各种边缘情况,否则它可能导致整个应用的瘫痪。
消息总线
消息总线本质上是一套公共接口,每个子应用,无论其形态如何都要遵循它。它在实现原理上比较简单,内联和 Web Components 形态的子应用使用变量替换,iframe 形态的子应用使用 postMessage 即可。
除了纯客户端方案之外,还有一种基于 Web Socket 的 Server Push 方案,这种方案可以彻底隔离子应用,只让它们对应的后端相互通讯,并把变化通知分别下发到子应用中。这有利于保障实时一致性,并优化前端的架构,但会把一部分复杂性转移给后端。
需要在 SDK 中对这些不同的机制进行封装,让子应用不需要关心自己和对方的实现形态。
用户行为分析
单体前端通常会使用 Google Analysis 进行用户行为分析。在微前端架构下,这些统计代码没必要每个子应用都初始化一套,而且还可能互相冲突。因此需要有一套框架级的用户行为分析服务,来完成这功能,并定义出每个子应用应该如何配合自己(特别是对 iframe 型应用)。
候选解决方案
首先,你不一定需要微前端
再回顾下这篇文章,评估下微前端给你带来的好处和挑战是否能给你的组织带来正面价值,你是否有信心应对这些挑战。
即使真要解决微前端要解决的这些问题,你也并不一定需要用前面提及的这些技术来实现。
比如对于 Angular 项目,你可以利用它的惰性加载特性,把每个子模块交给一个独立的团队开发,并最终在 CI 上把它们编译在一起。源码管理上,可以使用 git 的 submodule 特性。这种形态下,最终成果仍然是一个单一的应用,但是也能遵循康威定律,而且不用担心增加特性带来的体积膨胀等问题。你多年的开发经验和管理经验在这里几乎可以完全沿用下来。
再比如对于各个子应用之间几乎没有联系的项目,可以直接做成多个 SPA,然后各自当做独立子站看待,让它们互相链接即可。
所以,先看看在现有资源和现有技术体系下有没有更好的解决方案,就像商鞅立木一样,任何变革都最好从见效最快、代价最小的事情开始。
其次,优先使用基于 iframe 的解决方案
它提供了非常强大的隔离机制,让你可以省去很多管理成本,保障应用的平滑过渡。而且这个过程中建立的基础设施,对于将来扩展到其它解决方案也是有价值的。
再次,尝试使用基于 Web Components 的解决方案
对于异构系统,应该优先使用 Web Components 方案,它很可能会成为未来的标准,可以预期其生态也会更健全。
不过,这个方案可能需要对现有代码做很多改动,跟现有代码的质量息息相关,需要先做一些技术实验来评估其代价。这种方案比较适合那些历史包袱不重的项目。
最后,尝试使用基于内联加载的解决方案
内联加载所需克服的问题是这里面最多也最复杂的,其开发难度和管理难度都很大。当然,它也有一些独特的优点,就看你认为是否值得付出相应的代价了。
技术选型决策指南
- 不需要支持低版本浏览器?
- 可以自由使用 Web Components 以支持内联模式的样式隔离
- 可以自由使用 CSS Variables 以支持内联模式的样式刺穿(不含 Shadow DOM)
- 可以简化统一路由的实现,否则需要同时支持 # 模式的前端路由
- 可以自由使用 postMessage 实现通讯机制
- 可以考虑使用 Service Worker 实现通讯机制
- 全部 js 都是模块化的(如 webpack 打包)
- 不用考虑全局变量冲突问题
- 否则需要自己实现 js 包装器,而它可能破坏原有工作逻辑
- 否则考虑 iframe 方案
- 子应用访问了全局对象
- 优先考虑 iframe 方案
- 否则需要劫持全局对象,使其能协调子应用
- 全部 css 都是模块化的(如 css module,Angular Component Styles)
- 不用考虑全局样式冲突问题
- 否则需要解决全局 css 的共享和版本冲突等问题
- 否则考虑 iframe 方案
- 不用支持换肤等动态特性
- 优先使用 iframe 方案
- 否则使用内联方案
- 否则需要实现样式刺穿机制
- 弱网环境使用
- 优先使用 iframe + SSR 方案
- 优先支持惰性加载、SSR 惰性初始化
- 考虑控制小应用自身的大小
- 移动设备使用
- 考虑不用微前端方式
- 优先由 Native 壳程序提供微前端相关支持
- 考虑使用 iframe + SSR 方案
- 考虑支持惰性加载、SSR 惰性初始化
- 子应用需要安全、隐私隔离
- 优先使用 iframe 方案
- 考虑加强 iframe 加载器,以支持安全沙箱选项
- 慎用内联方案
- 子应用内容需要实时更新
- 优先使用 Server Push 方案
- 慎重使用小应用自己的轮询
- 应用需要最终用户进行定制
- 优先实现设计器,但要先定义出 "不做" 清单
- 否则可以不要设计器,其性价比较低
- 子应用之间有重叠功能
- 考虑将其提取成公共服务
- 考虑用 Web Components 提取公共组件
Related Issues not found
Please contact @asnowwolf to initialize the comment