前言关注核心实现,请直接跳至第四节:执行过程。本文中的命令仅适用于支持shell的系统,例如Mac、Ubuntu和其他linux发行版。它不适用于Windows。如果想在windows下执行文章中的命令,请使用git命令窗口(需要安装git)或者linux子系统(win10以下不支持)。1.初始化项目1.初始化项目目录cd~&&mkdirmy-single-spa&&cd"$_"2.初始化npm环境#初始化package.json文件npminit-y#安装dev依赖npminstall@babel/core@babel/plugin-syntax-dynamic-import@babel/preset-envrolluprollup-plugin-babelrollup-plugin-commonjsrollup-plugin-node-resolverollup-plugin-serve-D模块描述:模块描述3,配置babel和rollup创建babel.config.js#创建babel.config.jstouchbabel.config.js添加内容:module.export=function(api){//缓存babel配置api.cache(true);//相当于api。cache.forever()返回{预设:[['@babel/preset-env',{module:false}]],插件:['@babel/plugin-syntax-dynamic-import']};};createrollup.config.js#创建rollup.config.jstouchrollup.config.js添加内容:importresolvefrom'rollup-plugin-node-resolve';从'rollup-plugin-babel'导入babel;从'rollup-plugin-commonjs'导入commonjs;从'rollup-plugin-serve'导入服务;导出默认值{输入:'./src/my-single-spa.js',输出:{文件:'./lib/嗯d/my-single-spa.js',格式:'umd',名称:'mySingleSpa',sourcemap:true},插件:[resolve(),commonjs(),babel({exclude:'node_modules/**'}),//见下面package.json文件的script字段中的serve命令//目的是只有在执行serve命令时才启动这个插件process.env.SERVE?serve({open:true,contentBase:'',openPage:'/toutrial/index.html',host:'localhost',port:'10001'}):null]}4.添加script和browserslist字段到package。json{"script":{"build:dev":"rollup-c","serve":"SERVE=truerollup-c-w"},"browserslist":["ie>=11","last4Safari主要版本”、“最近10个Chrome主要版本”、“最近10个Firefox主要版本”、“最近4个Edge主要版本”]}5。添加项目文件夹mkdir-psrc/applicationssrc/lifecyclessrc/navigationsrc/servicestoutrial&&touchsrc/my-single-spa.js&&到uchtoutrial/index.html至此,整个项目的文件夹结构应该是:.├──babel.config.js├──package-lock.json├──package.json├──rollup.config.js├──node_modules├──教程|└──index.html└──src├──applications├──lifecycles├──my-single-spa.js├──navigation└──serviceshere,projecthere初始化完成,然后开始核心内容,微前端框架的编写二、app相关概念1、app需要微前端的核心是app,微前端的主要场景是:将应用拆分成加载多个应用程序,或将多个不同的应用程序一起加载为一个应用程序。为了更好地约束应用和行为,需要每个应用导出完整的生命周期函数,以便微前端框架更好地跟踪和控制。//app1exportdefault{//应用启动引导:[()=>Promise.resolve()],//应用挂载mount:[()=>Promise.resolve()],//应用卸载unmount:[()=>Promise.resolve()],//服务更新,只有服务可用可以将生命周期传递给返回Promise的函数或返回Promise的函数数组。2.应用状态为了更好的管理应用,特在应用中添加了状态。每个app有11个状态,每个状态的流程图如下:加载(load):判断需要挂载的App(mount):判断需要卸载的App(unmount):3.App生命周期函数和超时处理App生命周期为什么一个函数传入一个数组或者一个函数,但它们都必须返回一个Promise。为了处理方便,我们会判断:如果传入的不是Array,则传入的函数用数组包裹。导出函数smellLikeAPromise(promise){if(promiseinstanceofPromise){returntrue;}returntypeofpromise==='object'&&promise.then==='function'&&promise.catch==='function';}exportfunctionflattenLifecyclesArray(lifecycles,description){if(Array.isArray(lifecycles)){lifecycles=[lifecycles]}if(lifecycles.length===0){lifecycles=[()=>Promise.resolve()];}//处理生命周期returnprops=>newPromise((resolve,reject)=>{waitForPromise(0);functionwaitForPromise(index){letfn=lifecycles[index](props);if(!smellLikeAPromise(fn)){reject(`${description}atindex${index}didnotreturnapromise`);return;}fn.then(()=>{if(index>=lifecycles.length-1){resolve();}别的{waitForPromise(++索引);}}).赶上(拒绝);}});}//示例app.bootstrap=[()=>Promise.resolve(),()=>Promise.resolve(),()=>Promise.resolve()];app.bootstrap=flattenLifecyclesArray(app.bootstrap);具体过程如下图所示:思考:使用reduce怎么写?有什么需要注意的问题吗?为了app的易用性,我们还在各个app的生命周期函数中加入了超时处理//flattenedLifecyclesPromise为经过上一步flatten处理过的生命周期数exportfunctionreasonableTime(flattenedLifecyclesPromise,description,timeout){returnnewPromise((resolve,reject)=>{letfinished=false;flattenedLifecyclesPromise.then((data)=>{finished=true;resolve(data)}).catch(e=>{finished=true;reject(e);});setTimeout(()=>{if(finished){return;}leterror=`${description}没有解决或拒绝${timeout.milliseconds}毫秒`;if(timeout.rejectWhenTimeout){reject(newError(error));}else{console.log(`${error}但仍在等待forfulfilled或unfulfilled`);}},timeout.milliseconds);});}//示例reasonableTime(app.bootstrap(props),'appbootstraping',{rejectWhenTimeout:false,milliseconds:3000}).then(()=>{console.log('app启动成功');console.log(app.status==='NOT_MOUNTED');//=>true}).catch(e=>{console.error(e);console.log('app启动失败');console.log(app.status==='SKIP_BECAUSE_BROKEN');//=>true});3、路由拦截微前端中的app分为两种,一种是根据Location变化的,称为app,另一种是纯功能(Feature)级别的,称为service。如果我们想随着位置的变化动态地挂载和卸载那些符合条件的应用程序,我们需要统一拦截浏览器的位置相关操作。另外,为了减少使用Vue、React等视图框架时的冲突,需要保证微前端必须先处理Location相关的事件,然后才是Vue、React等框架的Router。为什么当Location发生变化时,微前端框架必须最先执行相关操作?如何保证“第一”?因为微前端框架需要根据Location来挂载或卸载app。然后应用内部使用的Vue或者React开始真正的做后续工作,可以最大限度的减少应用内部Vue或者React的无用(冗余)操作。拦截(劫持)原生的Location相关事件,由微前端框架统一控制,使其始终先执行。constHIJACK_EVENTS_NAME=/^(hashchange|popstate)$/i;constEVENTS_POOL={hashchange:[],popstate:[]};functionreroute(){//invoke主要用于加载、挂载、卸载满脚组件的app//具体条件请看文章官方应用状态小节中的"load、mount、unmount条件"invoke([],arguments)}window.addEventListener('hashchange',reroute);window.addEventListener('popstate',reroute);constoriginalAddEventListener=window.addEventListener;constoriginalRemoveEventListener=window.removeEventListener;window.addEventListener=function(eventName,handler){if(eventName&&HIJACK_EVENTS_NAME.test(eventName)&&typeofhandler==='函数'){EVENTS_POOL[事件名称].indexOf(handler)===-1&&EVENTS_POOL[eventName].push(handler);}returnoriginalAddEventListener.apply(this,arguments);};window.removeEventListener=function(eventName,handler){if(eventName&&HIJACK_EVENTS_NAME.test(eventName)){让eventsList=EVENTS_POOL[event名称];eventsList.indexOf(handler)>-1&&(EVENTS_POOL[eventName]=eventsList.filter(fn=>fn!==handler));}returnoriginalRemoveEventListener.apply(this,arguments);};functionmockPopStateEvent(state){returnnewPopStateEvent('popstate',{state});}//拦截history方法,因为pushState和replaceState方法不会触发onpopstate事件,所以即使我们在onpopstate期间执行了reroute方法,我们也必须在这里执行reroute方法constoriginalPushState=window.history.pushState;constoriginalReplaceState=window.history.replaceState;window.history.pushState=function(state,title,url){letresult=originalPushState.apply(this,arguments);重新路由(mockPopStateEvent(状态));返回结果;};window.history.replaceState=function(state,title,url){letresult=originalReplaceState.apply(this,arguments);重新路由(mockPopStateEvent(状态));returnresult;};//执行完load、mount、unmout操作后,执行该函数,保证微前端的逻辑总是先执行。然后App中的Vue或者React相关的Router就可以接收到Location事件了。导出函数callCapturedEvents(eventArgs){如果(!eventArgs){返回;}if(!Array.isArray(eventArgs)){eventArgs=[eventArgs];}letname=eventArgs[0].type;如果(!HIJACK_EVENTS_NAME.test(名称)){返回;}EVENTS_POOL[name].forEach(handler=>handler.apply(window,eventArgs));}4.执行过程(核心)整个微前端框架的执行顺序类似于js事件循环,一般是执行流程如下:触发时序整个系统的触发时序分为两大类:浏览器触发:当浏览器Location发生变化时,拦截onhashchange和onpopstate事件,mock浏览器历史的pushState()和replaceState()方法.手动触发:手动调用框架的registerApplication()或start()方法。每次通过触发机会进行触发操作时,都会将修改队列(changesQueue)存储在changesQueue队列中。它就像事件循环的事件队列一样,静静地等待处理。如果changesQueue为空,则停止循环,直到下一次触发时间到来。与js事件循环队列不同的是,changesQueue是当前循环中的所有变更都会被打包成批同时执行,而js事件循环是一个一个执行。在“事件”循环的每一次循环开始时,都会先判断整个微前端的框架是否已经启动。Unstarted:根据规则加载需要加载的app(见上文“判断需要加载的App”),加载完成后调用内部finish方法。Started:根据规则获取当前需要卸载(unmount)的app,需要加载(load)的app,以及因为不满足条件需要挂载(mount)的app,并首先合并加载和安装的应用程序。进行去重,unmout完成后挂载。然后等到挂载执行完成,就会调用内部的finish方法。可以通过调用mySingleSpa.start()来启动微前端框架。从上面我们可以发现,无论微前端框架当前状态是未启动还是启动,最终都会调用内部的finish方法。事实上,finish方法的内部非常简单。判断当前changesQueue是否为空。如果不为空,则重新开始下一个循环。如果为空,则终止循环并退出整个过程。functionfinish(){//获取成功挂载的appletresolveValue=getMountedApps();//pendings是上一个周期存储的一批changesQueue的别名//其实就是调用下面invoke方法的backup变量if(pendings){pendings.forEach(item=>item.success(resolveValue));}//标记循环结束loadAppsUnderway=false;//发现changesQueue的长度不为0if(pendingPromises.length){constbackup=pendingPromises;pendingPromises=[];//将“修改队列”传入invoke方法,开始下一个循环returninvoke(backup);}//changesQueue为空,终止循环,返回挂载的appreturnresolveValue;拦截到的location事件会在每个循环结束时触发,这样上面提到的微前端框架的location触发时机总是先执行,Vue或者React的router总是后执行。最后,关于如何获取微前端框架仓库地址,关注公众号:【fkdcxy,疯狂的程序员】免费获取!如果您觉得本文对您有帮助,记得点赞+转发分享给更多人。看完不喜欢的都是(rogue/(ㄒoㄒ)/~~如果你对这篇文章有其他的看法或者想法,欢迎在评论区留言,谢谢!
