手把手教你写一个简易的微前端框架
最近看了几个微前端框架的源码(singlespa〔1〕、qiankun〔2〕、microapp〔3〕),感觉收获良多。所以打算造一个迷你版的轮子,来加深自己对所学知识的了解。
这个轮子将分为五个版本,逐步的实现一个最小可用的微前端框架:
1。支持不同框架的子应用(v1〔4〕分支)2。支持子应用HTML入口(v2〔5〕分支)3。支持沙箱功能,子应用window作用域隔离、元素隔离(v3〔6〕分支)4。支持子应用样式隔离(v4〔7〕分支)5。支持各应用之间的数据通信(main〔8〕分支)
每一个版本的代码都是在上一个版本的基础上修改的,所以V5版本的代码是最终代码。
Github项目地址:https:github。comwoai3cminisinglespaV1版本
V1版本打算实现一个最简单的微前端框架,只要它能够正常加载、卸载子应用就行。如果将V1版本细分一下的话,它主要由以下两个功能组成:
1。监听页面URL变化,切换子应用2。根据当前URL、子应用的触发规则来判断是否要加载、卸载子应用监听页面URL变化,切换子应用
一个SPA应用必不可少的功能就是监听页面URL的变化,然后根据不同的路由规则来渲染不同的路由组件。因此,微前端框架也可以根据页面URL的变化,来切换到不同的子应用:当location。pathname以vue为前缀时切换到vue子应用https:www。example。comvuexxx当location。pathname以react为前缀时切换到react子应用https:www。example。comreactxxx
这可以通过重写两个API和监听两个事件来完成:
1。重写window。history。pushState()〔9〕2。重写window。history。replaceState()〔10〕3。监听popstate〔11〕事件4。监听hashchange〔12〕事件
其中pushState()、replaceState()方法可以修改浏览器的历史记录栈,所以我们可以重写这两个API。当这两个API被SPA应用调用时,说明URL发生了变化,这时就可以根据当前已改变的URL判断是否要加载、卸载子应用。执行下面代码后,浏览器的URL将从https:www。xxx。com变为https:www。xxx。comvuewindow。history。pushState(null,,vue)
当用户手动点击浏览器上的前进后退按钮时,会触发popstate事件,所以需要对这个事件进行监听。同理,也需要监听hashchange事件。
这一段逻辑的代码如下所示:import{loadApps}from。。applicationappsconstoriginalPushStatewindow。history。pushStateconstoriginalReplaceStatewindow。history。replaceStateexportdefaultfunctionoverwriteEventsAndHistory(){window。history。pushStatefunction(state:any,title:string,url:string){constresultoriginalPushState。call(this,state,title,url)根据当前url加载或卸载apploadApps()returnresult}window。history。replaceStatefunction(state:any,title:string,url:string){constresultoriginalReplaceState。call(this,state,title,url)loadApps()returnresult}window。addEventListener(popstate,(){loadApps()},true)window。addEventListener(hashchange,(){loadApps()},true)}
从上面的代码可以看出来,每次URL改变时,都会调用loadApps()方法,这个方法的作用就是根据当前的URL、子应用的触发规则去切换子应用的状态:exportasyncfunctionloadApps(){先卸载所有失活的子应用consttoUnMountAppgetAppsWithStatus(AppStatus。MOUNTED)awaitPromise。all(toUnMountApp。map(unMountApp))初始化所有刚注册的子应用consttoLoadAppgetAppsWithStatus(AppStatus。BEFOREBOOTSTRAP)awaitPromise。all(toLoadApp。map(bootstrapApp))consttoMountApp〔。。。getAppsWithStatus(AppStatus。BOOTSTRAPPED),。。。getAppsWithStatus(AppStatus。UNMOUNTED),〕加载所有符合条件的子应用awaittoMountApp。map(mountApp)}
这段代码的逻辑也比较简单:
1。卸载所有已失活的子应用2。初始化所有刚注册的子应用3。加载所有符合条件的子应用根据当前URL、子应用的触发规则来判断是否要加载、卸载子应用
为了支持不同框架的子应用,所以规定了子应用必须向外暴露bootstrap()mount()unmount()这三个方法。bootstrap()方法在第一次加载子应用时触发,并且只会触发一次,另外两个方法在每次加载、卸载子应用时都会触发。
不管注册的是什么子应用,在URL符合加载条件时就调用子应用的mount()方法,能不能正常渲染交给子应用负责。在符合卸载条件时则调用子应用的unmount()方法。registerApplication({name:vue,初始化子应用时执行该方法loadApp(){return{mount(){这里进行挂载子应用的操作app。mount(app)},unmount(){这里进行卸载子应用的操作app。unmount()},}},如果传入一个字符串会被转为一个参数为location的函数activeRule:vue会被转为(location)location。pathnamevueactiveRule:(location)location。hashvue})
上面是一个简单的子应用注册示例,其中activeRule()方法用来判断该子应用是否激活(返回true表示激活)。每当页面URL发生变化,微前端框架就会调用loadApps()判断每个子应用是否激活,然后触发加载、卸载子应用的操作。何时加载、卸载子应用
首先我们将子应用的状态分为三种:
bootstrap,调用registerApplication()注册一个子应用后,它的状态默认为bootstrap,下一个转换状态为mount。mount,子应用挂载成功后的状态,它的下一个转换状态为unmount。unmount,子应用卸载成功后的状态,它的下一个转换状态为mount,即卸载后的应用可再次加载。
现在我们来看看什么时候会加载一个子应用,当页面URL改变后,如果子应用满足以下两个条件,则需要加载该子应用:
1。activeRule()的返回值为true,例如URL从变为vue,这时子应用vue为激活状态(假设它的激活规则为vue)。2。子应用状态必须为bootstrap或unmount,这样才能向mount状态转换。如果已经处于mount状态并且activeRule()返回值为true,则不作任何处理。
如果页面的URL改变后,子应用满足以下两个条件,则需要卸载该子应用:
1。activeRule()的返回值为false,例如URL从vue变为,这时子应用vue为失活状态(假设它的激活规则为vue)。2。子应用状态必须为mount,也就是当前子应用必须处于加载状态(如果是其他状态,则不作任何处理)。然后URL改变导致失活了,所以需要卸载它,状态也从mount变为unmount。API介绍
V1版本主要向外暴露了两个API:
1。registerApplication(),注册子应用。2。start(),注册完所有的子应用后调用,在它的内部会执行loadApps()去加载子应用。
registerApplication(Application)接收的参数如下:interfaceApplication{子应用名称name:string激活规则,例如传入vue,当url的路径变为vue时,激活当前子应用。如果activeRule为函数,则会传入location作为参数,activeRule(location)返回true时,激活当前子应用。activeRule:Functionstring传给子应用的自定义参数props:AnyObjectloadApp()必须返回一个Promise,resolve()后得到一个对象:{bootstrap:()Promisemount:(props:AnyObject)Promiseunmount:(props:AnyObject)Promise}loadApp:()Promise}一个完整的示例
现在我们来看一个比较完整的示例(代码在V1分支的examples目录):letvueAppregisterApplication({name:vue,loadApp(){returnPromise。resolve({bootstrap(){console。log(vuebootstrap)},mount(){console。log(vuemount)vueAppVue。createApp({data(){return{text:VueApp}},render(){returnVue。h(p,标签名称this。text标签内容)},})vueApp。mount(app)},unmount(){console。log(vueunmount)vueApp。unmount()},})},activeRule:(location)location。hashvue,})registerApplication({name:react,loadApp(){returnPromise。resolve({bootstrap(){console。log(reactbootstrap)},mount(){console。log(reactmount)ReactDOM。render(React。createElement(LikeButton),(app));},unmount(){console。log(reactunmount)ReactDOM。unmountComponentAtNode((app));},})},activeRule:(location)location。hashreact})start()
演示效果如下:
小结
V1版本的代码打包后才100多行,如果只是想了解微前端的最核心原理,只看V1版本的源码就可以了。V2版本
V1版本的实现还是非常简陋的,能够适用的业务场景有限。从V1版本的示例可以看出,它要求子应用提前把资源都加载好(或者把整个子应用打包成一个NPM包,直接引入),这样才能在执行子应用的mount()方法时,能够正常渲染。
举个例子,假设我们在开发环境启动了一个vue应用。那么如何在主应用引入这个vue子应用的资源呢?首先排除掉NPM包的形式,因为每次修改代码都得打包,不现实。第二种方式就是手动在主应用引入子应用的资源。例如vue子应用的入口资源为:
那么我们可以在注册子应用时这样引入:registerApplication({name:vue,loadApp(){returnPromise。resolve({bootstrap(){import(http:localhost:8001jschunkvendors。js)import(http:localhost:8001jsapp。js)},mount(){。。。},unmount(){。。。},})},activeRule:(location)location。hashvue})
这种方式也不靠谱,每次子应用的入口资源文件变了,主应用的代码也得跟着变。还好,我们有第三种方式,那就是在注册子应用的时候,把子应用的入口URL写上,由微前端来负责加载资源文件。registerApplication({子应用入口URLpageEntry:http:localhost:8081。。。})自动加载资源文件
现在我们来看一下如何自动加载子应用的入口文件(只在第一次加载子应用时执行):exportdefaultfunctionparseHTMLandLoadSources(app:Application){returnnewPromisevoid(async(resolve,reject){constpageEntryapp。pageEntryloadhtmlconsthtmlawaitloadSourceText(pageEntry)constdomparsernewDOMParser()constdocdomparser。parseFromString(html,texthtml)const{scripts,styles}extractScriptsAndStyles(docasunknownasElement,app)提取了scriptstyle后剩下的body部分的html内容app。pageBodydoc。body。innerHTMLletisStylesDonefalse,isScriptsDonefalse加载stylescript的内容Promise。all(loadStyles(styles))。then(data{isStylesDonetrue将style样式添加到document。head标签addStyles(dataasstring〔〕)if(isScriptsDoneisStylesDone)resolve()})。catch(errreject(err))Promise。all(loadScripts(scripts))。then(data{isScriptsDonetrue执行script内容executeScripts(dataasstring〔〕)if(isScriptsDoneisStylesDone)resolve()})。catch(errreject(err))})}
上面代码的逻辑:
1。利用ajax请求子应用入口URL的内容,得到子应用的HTML2。提取HTML中scriptstyle的内容或URL,如果是URL,则再次使用ajax拉取内容。最后得到入口页面所有的scriptstyle的内容3。将所有style添加到document。head下,script代码直接执行4。将剩下的body部分的HTML内容赋值给子应用要挂载的DOM下。
下面再详细描述一下这四步是怎么做的。一、拉取HTML内容exportfunctionloadSourceText(url:string){returnnewPromisestring((resolve,reject){constxhrnewXMLHttpRequest()xhr。onload(res:any){resolve(res。target。response)}xhr。onerrorrejectxhr。onabortrejectxhr。open(get,url)xhr。send()})}
代码逻辑很简单,使用ajax发起一个请求,得到HTML内容。
上图就是一个vue子应用的HTML内容,箭头所指的是要提取的资源,方框标记的内容要赋值给子应用所挂载的DOM。二、解析HTML并提取stylescript标签内容
这需要使用一个APIDOMParser〔13〕,它可以直接解析一个HTML字符串,并且不需要挂到document对象上。constdomparsernewDOMParser()constdocdomparser。parseFromString(html,texthtml)
提取标签的函数extractScriptsAndStyles(node:Element,app:Application)代码比较多,这里就不贴代码了。这个函数主要的功能就是递归遍历上面生成的DOM树,提取里面所有的stylescript标签。三、添加style标签,执行script脚本内容
这一步比较简单,将所有提取的style标签添加到document。head下:exportfunctionaddStyles(styles:string〔〕HTMLStyleElement〔〕){styles。forEach(item{if(typeofitemstring){constnodecreateElement(style,{type:textcss,textContent:item,})head。appendChild(node)}else{head。appendChild(item)}})}
js脚本代码则直接包在一个匿名函数内执行:exportfunctionexecuteScripts(scripts:string〔〕){try{scripts。forEach(code{newFunction(window,code)。call(window,window)})}catch(error){throwerror}}四、将剩下的body部分的HTML内容赋值给子应用要挂载的DOM下
为了保证子应用正常执行,需要将这部分的内容保存起来。然后每次在子应用mount()前,赋值到所挂载的DOM下。保存HTML代码app。pageBodydoc。body。innerHTML加载子应用前赋值给挂载的DOMapp。container。innerHTMLapp。pageBodyapp。mount()
现在我们已经可以非常方便的加载子应用了,但是子应用还有一些东西需要修改一下。子应用需要做的事情
在V1版本里,注册子应用的时候有一个loadApp()方法。微前端框架在第一次加载子应用时会执行这个方法,从而拿到子应用暴露的三个方法。现在实现了pageEntry功能,我们就不用把这个方法写在主应用里了,因为不再需要在主应用里引入子应用。
但是又得让微前端框架拿到子应用暴露出来的方法,所以我们可以换一种方式暴露子应用的方法:每个子应用都需要这样暴露三个API,该属性格式为minisinglespa{appName}window〔minisinglespavue〕{bootstrap,mount,unmount}
这样微前端也能拿到每个子应用暴露的方法,从而实现加载、卸载子应用的功能。
另外,子应用还得做两件事:
1。配置cors,防止出现跨域问题(由于主应用和子应用的域名不同,会出现跨域问题)2。配置资源发布路径
如果子应用是基于webpack进行开发的,可以这样配置:module。exports{devServer:{port:8001,子应用访问端口headers:{AccessControlAllowOrigin:}},publicPath:localhost:8001,}一个完整的示例
示例代码在examples目录。registerApplication({name:vue,pageEntry:http:localhost:8001,activeRule:pathPrefix(vue),container:(subappviewport)})registerApplication({name:react,pageEntry:http:localhost:8002,activeRule:pathPrefix(react),container:(subappviewport)})start()
V3版本
V3版本主要添加以下两个功能:
1。隔离子应用window作用域2。隔离子应用元素作用域隔离子应用window作用域
在V2版本下,主应用及所有的子应用都共用一个window对象,这就导致了互相覆盖数据的问题:先加载a子应用window。namea后加载b子应用window。nameb这时再切换回a子应用,读取window。name得到的值却是bconsole。log(window。name)b
为了避免这种情况发生,我们可以使用Proxy〔14〕来代理对子应用window对象的访问:app。windownewProxy({},{get(target,key){if(Reflect。has(target,key)){returnReflect。get(target,key)}constresultoriginalWindow〔key〕window原生方法的this指向必须绑在window上运行,否则会报错TypeError:Illegalinvocatione。g:constobj{};obj。alertalert;obj。alert();return(isFunction(result)needToBindOriginalWindow(result))?result。bind(window):result},set:(target,key,value){this。injectKeySet。add(key)returnReflect。set(target,key,value)}})
从上述代码可以看出,用Proxy对一个空对象做了代理,然后把这个代理对象作为子应用的window对象:
1。当子应用里的代码访问window。xxx属性时,就会被这个代理对象拦截。它会先看看子应用的代理window对象有没有这个属性,如果找不到,就会从父应用里找,也就是在真正的window对象里找。2。当子应用里的代码修改window属性时,会直接在子应用的代理window对象上修改。
那么问题来了,怎么让子应用里的代码读取修改window时候,让它们访问的是子应用的代理window对象?
刚才V2版本介绍过,微前端框架会代替子应用拉取js资源,然后直接执行。我们可以在执行代码的时候使用with〔15〕语句将代码包一下,让子应用的window指向代理对象:exportfunctionexecuteScripts(scripts:string〔〕,app:Application){try{scripts。forEach(code{ts使用with会报错,所以需要这样包一下将子应用的js代码全局window环境指向代理环境proxyWindowconstwarpCode;(function(proxyWindow){with(proxyWindow){(function(window){{code}})。call(proxyWindow,proxyWindow)}})(this);newFunction(warpCode)。call(app。sandbox。proxyWindow)})}catch(error){throwerror}}卸载时清除子应用window作用域
当子应用卸载时,需要对它的window代理对象进行清除。否则下一次子应用重新加载时,它的window代理对象会存有上一次加载的数据。刚才创建Proxy的代码中有一行代码this。injectKeySet。add(key),这个injectKeySet是一个Set对象,存着每一个window代理对象的新增属性。所以在卸载时只需要遍历这个Set,将window代理对象上对应的key删除即可:for(constkeyofinjectKeySet){Reflect。deleteProperty(microAppWindow,keyas(stringsymbol))}记录绑定的全局事件、定时器,卸载时清除
通常情况下,一个子应用除了会修改window上的属性,还会在window上绑定一些全局事件。所以我们要把这些事件记录起来,在卸载子应用时清除这些事件。同理,各种定时器也一样,卸载时需要清除未执行的定时器。
下面的代码是记录事件、定时器的部分关键代码:部分关键代码microAppWindow。setTimeoutfunctionsetTimeout(callback:Function,timeout?:numberundefined,。。。args:any〔〕):number{consttimeroriginalWindow。setTimeout(callback,timeout,。。。args)timeoutSet。add(timer)returntimer}microAppWindow。clearTimeoutfunctionclearTimeout(timer?:number):void{if(timerundefined)returnoriginalWindow。clearTimeout(timer)timeoutSet。delete(timer)}microAppWindow。addEventListenerfunctionaddEventListener(type:string,listener:EventListenerOrEventListenerObject,options?:booleanAddEventListenerOptionsundefined,){if(!windowEventMap。get(type)){windowEventMap。set(type,〔〕)}windowEventMap。get(type)?。push({listener,options})returnoriginalWindowAddEventListener。call(originalWindow,type,listener,options)}microAppWindow。removeEventListenerfunctionremoveEventListener(type:string,listener:EventListenerOrEventListenerObject,options?:booleanAddEventListenerOptionsundefined,){constarrwindowEventMap。get(type)〔〕for(leti0,lenarr。length;ilen;i){if(arr〔i〕。listenerlistener){arr。splice(i,1)break}}returnoriginalWindowRemoveEventListener。call(originalWindow,type,listener,options)}
下面这段是清除事件、定时器的关键代码:for(consttimeroftimeoutSet){originalWindow。clearTimeout(timer)}for(const〔type,arr〕ofwindowEventMap){for(constitemofarr){originalWindowRemoveEventListener。call(originalWindow,typeasstring,item。listener,item。options)}}缓存子应用快照
之前提到过子应用每次加载的时候会都执行mount()方法,由于每个js文件只会执行一次,所以在执行mount()方法之前的代码在下一次重新加载时不会再次执行。
举个例子:window。nametestfunctionbootstrap(){。。。}functionmount(){。。。}functionunmount(){。。。}
上面是子应用入口文件的代码,在第一次执行js代码时,子应用可以读取window。name这个属性的值。但是子应用卸载时会把name这个属性清除掉。所以子应用下一次加载的时候,就读取不到这个属性了。
为了解决这个问题,我们可以在子应用初始化时(拉取了所有入口js文件并执行后)将当前的子应用window代理对象的属性、事件缓存起来,生成快照。下一次子应用重新加载时,将快照恢复回子应用上。
生成快照的部分代码:const{windowSnapshot,microAppWindow}thisconstrecordAttrswindowSnapshot。get(attrs)!constrecordWindowEventswindowSnapshot。get(windowEvents)!缓存window属性this。injectKeySet。forEach(key{recordAttrs。set(key,deepCopy(microAppWindow〔key〕))})缓存window事件this。windowEventMap。forEach((arr,type){recordWindowEvents。set(type,deepCopy(arr))})
恢复快照的部分代码:const{windowSnapshot,injectKeySet,microAppWindow,windowEventMap,onWindowEventMap,}thisconstrecordAttrswindowSnapshot。get(attrs)!constrecordWindowEventswindowSnapshot。get(windowEvents)!recordAttrs。forEach((value,key){injectKeySet。add(key)microAppWindow〔key〕deepCopy(value)})recordWindowEvents。forEach((arr,type){windowEventMap。set(type,deepCopy(arr))for(constitemofarr){originalWindowAddEventListener。call(originalWindow,typeasstring,item。listener,item。options)}})隔离子应用元素作用域
我们在使用document。querySelector()或者其他查询DOM的API时,都会在整个页面的document对象上查询。如果在子应用上也这样查询,很有可能会查询到子应用范围外的DOM元素。为了解决这个问题,我们需要重写一下查询类的DOMAPI:将所有查询dom的范围限制在子应用挂载的dom容器上Document。prototype。querySelectorfunctionquerySelector(this:Document,selector:string){constappgetCurrentApp()if(!app!selectorisUniqueElement(selector)){returnoriginalQuerySelector。call(this,selector)}将查询范围限定在子应用挂载容器的DOM下returnapp。container。querySelector(selector)}Document。prototype。getElementByIdfunctiongetElementById(id:string){。。。}
将查询范围限定在子应用挂载容器的DOM下。另外,子应用卸载时也需要恢复重写的API:Document。prototype。querySelectororiginalQuerySelectorDocument。prototype。querySelectorAlloriginalQuerySelectorAll。。。
除了查询DOM要限制子应用的范围,样式也要限制范围。假设在vue应用上有这样一个样式:body{color:red;}
当它作为一个子应用被加载时,这个样式需要被修改为:body被替换为子应用挂载DOM的id选择符app{color:red;}
实现代码也比较简单,需要遍历每一条css规则,然后替换里面的body、html字符串:constre(s,)?(bodyhtml)bg将bodyhtml标签替换为子应用挂载容器的idcssText。replace(re,{app。container。id})V4版本
V3版本实现了window作用域隔离、元素隔离,在V4版本上我们将实现子应用样式隔离。第一版
我们都知道创建DOM元素时使用的是document。createElement()API,所以我们可以在创建DOM元素时,把当前子应用的名称当成属性写到DOM上:Document。prototype。createElementfunctioncreateElement(tagName:string,options?:ElementCreationOptions,):HTMLElement{constappNamegetCurrentAppName()constelementoriginalCreateElement。call(this,tagName,options)appNameelement。setAttribute(singlespaname,appName)returnelement}
这样所有的style标签在创建时都会有当前子应用的名称属性。我们可以在子应用卸载时将当前子应用所有的style标签进行移除,再次挂载时将这些标签重新添加到document。head下。这样就实现了不同子应用之间的样式隔离。
移除子应用所有style标签的代码:exportfunctionremoveStyles(name:string){conststylesdocument。querySelectorAll(style〔singlespaname{name}〕)styles。forEach(style{removeNode(style)})returnstylesasunknownasHTMLStyleElement〔〕}
第一版的样式作用域隔离完成后,它只能对每次只加载一个子应用的场景有效。例如先加载a子应用,卸载后再加载b子应用这种场景。在卸载a子应用时会把它的样式也卸载。如果同时加载多个子应用,第一版的样式隔离就不起作用了。第二版
由于每个子应用下的DOM元素都有以自己名称作为值的singlespaname属性(如果不知道这个名称是哪来的,请往上翻一下第一版的描述)。
所以我们可以给子应用的每个样式加上子应用名称,也就是将这样的样式:p{color:red;}
改成:p〔singlespanamevue〕{color:red;}
这样一来,就把样式作用域范围限制在对应的子应用所挂载的DOM下。给样式添加作用域范围
现在我们来看看具体要怎么添加作用域:给每一条css选择符添加对应的子应用作用域1。a{}a〔singlespaname{app。name}〕{}2。abc{}a〔singlespaname{app。name}〕bc{}3。a,b{}a〔singlespaname{app。name}〕,b〔singlespaname{app。name}〕{}4。body{}{子应用挂载容器的id}〔singlespaname{app。name}〕{}5。mediasupports特殊处理,其他规则直接返回cssText
主要有以上五种情况。
通常情况下,每一条css选择符都是一个css规则,这可以通过style。sheet。cssRules获取:
拿到了每一条css规则之后,我们就可以对它们进行重写,然后再把它们重写挂载到document。head下:functionhandleCSSRules(cssRules:CSSRuleList,app:Application){letresultArray。from(cssRules)。forEach(cssRule{constcssTextcssRule。cssTextconstselectorText(cssRuleasCSSStyleRule)。selectorTextresultcssRule。cssText。replace(selectorText,getNewSelectorText(selectorText,app),)})returnresult}letcount0constre(s,)?(bodyhtml)bgfunctiongetNewSelectorText(selectorText:string,app:Application){constarrselectorText。split(,)。map(text{constitemstext。trim()。split()items〔0〕{items〔0〕}〔singlespaname{app。name}〕returnitems。join()})如果子应用挂载的容器没有id,则随机生成一个idletidapp。container。idif(!id){idsinglespaidcountapp。container。idid}将bodyhtml标签替换为子应用挂载容器的idreturnarr。join(,)。replace(re,{id})}
核心代码在getNewSelectorText()上,这个函数给每一个css规则都加上了〔singlespaname{app。name}〕。这样就把样式作用域限制在了对应的子应用内了。效果演示
大家可以对比一下下面的两张图,这个示例同时加载了vue、react两个子应用。第一张图里的vue子应用部分字体被react子应用的样式影响了。第二张图是添加了样式作用域隔离的效果图,可以看到vue子应用的样式是正常的,没有被影响。
V5版本
V5版本主要添加了一个全局数据通信的功能,设计思路如下:
1。所有应用共享一个全局对象window。spaGlobalState,所有应用都可以对这个全局对象进行监听,每当有应用对它进行修改时,会触发change事件。2。可以使用这个全局对象进行事件订阅发布,各应用之间可以自由的收发事件。
下面是实现了第一点要求的部分关键代码:exportdefaultclassGlobalStateextendsEventBus{privatestate:AnyObject{}privatestateChangeCallbacksMap:Mapstring,ArrayCallbacknewMap()set(key:string,value:any){this。state〔key〕valuethis。emitChange(set,key)}get(key:string){returnthis。state〔key〕}onChange(callback:Callback){constappNamegetCurrentAppName()if(!appName)returnconst{stateChangeCallbacksMap}thisif(!stateChangeCallbacksMap。get(appName)){stateChangeCallbacksMap。set(appName,〔〕)}stateChangeCallbacksMap。get(appName)?。push(callback)}emitChange(operator:string,key?:string){this。stateChangeCallbacksMap。forEach((callbacks,appName){如果是点击其他子应用或父应用触发全局数据变更,则当前打开的子应用获取到的app为null所以需要改成用activeRule来判断当前子应用是否运行constappgetApp(appName)asApplicationif(!(isActive(app)app。statusAppStatus。MOUNTED))returncallbacks。forEach(callbackcallback(this。state,operator,key))})}}
下面是实现了第二点要求的部分关键代码:exportdefaultclassEventBus{privateeventsMap:Mapstring,Recordstring,ArrayCallbacknewMap()on(event:string,callback:Callback){if(!isFunction(callback)){throwError(Thesecondparam{typeofcallback}isnotafunction)}constappNamegetCurrentAppName()parentconst{eventsMap}thisif(!eventsMap。get(appName)){eventsMap。set(appName,{})}consteventseventsMap。get(appName)!if(!events〔event〕){events〔event〕〔〕}events〔event〕。push(callback)}emit(event:string,。。。args:any){this。eventsMap。forEach((events,appName){如果是点击其他子应用或父应用触发全局数据变更,则当前打开的子应用获取到的app为null所以需要改成用activeRule来判断当前子应用是否运行constappgetApp(appName)asApplicationif(appNameparent(isActive(app)app。statusAppStatus。MOUNTED)){if(events〔event〕?。length){for(constcallbackofevents〔event〕){callback。call(this,。。。args)}}}})}}
以上两段代码都有一个相同的地方,就是在保存监听回调函数的时候需要和对应的子应用关联起来。当某个子应用卸载时,需要把它关联的回调函数也清除掉。
全局数据修改示例代码:父应用window。spaGlobalState。set(msg,父应用在spa全局状态上新增了一个msg属性)子应用window。spaGlobalState。onChange((state,operator,key){alert(vue子应用监听到spa全局状态发生了变化:{JSON。stringify(state)},操作:{operator},变化的属性:{key})})
全局事件示例代码:父应用window。spaGlobalState。emit(testEvent,父应用发送了一个全局事件:testEvent)子应用window。spaGlobalState。on(testEvent,()alert(vue子应用监听到父应用发送了一个全局事件:testEvent))
总结
至此,一个简易微前端框架的技术要点已经讲解完毕。强烈建议大家在看文档的同时,把demo运行起来跑一跑,这样能帮助你更好的理解代码。
如果你觉得我的文章写得不错,也可以看看我的其他一些技术文章或项目:
带你入门前端工程〔16〕可视化拖拽组件库一些技术要点原理分析〔17〕前端性能优化24条建议(2020)〔18〕前端监控SDK的一些技术要点原理分析〔19〕手把手教你写一个脚手架〔20〕计算机系统要素从零开始构建现代计算机〔21〕References
〔1〕singlespa:https:github。comsinglespasinglespa
〔2〕qiankun:https:github。comumijsqiankun
〔3〕microapp:https:github。commicrozoemicroapp
〔4〕v1:https:github。comwoai3cminisinglespatreev1
〔5〕v2:https:github。comwoai3cminisinglespatreev2
〔6〕v3:https:github。comwoai3cminisinglespatreev3
〔7〕v4:https:github。comwoai3cminisinglespatreev4
〔8〕main:https:github。comwoai3cminisinglespa
〔9〕window。history。pushState():https:developer。mozilla。orgzhCNdocsWebAPIHistorypushState
〔10〕window。history。replaceState():https:developer。mozilla。orgzhCNdocsWebAPIHistoryreplaceState
〔11〕popstate:https:developer。mozilla。orgzhCNdocsWebAPIWindowpopstateevent
〔12〕hashchange:https:developer。mozilla。orgzhCNdocsWebAPIWindowhashchangeevent
〔13〕DOMParser:https:developer。mozilla。orgzhCNdocsWebAPIDOMParser
〔14〕Proxy:https:developer。mozilla。orgzhCNdocsWebJavaScriptReferenceGlobalObjectsProxy
〔15〕with:https:developer。mozilla。orgzhCNdocsWebJavaScriptReferenceStatementswith
〔16〕带你入门前端工程:https:woai3c。gitee。iointroductiontofrontendengineering
〔17〕可视化拖拽组件库一些技术要点原理分析:https:github。comwoai3cFrontendarticlesissues19
〔18〕前端性能优化24条建议(2020):https:github。comwoai3cFrontendarticlesblobmasterperformance。md
〔19〕前端监控SDK的一些技术要点原理分析:https:github。comwoai3cFrontendarticlesissues26
〔20〕手把手教你写一个脚手架:https:github。comwoai3cFrontendarticlesissues22
〔21〕计算机系统要素从零开始构建现代计算机:https:github。comwoai3cnand2tetris