当前位置: 首页 > Web前端 > JavaScript

从零开始写一个微前端框架——sandbox

时间:2023-03-27 11:09:38 JavaScript

前言自从微应用框架微应用开源以来,很多朋友都非常感兴趣,问我如何实现,但这不是三言两语说得明白的。为了把原理讲清楚,我将从零开始实现一个简单的微前端框架。其核心功能包括:渲染、JS沙箱、样式隔离、数据通信。由于内容太多,将按照功能分为四篇进行讲解。这是该系列的第二篇文章:沙盒。通过这些文章,您可以了解微前端框架的具体原理和实现方法,对您使用微前端框架或自己编写微前端框架有很大的帮助。如果本文对您有帮助,请点赞和评论。相关推荐微应用仓库地址simple-微应用仓库地址从零开始写一个微前端框架-渲染文章从零开始写一个微前端框架-沙盒文章从零开始写一个微前端框架-风格隔离文章来自写一个微前端框架从零开始——数据通信小程序介绍在上一篇文章中,我们已经完成了微前端的渲染。虽然页面已经正常渲染,但是基础应用和子应用是在同一个window下执行的,这可能会导致一些问题,比如全局变量冲突,全局事件监听和解绑定。下面我们列出了两个具体问题,然后我们通过创建沙箱来解决这些问题。问题示例1.子应用程序向窗口添加一个全局变量:globalStr='child'。如果基础应用也有相同的全局变量:globalStr='parent',此时会发生变量冲突。基础应用程序变量将被覆盖。2、子应用渲染完成后,通过监听scrollwindow.addEventListener('scroll',()=>{console.log('scroll')})子应用卸载时添加全局监听事件,监听函数没有释放Binding,页面滚动的监听一直存在。如果子应用渲染了两次,监听函数就会被绑定两次,这显然是错误的。接下来,我们通过为微前端创建一个JS沙箱环境来解决这两个典型问题,将基础应用和子应用的JS隔离开来。创建沙箱由于每个子应用都需要一个独立的沙箱,所以我们按类创建一个沙箱:SandBox,当创建一个新的子应用时,会创建一个新的沙箱并与之绑定。///src/sandbox.jsexportdefaultclassSandBox{active=false//沙箱是否运行microWindow={}////代理对象injectedKeys=newSet()//新增属性,卸载constructor()时清除{}//Startstart(){}//Stopstop(){}}我们使用Proxy进行代理操作,代理对象是一个空对象microWindow,得益于Proxy的强大功能,很容易实现沙箱高效。在构造函数中进行代理相关操作,通过Proxy代理microWindow,设置三个拦截器:get、set、deleteProperty。这时候子应用对window的操作就基本可以覆盖了。///src/sandbox.jsexportdefaultclassSandBox{active=false//沙箱是否运行microWindow={}////代理对象injectedKeys=newSet()//新增属性,卸载constructor()时清除{this.proxyWindow=newProxy(this.microWindow,{//Valueget:(target,key)=>{//优先从代理对象获取值if(Reflect.has(target,key)){returnReflect.get(target,key)}//否则,从window对象中获取valueconstrawValue=Reflect.get(window,key)//如果value是函数,则需要绑定window对象,如:console,alert等if(typeofrawValue==='function'){constvalueStr=rawValue.toString()//排除构造函数if(!/^function\s+[A-Z]/.test(valueStr)&&!/^class\s+/.test(valueStr)){returnrawValue.bind(window)}}//其他情况直接返回returnrawValue},//设置变量set:(target,key,value)=>{//沙盒变量只能在运行时设置if(this.active){Reflect.set(targ等,键,值)//记录添加的变量,用于后续清空操作this.injectedKeys.add(key)}returntrue},deleteProperty:(target,key)=>{//只有当前key存在于代理对象上,才满足删除条件if(target.hasOwnProperty(key)){returnReflect.deleteProperty(target,key)}returntrue},})}...}创建agent后,我们将改进start和stop方法,实现方法为也很简单,如下:///src/sandbox.jsexportdefaultclassSandBox{...//startstart(){if(!this.active){this.active=true}}//stopstop(){if(this.active){this.active=false//清除变量this.injectedKeys.forEach((key)=>{Reflect.deleteProperty(this.microWindow,key)})this.injectedKeys.clear()}}上面一个沙盒的原型就完成了。让我们尝试一下,看看它是否有效。在src/app.js中引入沙箱,在CreateApp的构造函数中创建沙箱实例,在mount方法中执行沙箱的start方法,在unmount方法中执行沙箱的stop方法。///src/app.jsimportloadHtmlfrom'./source'+importSandboxfrom'./sandbox'exportdefaultclassCreateApp{constructor({name,url,container}){...+this.sandbox=newSandbox(name)}...mount(){...+this.sandbox.start()//执行jsthis.source.scripts.forEach((info)=>{(0,eval)(info.code)})}/***卸载应用*@paramdestroy是否完全销毁,删除缓存资源*/unmount(destory){...+this.sandbox.stop()//destory为真则删除应用如果(destory){appInstanceMap.delete(this.name)}}}上面我们创建了sandbox实例,启动了sandbox,那么sandbox生效了吗?显然不是,我们还需要通过一个with函数将子应用的js包裹起来,修改js的作用域,将子应用的窗口指向代理对象。形式如:(function(window,self){with(window){子应用的js代码}}).call(proxyobject,proxyobject,proxyobject)在沙盒中添加方法bindScope并修改jsscope:///src/sandbox.jsexportdefaultclassSandBox{...//修改jsscopebindScope(code){window.proxyWindow=this.proxyWindowreturn`;(function(window,self){with(window){;${code}\n}}).call(window.proxyWindow,window.proxyWindow,window.proxyWindow);`}}然后在mount方法中添加bindScope的使用///src/app.jsexportdefaultclassCreateApp{mount(){...//执行jsthis.source.scripts.forEach((info)=>{-(0,eval)(info.code)+(0,eval)(this.sandbox.bindScope(info.code))})}}既然沙箱真的能用了,那我们就来验证题例中的第一个问题。首先关闭沙箱,因为子应用覆盖了基础应用的全局变量globalStr,当我们在基础中访问这个变量时,得到的值为:child,说明变量冲突。启用沙箱后,再次打印基础应用中globalStr的值,得到的值为:parent,说明变量冲突问题已经解决,沙箱正常运行。第一个问题已经解决了,我们开始解决第二个问题:全局监听事件。重写全局事件,复习第二题。报错原因是卸载子应用时没有清除事件监听。如果子应用知道自己将被卸载,主动清除事件监听器,这个问题是可以避免的,但这是一种理想的情况,一是子应用不知道什么时候卸载了,二是很多第三方库也有一些全局监听事件,子应用不能完全控制。因此,我们需要在子应用卸载时,自动清除子应用剩余的全局监听事件。我们重写了沙盒中的window.addEventListener和window.removeEventListener,记录所有的全局监听事件,如果有残留的全局监听事件会在应用卸载时清空。此处创建效果函数并进行具体操作///src/sandbox.js//记录addEventListener、removeEventListenernative方法constrawWindowAddEventListener=window.addEventListenerconstrawWindowRemoveEventListener=window.removeEventListener/***重写全局事件监听和解绑定*@parammicroWindowprototypeobject*/functioneffect(microWindow){//使用Map记录全局事件consteventListenerMap=newMap()//重写addEventListenermicroWindow.addEventListener=function(type,listener,options){constlistenerList=eventListenerMap.get(type)//如果当前事件不是第一个监听,则添加缓存if(listenerList){listenerList.add(listener)}else{//当前事件是第一个监听,则初始化数据eventListenerMap.set(type,newSet([listener]))}//执行原生监听函数returnrawWindowAddEventListener.call(window,type,listener,options)}//重写removeEventListenermicroWindow.removeEventListener=function(type,listener,options){constlistenerList=eventListenerMap.get(type)//从缓存中删除监听函数if(listenerList?.size&&listenerList.has(listener)){listenerList.delete(listener)}//执行原生解绑定函数returnrawWindowRemoveEventListener.call(window,type,listener,options)}//清除残留事件return()=>{console.log('需要卸载的全局事件',eventListenerMap)//清除window绑定事件if(eventListenerMap.size){//解绑其余未解绑的函数eventListenerMap.forEach((listenerList,type)=>{if(listenerList.size){for(constlisteneroflistenerList){rawWindowRemoveEventListener.call(window,type,listener)}}})eventListenerMap.clear()}}}执行沙盒的构造函数中的effect方法得到卸载的hook函数releaseEffect在沙盒关闭时执行卸载操作,即在stop方法中执行releaseEffect函数///src/sandbox.jsexportdefaultclassSandBox{...//修改js作用域构造函数(){//卸载挂钩+this.releaseEffect=effect(this.microWindow)...}stop(){if(this.active){this.active=false//清除变量this.injectedKeys.forEach((key)=>{Reflect.deleteProperty(this.microWindow,key)})this.injectedKeys.clear()//卸载全局事件+this.releaseEffect()}}}重写全局事件并卸载的操作基本完成,我们来验证一下是否是运行正常先关闭沙箱,验证是否存在问题2:卸载子应用后滚动页面,仍然打印滚动条,说明事件没有卸载。启用沙箱后,卸载子应用并滚动页面。这时候卷轴就不再打印了,说明事件已经卸载了。从截图中可以看出,除了我们主动监听的scroll事件之外,还有error、unhandledrejection等全局事件。这些事件受框架、构建工具等第三方的约束。回收,造成内存泄漏。至此沙箱功能基本完成,两个问题都解决了。当然,沙盒需要解决的问题远不止这些,但基本的架构思路是不变的。结语JS沙箱的核心是修改js的范围,重写window。它的使用场景不局限于微前端,也可以用在其他地方。比如我们对外提供组件或者引入第三方组件,就可以使用沙箱。以避免冲突。下一篇我们将完成微前端的风格隔离。