微前端架构是一种设计方法,其中,前端应用被分解为多个松散而协同工作的半独立“微应用”。微前端的思想来源于微服务,其名称也遵循了微服务的命名方式。那么,微前端到底如何落地呢?来自网易的资深前端开发工程师张浩为大家解读了网易严选企业级微前端的解决方案与落地实践。
“微前端”这个词大家应该并不陌生,甚至可以说近期在前端技术领域算是一个热门的话题,目前市面上不乏各种微前端的文章与技术方案。
传统的前端 SPA 开发模式,一方面,随着系统迭代,发展到一定程度,规模已经非常庞大。通过项目内的模块化,已经无法解决业务膨胀的问题;另一方面,随着应用框架的升级、变迁,多框架多版本、同框架多版本共存的状态无法避免,必须要有一种方案,能对整个业务进行合理拆分、组合,所以微前端的思想应运而生。
而这之中的痛点,以及大家的目标显然都是一致的。简而言之,我们希望 项目分离,运营聚合。
在网易严选,微前端不仅仅是一个框架或者说一个“壳”,而是一整套包括规范、工具、框架、配置中心、应用监控等一系列相关功能在内的前端应用架构体系。当然,不同的企业有不同的需求和应用场景,我们可以针对性地做一些个性化开发和改造,从而真真正正地在业务场景中提高生产力。
如下是网易严选微前端的总体架构图:
挑重点来说,有以下几点切实而紧迫的实际需求。
严选发展至今,内部的技术栈颇多。就当前来说,NEJ 等内部技术栈 /Jquery/AngularJs/ Angular 2.x,4.x,6.x/React 0.x,15.x,16.x/Vue 1.x,2.x,可以说一应俱全。随着技术的发展,我们也必然会持续不断地迭代我们的技术体系,同时也要兼容老的项目运行。
严选内部有非常多的项目经过多年持续不断的迭代,已经变得非常庞大,动辄 300+ 的页面数量让这些系统越来越难以维护,甚至一次编译部署都要耗费非常久的时间。这其实在许多企业级的项目开发中也非常常见,也就是所谓的“巨石应用”。
各系统之间有很多功能需要复用 我们可以通过复制代码去做模块复用,当然这种方式不够高级,而且难以维护。我们还可以通过把功能模块封装成包,然后以发布 npm 包的方式来复用。但其实这种方式,在具体的业务场景开发中也存在诸多问题。
有一个现实案例,严选有个业务功能叫 库存模块,包含 10 来个页面。我们把这个模块整体发布成了一个 npm 包,在多个项目中引用。本来设想是美好的,只需要和 input、select 那样的基础组件去维护就好了。但是,现实击碎了我们的美好想法。这个业务功能模块并不像基础组件一样稳定,一方面,是因为频繁地需求迭代;另一方面,是因为模块过大 bug 变多,导致我们需要频繁修改这个包。所以,每次在开发联调测试阶段都极为繁琐,我们需要不断地在库存模块的项目里调整代码,发布 npm,然后再用到库存模块的各个项目中,逐个更新依赖、构建、部署。这个过程效率低不说,还容易反复和遗漏。
跨系统流转的工单开发模式探索 比如一个采购工单,如图:
采购工单
需要流转【采购系统 —> 商品中心 —> 财务系统 】,所以为了开发一个采购工单,我们需要涉及到 3 个系统的产品、开发、测试人员,流程繁琐,开发周期长,效率低下。
采购流程开发
出于某些原因,我们会将一个应用拆分为多个功能模块,跨部门多团队来共同完成开发。比如,其中有一个功能模块会整体交给外包团队。当前的流程是:外包团队先从严选 git 仓库复制一份代码出去开发,待开发完成后,再将代码重新合并到严选 git 仓库,然后再编译后上线。
这种多团队合作的问题不言而喻:
(1) 容易引起代码冲突; (2) 各团队开发的模块之间没有隔离,容易互相影响,从而遇到各种不可预知的问题。
所以,每次在合并代码后,仍然需要花费不少的时间进行回归测试。
鉴于以上的场景,我们从 2019 年初就开始了对微前端方案的调研,期间也借鉴了许多外部的优秀方案与设计。经过长时间的探索、开发、实践,以及实际业务场景的多个项目落地,形成了现在的严选微前端应用架构体系。
正如前面所说,微前端其实是一个完整的技术应用架构体系。 该体系涉及到的技术点比较广,从具体的应用开发,到主框架的实现原理,再到我们相关的配套设施(配置中心、应用监控等),恐怕不是短短一篇文章所能阐述。这里简单阐述几点。
严选的项目基于 @sharkr/cli 脚手架创建,底层基于 webpack。我们通过约定、插件、配置等手段,让每个应用在开发、构建、部署时,不需要用户再去做一丝一毫的改动,从而都能符合我们微前端所需的规范。说白了,我们做到了“零成本接入”。举两个例子:
(1)假设我们有两个 react 应用,都基于 webpack 构建,为了让它们能在同一个页面上跑起来,我们就需要对 webpackJsonp 进行配置,修改全局暴露的 window.webpackJsonp 名称。而这个过程就是自动会在我们的工具中完成修改,无需手动地进行每个应用修改。
(2)严选的一个前端应用包含前端和 Node(用于承担 BFF 层和 SSR 等功能)两块。我们构建后的应用,会自动在 Node 端生成一个接口 /xhr/config/get.json 用来获取该应用的配置信息(包括 JS、CSS 静态资源路径等),用于后续的应用加载。(具体在下面图中会说到)
当然,我们做的不止这些。简单来说,就是我们可以通过一些工具和规范约定,同时与每个企业自身的 devops 平台无缝对接,形成完整的开发链路闭环。
在说这个之前,我们不妨先来简单了解下当前几种主流的微应用架构。
这种方式就是在多个独立的 SPA 应用之间跳转,通过把界面、导航、皮肤做成类似的样子,让用户感觉像是同一个应用。
优点:
a. 框架无关; b. 独立开发、部署、运行; c. 应用之间 100% 隔离。
缺点:
a. 应用之间的彻底割裂导致复用困难。(比如,每个应用左侧和顶部都带有导航,那么, 当我要把该应用在其他系统中复用时,需要对该子应用的导航做较为复杂的改动) ; b. 每个独立的 SPA 应用加载时间较长,容易出现白屏,影响用户体验; c. 后续如果要做同屏多应用,不便于扩展。
这里我把与 Single-SPA 原理类似的,或基于 Single-SPA 开发的一些解决方案都归为一类,他们可能在实现上有所差异,但本质趋同。
主应用的代码一般非常简单,仅作为加载容器,管理子应用的生命周期。主应用捕获全局的路由事件,基于判断当前路由需要加载哪个子应用,然后 load 它。比如路由为 /vue,我们就加载 /vue 子应用;路由为 /react,我们就加载 /react 子应用。当然,在路由切走后,也要卸载该应用。
优点:
a. 框架无关; b. 独立开发、部署、运行; c. 项目自由切割,应用可以自由组合,方便复用; d. 便于自由扩展功能。
缺点:
a. 子应用需要实现 mount、unmount 等钩子,侵入式的代码开发体验并不友好; b. 全局污染和资源竞争。
这里的“基座”也就是主应用,会包含应用依赖的绝大多数环境,包括基础框架、基础组件与第三方依赖包,而子应用只会包含自己的一些业务代码(以及一些主应用可能不包含的 third party)。当我们的主应用启动之后,基本就有了全套的运行时环境。同样的,主应用捕获全局的路由事件,基于判断当前路由需要加载哪个子应用,然后 load 它。路由这里有点不同,在类 Single-SPA 方案中,子应用在加载后,一般会由子应用去接管系统路由。而在基座式的方式中,子应用一般会把自己的路由注册到主应用中,并不接管系统路由。子应用更像是主应用的一个“路由模块”。
优点:
显而易见,这种模式打包出来的子应用只包含了业务代码,体积小、加载快、用户体验好。
缺点:
缺点也很明显,首先基座就决定了它是框架强相关的,哪怕是基座的版本升级迭代,也会非常容易造成子应用 break change。
这个方案需要对自定义构建的依赖颇多,基本上需要自己定义一套方案,来解决公共资源的问题(当然这是难点不是缺点)。我见过有类似方案做的不够完善的,未能实现独立构建,而是必须依赖于主应用构建,也就是所谓的 构建时组合。同时,这种方式对规范的依赖最强,我们必须遵照一定的规范来开发项目,从 dev 到 build,都需要建设自己的开发体系来实现上述效果。
这个概念比较好懂,简而言之,就是把通用的一些业务功能发布成组件,通过私有 npm 的方式去维护和管理。其中,跟框架无关又比较有代表性的方案就属 Web Components 了。当然,这种模式更像是业务组件,或者说业务模块,而不是应用。
优点:
对现有项目渐进式增强,逐步改进;
缺点:
随着业务中组件数量的爆发式增加,组件粒度通信、组件的维护成本都急剧增加;并不能做到真正的独立开发、测试、部署。
总结来说,如下图:
总体分析下来,类 Single-SPA 是相对较好的。
严选的微前端方案,在 Single-SPA 的思想上进行了大刀阔斧的改革和创新,同时借助 Node 层(数据层、服务端渲染、静态资源处理)来作为支撑,可以说形成了一个较有特色的微前端应用体系。
a. 独立开发、独立部署、独立运行; b. 技术栈无关; c. 应用可以自由拆解和组合,子应用复用性强; d. 配套的 Node 层设计,使用前端 + 服务端作为整体方案,子应用既可以独立提供服务,亦可以作为主应用的一个子模块; e. 业务代码无侵入设计,子应用免主动式申明生命周期; f. 子应用间实现绝对隔离; g. 优秀的用户体验; h. 项目改造成本低,老项目能快速落地。
通常来说,我们的中后台应用一般长这样。
左侧和顶部是我们的应用导航区,右边的区域是我们的内容展示区。
在严选的微前端模式中,我们干脆把这种结构进一步抽象和统一化处理。由主应用负责应用加载与管理的同时来承载左侧和顶部导航栏。(当然这种抽象是否必须,需要由具体的业务场景决定,或许你并不需要把导航功能集成到主应用中,但这并不影响我们的设计)。而不同的子应用,则展示在右侧区域。如下图所示:
这里我们只讨论同屏单应用的情况,就目前严选实际的业务落地场景来看,同屏单应用 + 业务组件的方式基本覆盖了所有需求(100+ 的中后台应用)。当然,理论上同屏多应用只需合理划分页面区域即可,原理同样适用。
假设我们有一个主应用叫 main,有一个子应用叫 s1,我们来看一下应用的加载流程。
简单梳理一下:
(1) 主应用是微前端框架的承载体,主要包含:
a. 页面主体框架的渲染,比如一些通用的导航; b. 监听捕获全局的路由变化,加载 / 卸载子应用,active 标签等; c. 应用隔离、应用通信、数据共享等全局方法的载体。
(2)子引用在被主应用启动后,会接管系统路由,与一个独立运行的应用没有本质区别。
(3)在获取子应用的配置信息时,我们完全可以按照约定 path 的规则,避免像类 Single-SPA 技术方案那种 router 对应 entry js/html 的繁琐配置。不只是这里的 router 规则,在整套微前端应用架构体系中,我们也在许多地方遵循着 [约定优于配置] 的游戏规则。对于约定规则,你可以理解为是一种规范,当大家都按照同一标准做事的时候,许多事情就变得简单了。诸如此类设计思想的框架很多,比如 egg.js、springBoot 就是其中比较出名的。当然如果你确实更喜欢做配置的话,我们也支持。
微前端架构中,应用隔离可以说是不得不提的一环。大名鼎鼎的 Single-SPA 虽然做了完善的应用加载逻辑,却把应用隔离的问题抛给了用户,让一众想要在生产环境实践的同学不得不望而却步。在这一点上,蚂蚁的 qiankun 框架不得不说是在众多方案中走在前面的一个,路由系统基于 Single-SPA 实现,在应用的加载和管理层引入了 jsSandBox,虽然目前仍存在一些问题,但并不妨碍它是我目前为止在市面上见过的考虑比较完善的方案之一。
回归主题,严选微前端做应用隔离时考虑了两方面:子应用与子应用隔离,以及主应用与子应用隔离。
js 隔离
一个子应用从加载到运行,再到卸载,有可能会对全局产生一些污染。这些污染包括但不限于:添加 / 删除 / 修改全局变量(比如:window.$ = jQuery)、绑定全局事件(比如:window.addEventListener(‘popstate’,cb) )、修改原生方法或对象(比如:Promise)、修改原生方法或对象的原型链(比如:XMLHttpRequest.prototype.open)。而所有这些子应用造成的影响都可能引起潜在的全局冲突。为此,我们需要在加载和卸载一个应用的同时,尽可能消除这种影响。
这个子应用的加载引擎方案,我们内部称之为 Loader Engine 。在我们的设计中,Loader Engine 是一套规范定义,只要按照规则实现,理论上可以替换为任意的 Loader Engine。当前我们实现了两种方式。
①硬隔离
简单来说,就是在每个子应用加载之前,都进行一次 window reload,这样我们可以保证每个子应用在渲染时都是一个全新的环境,哪怕是上一个子应用把 window 上的属性改了个遍,也丝毫没有影响。
当然,光有 window reload 不行,页面的刷新会带来用户体验上的下降。所以配合该方案,我们还需要进行以下一些手段优化:
a. 前端 snapshot + resume,快速恢复应用界面。当前已应用于生产环境。 b. 主应用使用 SSR 局部直出,使页面在视觉效果上无刷新。当前还没有应用于生产环境。
实际使用中,以上两种方案带来的体验差别不大,选择其一即可。当然,目前我们也在探索通过 service worker 来替代 Node 渲染的工作,理论上 service worker 不比 Node 端渲染的执行环境差,并且,这么做的好处是无需再考虑 Node 端渲染的性能问题,同时又有 Node 端渲染的效果。当然缺点是做起来还是比较繁琐,技术上存在一些改造的难点,具体实践方式仍在探索中。
②软隔离
简单来说,就是在应用加载之前做一次全局快照,在应用卸载之后,按快照恢复全局属性。考虑到主应用和子应用会同时运行在 Window 中,所以,我们必须区分哪些修改是由子应用引起而需要恢复的。为此,我们创建了一个 sandbox。在子应用加载前后,我们需要做这些事。
a. 记住对全局变量的修改,解除应用时恢复原有值; b. 记住全局事件的修改,比如 window/document.addEventListener,卸载应用时 remove 事件; c. 记住 setTimeout 和 setIntervald 的修改,卸载应用时解除; d. 此外,sandbox 并不能监听到对全局方法(对象)和它们的原型链修改(比如:XMLHttpRequest.prototype.open,Promise,Promise.prototype.then),因此我们还需要在加载子应用前创建一份 window snapshot ,卸载应用后按 snapshot 恢复全局方法(对象)和它们的原型链。这些对象和原型链包括但不限于:Promise、fetch、setTimeout、clearTimeout、setInterval、clearInterval、requestAnimationFrame、cancelAnimationFrame、MutationObserver、IntersectionObserver、FileReader、XMLHttpRequest、XMLHttpRequestEventTarget、Document、Element、HTMLElement、HTMLMediaElement、HTMLFrameSetElement、HTMLBodyElement、HTMLFrameElement、HTMLIFrameElement、WebSocket、Object。
至此,我们可以让每个子应用在运行时都有一份干净的运行环境。
就当前前端技术手段而言,任何的软隔离方案(或者说模拟 sandbox)都是存在漏洞的,除非哪天 Window 原生给我们支持了类似于 copy window 的方法。但是就具体实践来说,我们可以尽可能去覆盖更多情况来满足我们的业务场景。或许可能仍然会有漏洞,那就打补丁吧!
css 隔离
子应用与子应用之间的 css 隔离非常简单,我们只需要在子应用加载时,标记该子应用所有的 link 和 style 文件。在子应用卸载后,同步卸载所有的 link 和 style 即可。
主应用和子应用的隔离相对没有那么麻烦,因为主应用的功能是可收敛的,需要包含的功能基本可预见,在相对可控的情况下,隔离起来并没有那么麻烦。(子应用与主应用不同,你不能约束一个子应用会用什么第三方包,使用什么样的框架,乃至于使用一些 hack 的方法。)
js 隔离
基于 webpack 模块化的打包方式,应用之间天然就可以避免绝大多数的全局冲突,因为大多数都被编译成了闭包、内部变量和方法。而在主应用功能可收敛的情况下,余下的一小部分基本可以通过约束业务代码开发规范的形式,避免产品全局冲突和污染。这些规范包括但不限于:尽量不挂载会引起副作用的全局变量;建立模块池,收敛不可控第三方包等等。
而针对当前已知的会引起冲突的包(比如之前所说的 webpack 的全局 webpackJsonp 变量),则全部会在 cli 工具中 cover 掉。
css 隔离
当主应用和子应用同屏渲染时,要彻底隔离 css 污染,当前有两种方法,iframe 和 shadow dom。iframe 缺点颇多,此处不再赘述。Shadow DOM 拥有类似 iframe 的独立作用域,我们可以将应用渲染到 shadow dom 中,乍一看有一个光明的前景。但在当前的环境下,就算我们自己可以在业务代码上遵从规范,避免把 dom 渲染到全局的 document 上,也不能避免我们子应用的某些第三方库不会这么干。而一旦把 css/html 写到全局,那么势必就会破坏 css 隔离。
综上所述,对于全局共享的基础样式,其实我们并不能做到绝对隔离。也就是说,我们对于全局基础框架样式的隔离,不得不退一步:采用同一套 rebase 方案,保证样式不会互相覆盖。对于具体业务代码的样式,我们的做法要简单得多,可以采用 CSS Module 或者命名空间的方式,给每个业务模块以特定前缀,即可保证不会互相干扰。
应用间通信有很多种方式,当然,要让两个分离的应用之间要做到通信,本质上仍离不开中间媒介或者说全局对象。为此,我们封装了 Event 来进行跨应用的通信。Event 对象初始化后挂载在 Window 下,在全局以单例模式运行。简单的使用范例如下:
import { Event } from '@sharkr/utils';
Event.on('customEvent', (data)=>{
console.log(data);
});
Event.dispatch('customEvent', someData);
应用之间共享数据的思路与之类似,具体方式这里不再赘述。
一个主应用由多个子应用构成,但主应用如何手动维护或去代码化自动生成?主应用如何知道自己关联了哪些子应用从而控制访问权限?子应用如何支持多套环境?怎么监控子应用的状态?诸如此类的问题,我们不仅要用框架把应用跑起来,还要让应用跑得好,跑得稳。
总而言之,为了让开发者便于使用微前端的开发模式,我们势必要建立一套成熟的,与之配套的管理平台——我们称之为配置中心,它是组成微前端应用架构体系不可或缺的一部分。我们需要在上面维护应用的基本信息、应用关联信息、应用代理层配置、应用访问权限控制等。如果说主框架让我们从技术上实现了微前端,那么 相关配套设施则是在具体的业务场景中落地的点睛之笔。
以下是当前严选配置中心的部分功能截图:
这个很好理解,我们不再持续开发一个超大的应用,而是可以适当地去做一些拆解和分离。
我们可以把通用能力封装成子应用,借用一句话: “write once,run any where”。回到刚才的微前端配置中心,我们绿色方框所圈出来的“权限管理子应用”,就是一个通用型子应用,可以无缝集成到任何其他项目中运行。
回到前面所说的工单开发问题。此时,开发一个采购工单,我们不再需要去召集采购系统、商品中心、财务系统等 3 个系统的产品、开发、测试人员,而是只需要专门一个团队开发一个采购工单子应用,然后在不同的系统中接入即可。当然,为了能更便捷地进行工单开发,我们还配套做了“流程平台”,只不过这已经不属于微前端的讨论范畴了。
其他团队拿到开发任务后,无需再去拿源码 clone,也无需关注其他团队的开发状态,直接开发一个子应用即可。如果规范允许,甚至连技术栈都可以让团队自由选择,只要符合我们的规范即可。最后,等待他们开发的子应用上线,我们只需要在应用配置中心把他们开发的子应用配置到主应用里。
网易严选微前端方案的内部代号是 wolf。一匹狼并不凶猛,就比如一个子应用也并不能解决什么问题。但当我们的微应用成体系、成规模时,我们希望它如狼群一样能紧密、有序协作,发挥 1+1>2 的效果。
我们会持续观察微前端开发模式在严选业务中的落地,尽可能多地解决在具体使用中的痛点与难点,同时把相关配套做到极致。(在我们的设想的美好蓝图中,如果子应用做得足够丰富,主应用能够做到足够的配置化。那么当有新需求来时,通过配置化来实现大部分业务场景是不是也未尝不可呢?就好比搭积木一般,只不过这里的积木是各种子应用罢了。)
以后可能会开源与业务无关的主框架代码以及相关配套,也就是类似于 qiankun 或 Single-SPA 那样的“构建主应用的壳”和相关工具。当然,正如我反复提到的,微前端是一个完整的技术应用架构体系,我们能提供的“壳”仅仅是一部分,或许我们能做的更多的是提供一些探索思路和实践案例,为前端社区做一点微小的贡献是我们始终的追求。
作者介绍
张浩,网易资深前端开发工程师,严选数据产品前端负责人。先后负责过网易企业邮箱、网易有钱、网易严选等大型项目的前端架构设计及开发。当前致力于大前端与通用能力建设、工程化与效率工具、企业级应用架构等领域研究。
领取专属 10元无门槛券
私享最新 技术干货