本篇文章大致分为四个部分:
1、简单介绍微前端
2、我们的多应用协作模式
3、微前端框架:single-spa
4、single-spa未解决的问题
什么是微前端?
在听闻微前端之前,我想你肯定已经听说过微服务了,后端的系统设计在接入微服务之前是以单体模式架构存在的,即所有的相关联的系统都聚合在一起,久而久之,业务越来越复杂系统也越来越庞大,问题就逐渐暴露出来,比如系统耦合紧密导致难以维护,新技术栈引入局限性、系统捆绑部署升级运维成本高等等。于是乎,微服务的构架模式就由此而生,它将应用程序构造为一组松散耦合的服务,这种结构中,服务是细粒度的,协议是轻量级的微服务是一种以业务功能为主的服务设计概念,每一个服务都具有自主运行的业务功能,对外开放不受语言限制的 API (最常用的是 HTTP),应用程序则是由一个或多个微服务组成。反观我们前端目前已经成熟的单页应用,其实也存在这些问题,这种“微”理念也就被应用到了前端,即微前端。
微前端是一种类似于微服务的架构,它将微服务的理念应用于浏览器端,即将单页面前端应用由单一的单体应用转变为把多个小型前端应用聚合为一体的应用。这就意味着前端应用的拆分,拆分后的应用实现应用自治、单一职责、技术栈无关三大特性,再进行基座模式或自由组合的模式进行聚合,达到微前端的目的。
为什么需要微前端?
单页应用的痛点:
遗留系统迁移:定义是使用旧的、不再使用的技术栈编写的,由于框架本身已经不更新(不添加新的功能或者不再维护)。既然应用可以使用就不必再花力气去重写,而是直接整合到新的应用中去。
聚合前端应用:后台微服务解耦,前端微服务聚合。多种业务功能聚合,减少应用,可在一个系统中连贯完成大部分操作。
热闹驱动开发:因为“流行”所以尝试。
微前端的实现方案很多,甚至千人千面,当前也没有标准的实现方式,但从现在众多已落地的微前端方案对比看来,都是结合自身业务需求进行详尽设计,各不相同,其基本原理相似。
我们协作模式
我们目前主要是自由组织模式,自由组组织模式指的就是:系统内部子系统之间能自行按照某种规则形成一定的结构或功能。就像我们现在各个业务线都维护一套同样的路由配置。
例如:
前端根据frontSet(相当于一个配置文件)控制跳转配合Nginx 配置,实现路由分发及跳转
以下是nginx配置截取:
server { listen 80 default_server backlog=10240; server_name test.teaching.xiaojiaoyu100.com test-teaching.xiaojiaoyu100.com; #####################grey conf ########################################### ## 此处省略n多行 location ^~ /teaching-ss/ { # ^~ 开头表示uri以某个常规字符串开头,不是正则匹配 access_log logs/access.teaching-ss.log main; proxy_pass http://teaching-ss; # 请求转向teaching-ss 定义的服务器列表 rewrite ^/teaching-ss/(.*)$ /$1 break; rewrite ^/teaching-ss$ / break; } ## 此处省略n多行}
基座模式 - single-spa
微前端还有一种模式,就是基座模式,它也许就是我们下一阶段的小目标或者说是日后我们周末学习的驱动力。微前端基座模式目前在前端社区比较活跃的有三种,single-spa、飞冰、乾坤,大致的思路都源自于single-spa,目前热度最高的乾坤也是在single-spa的基础上进行二次封装。所以在找到适合我们的方案之前,我们先来仔细聊聊single-spa。
类比我们的自由组织模式,Single-spa基座模式把路由管控部分从nginx搬到了我们前端,通过网页地址首先我们加载到的是基座,我们可以来看一个简易demo的基座代码,它就是一个html文件, 子应用在基座上进行注册,每个子应用都应该有一套用来描述自己的标签,基座通过路由去匹配对应的子应用,并对子应用们进行统一管理,最后将正确的子应用内容挂在到页面上。
基座
最简单一个index.html文件
基座代码分析
<html> <head> <meta http-equiv="Content-Security-Policy" content="default-src * data: blob: 'unsafe-inline' 'unsafe-eval'; script-src * 'unsafe-inline' 'unsafe-eval'; connect-src * 'unsafe-inline'; img-src * data: blob: 'unsafe-inline'; frame-src *; style-src * data: blob: 'unsafe-inline'; font-src * data: blob: 'unsafe-inline';" /> <meta charset="utf-8" /> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <title>Your applicationtitle> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="importmap-type" content="systemjs-importmap" /> <script type="systemjs-importmap"> { "imports": { "app1": "http://localhost:4201/main.js", "app2": "http://localhost:4202/main.js", "my-parcel": "http://localhost:8080/js/app.js", "single-spa": "https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.5/system/single-spa.min.js" } }script> <link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/single-spa/4.3.5/system/single-spa.min.js" as="script" crossorigin="anonymous" /> <script src="https://unpkg.com/core-js-bundle@3.1.4/minified.js">script> <script src="https://unpkg.com/zone.js">script> <script src="https://unpkg.com/import-map-overrides@1.6.0/dist/import-map-overrides.js">script> <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/4.0.0/system.min.js">script> <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/4.0.0/extras/amd.min.js">script> <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/4.0.0/extras/named-exports.js">script> <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/4.0.0/extras/named-register.min.js">script> head> <body> <style> .imo-popup { overflow: scroll; }style> <script> System.import("single-spa").then(function (singleSpa) { singleSpa.registerApplication( "app1", function () { return System.import("app1"); }, function (location) { return location.pathname.startsWith("/app1"); } ); singleSpa.registerApplication( "app2", function () { return System.import("app2"); }, function (location) { return location.pathname.startsWith("/app2"); } ); singleSpa.start(); });script> <import-map-overrides-full>import-map-overrides-full> body>html>
总的来说基座主要做了这些事儿
首先,基座利用systemjs这个库对子应用资源包、及工具库进行确认(systemjs的官方定义是为浏览器中的ES模块启用向后兼容工作流和可配置的模块加载程序,说的简单点就是让你可以在浏览器中任意使用【CommonJS、AMD、CMD、UMD、ES6 modules】这五种模块化方式)。
确认之后,基座会对子应用进行规范化处理,规范化处理指的是检测并完善子应用配置信息{name, loadApp,activeWhen,customProps },也就是给子应用们贴上各种标签,后续基座通过标签对子应用进行管理,合格的子应用被收集到全局变量apps的数组中,那不合格的基座会报错,此时子应用的状态默认为NOT_LOADED,等待被基座Q,这关系是不是有点像皇帝和他的三千佳丽啊,其实基座就是个工具人,用户才是真正的帝王。
注册完了之后呢,就要把这些子应用加载进来,才开始工具人最核心的工作,通过管控路由来控制子应用的切换。
single-spa 路由管控
当基座监听到路由变化时,把响应的路由事件暂存起来,执行子应用的状态变更,该卸载的卸载,该初始化的初始化,变更完成之后再将之前暂存的路由事件拎出来一次执行,以此来唤起子应用的变更检测及视图更新。
劫持hashChange和popState事件(源码)
if (isInBrowser) { // We will trigger an app change for any routing events. window.addEventListener("hashchange", urlReroute); window.addEventListener("popstate", urlReroute); // Monkeypatch addEventListener so that we can ensure correct timing const originalAddEventListener = window.addEventListener; const originalRemoveEventListener = window.removeEventListener; window.addEventListener = function (eventName, fn) { if (typeof fn === "function") { // 如果用户监听的是 hashchange 和 popstate 事件,并且这个监听器此前未加入事件监听列表 // 那这个事件时有可能引发应用变更的,需要加入 capturedEventListeners 中 // 直接 return 掉,说明 hashchange 和 popstate 事件并没有马上执行 // 而是在执行完 reroute 逻辑之后在执行 if ( routingEventsListeningTo.indexOf(eventName) >= 0 && !find(capturedEventListeners[eventName], (listener) => listener === fn) ) { capturedEventListeners[eventName].push(fn); return; } } return originalAddEventListener.apply(this, arguments); }; window.removeEventListener = function (eventName, listenerFn) { if (typeof listenerFn === "function") { if (routingEventsListeningTo.indexOf(eventName) >= 0) { capturedEventListeners[eventName] = capturedEventListeners[ eventName ].filter((fn) => fn !== listenerFn); return; } } return originalRemoveEventListener.apply(this, arguments); }; // patchedUpdateState: 对原生的window.history.pushState 和 window.history.replaceState做加强 // 使其能触发 popstate事件 window.history.pushState = patchedUpdateState( window.history.pushState, "pushState" ); window.history.replaceState = patchedUpdateState( window.history.replaceState, "replaceState" ); if (window.singleSpaNavigate) { console.warn( formatErrorMessage( 41, __DEV__ && "single-spa has been loaded twice on the page. This can result in unexpected behavior." ) ); } else { /* For convenience in `onclick` attributes, we expose a global function for navigating to * whatever an tag's href is. */ window.singleSpaNavigate = navigateToUrl; }}
基座设计
基座的核心代码在reroute方法中,它包揽了子应用状态变更所有的活,即所有子应用的加载、初始化、挂载、更新、卸载等。这个方法将在三个地方被调用:子应用注册时、基座启动时、路由变更时。我们先来看看子应用的整个状态变更历程。
子应用状态变更
所有子应用状态流转
主应用如何更新子应用状态
子应用注册 registerApplication()
将规范化后的子应用信息存入全局变量apps中,此时子应用的状态默认为NOT_LOADED,规范化处理指的是检测并完善子应用配置信息{name,loadApp,activeWhen,customProps}。 手动触发 reroute(),此时基座未启动执行的是加载子应用(loadApps())
启动 start()
手动触发 reroute(),此时子应用已加载完毕,启动基座执行的是卸载/挂载子应用(performanceApps())。
reroute() 做了什么
1、对原生的pushState和replaceState事件进行加强,使其触发popstate事件,再而触发reroute()
2、覆盖全局window.addEventListener,劫持hashchange 和 popstate 事件,并存到capturedEventListeners中,在执行完 reroute 逻辑之后在执行,即第7点。(navigation/navigation-events.js)
3、收集需要变更的子应用,并根据状态分门别类(appsToUnload, appsToUnmount, appsToLoad, appsToMount),那么最初注册的子应用便是在appsToLoad集合中。(applications/apps.js)
4、依次处理各个状态的app集合,触发相应事件和生命周期,appsToUnload、appsToUnmount、appsToLoad、appsToMount (navigation/reroute.js)。
5、appsToUnload,appsToUnmount:卸载与当前路由不匹配的子应用,将其从对应的app数组中删除,并删除其生命周期函数,状态变更为NOT_LOADED。
5、appsToLoad:执行loadApp,即通过system加载子应用包,加载之后一系列规范检查(必要的生命周期函数是否存在),更新的子应用的状态为NOT_BOOTSTRAPPED,即已经load但未初始化。最后绑定生命周期函数并返回app。(lifecycles/load.js)
6、appsToMount:初始化 子应用状态NOT_BOOTSTRAPPED -> BOOTSTRAPPING,执行bootstrap周期函数,子应用状态 BOOTSTRAPPING -> NOT_MOUNTED,触发全局事件single-spa:before-first-mount,执行mount周期函数,在这里就会监听全局的single-spa:routing-event事件啦,子应用状态 NOT_MOUNTED -> MOUNTED,触发全局事件"single-spa:first-mount";
7、触发全局事件 CustomEvent("single-spa:routing-event", getCustomEventDetail(),该事件会传递到当前激活(mount)的子应用中,路由被子应用接管,基座reroute结束。(navigation/reroute.js)
single-spa的实现重点在于 applicaitons、lifecycles、navigation三个模块,即对应子应用的注册、加载、挂载、销毁;子应用的生命周期;覆盖全局window.addEventListener,拦截hashChange和popState事件,以便控制路由,子应用之间的切换三个部分。
自定义事件
基座通过 new CustomEvent(name, getCustomEventDetail) 向全局派发事件,主要用于单元测试,子应用也可以通过监听这些事件添加业务逻辑。
single-spa:before-no-app-change:每次进入 reroute 方法,会判断本次 reroute 有无应用状态发生改变,如果没有,产生该事件。
single-spa:before-app-change:每次进入 reroute 方法,会判断本次 reroute 有无应用状态发生改变,如果有,产生该事件。
single-spa:before-routing-event:紧跟在上述事件之后发生,每次 reroute 开始一定会发生。
single-spa:before-mount-routing-event:url 发行改变后,旧的应用卸载完毕后,触发该事件,表示后续要开始加载应用。
single-spa:before-first-mount:只发出一次,第一次 mount 应用之前产生该事件。
single-spa:first-mount:只发出一次,第一次 mount 应用之后产生该事件。
single-spa:no-app-change:与事件 1 是一致的,只不过发生在 reroute 方法结束。
single-spa:app-change:与事件 2 是一致的,只不过发生在 reroute 方法结束。
single-spa:routing-event:与事件 3 对应,发生在 reroute 结束。
子应用
接下来换子应用登场,我们拿single-spa-angular为例。
在ng子应用中执行命令 ng add single-spa-angular,该命令完成了以下事情:
命令:ng add single-spa-angular
将angular应用的入口从main.ts改造为main.single-spa.ts
在src/single-spa目录下创建了两个文件,一个是single-spa-props用来传递自定义属性,另外一个asset-url.ts用来动态获取当前应用的静态资源路径。
在src目录下创建了一个空的路由,让单个应用在应用间跳转时找不到路由情况下显示空路由app-routing.module.ts。
在package.json中添加了两个命令build:single-spa和serve:single-spa分别用来构建一个single-spa子应用和启动一个single-spa子应用。
在根目录下创建了一个自定义的webpack配置文件extra-webpack.config.js,引入single-spa-angular的webpack配置。
改造入口文件
将一个angular应用的入口从main.ts改造为main.single-spa.ts,对外暴露三个生命周期的操作,即bootstrap,mount,unmount三个阶段。
创建自定义的webpack配置
创建一个extra-webpack.config.js文件,将我们最后输出的bundle以umd格式打包,将zone.js抽取出来,在index.html里面直接共享,同时为了不让webpack覆盖system全局变量,制定parser下面的system为false,剩下的操作就是把所有的入口包括全局css都去掉,只保留一个main入口,这样保证最终一个angular子应用打包出来的只有一个main.js。
生命周期的实现
子应用拥有三个生命周期,分别是bootstrap、mount、unmount共基座调度,实现子应用的切换。
以single-spa-angular为例
bootstrap: 在子应用loading完成之后做了多实例angular应用的标志,为了一个页面运行多个ng-app,即为ng项目的zone贴上“"single-spa-angular: appName”的标志,并告诉zonejs,single-spa触发了子应用切换,需要启动变更检测,实现routingEventListener方法,供当前子应用执行mount生命周期时调用,触发zone.run(fn)方法(从run中的这个函数fn中调度的任何将来的任务或微任务将继续从Angular区域中执行),唤起angular变更检测。
mount:调用angular的platformBrowserDynamic().bootstrapModule(AppModule)方法手动启动angular应用, 将子应用的内容挂载在id为 "single-spa-application: name"的节点上,并加入到body中。监听全局的single-spa:routing-event事件,在该事件触发时执行NgZone.run()方法,唤起变更检测,并将启动的module实例保存了下来。
unmount:调用启动的module实例的destroy方法,销毁子应用,并针对特殊情况做了一些处理。
思考
欢迎大家一起来交流补充~
如何开发
import-map-overrides-full
import-map-overrides可以覆盖导入映射的浏览器javascript库。这适用于本地浏览器导入映射、SystemJS导入映射、es-module-shims导入映射等等。例如在开发过程中test环境下开启该工具,便可将本地正在运行的子应用链接注入到项目中,案例。
如何托管、构建或部署
选择 1: 一个代码仓库, 一个build包
选择 2: NPM包
选择 3: 动态加载模块
创建一个父应用,允许子应用单独部署。为了实现这一点,创建一个manifest文件,当子应用部署更新时,它控制子应用的“上线”版本及加载的JavaScript文件。
改变每个子应用加载的JavaScript文件有很多的方法,比如:
Web服务器:在你的web服务器为每个子应用的正确版本创建一个动态脚本。
使用模块加载 例如 SystemJS 可以在浏览器通过动态urls下载并执行JavaScript代码。
如何处理 全局CSS 污染
CSS沙箱:postcss-loader,在项目打包时加上子项目的项目名称作为类名前缀,即主动创建namespace,形成天然的沙箱。