自从微前端框架micro-app开源后,不少小伙伴都很是感兴趣,问我是如何实现的,但这并非几句话能够说明白的。为了讲清楚其中的原理,我会从零开始实现一个简易的微前端框架,它的核心功能包括:渲染、JS沙箱、样式隔离、数据通讯。因为内容太多,会根据功能分红四篇文章进行讲解,这是系列文章的第二篇:沙箱篇。前端
经过这些文章,你能够了解微前端框架的具体原理和实现方式,这在你之后使用微前端或者本身写一套微前端框架时会有很大的帮助。若是这篇文章对你有帮助,欢迎点赞留言。git
前一篇文章中,咱们已经完成了微前端的渲染工做,虽然页面已经正常渲染,可是此时基座应用和子应用是在同一个window下执行的,这有可能产生一些问题,如全局变量冲突、全局事件监听和解绑。github
下面咱们列出了两个具体的问题,而后经过建立沙箱来解决。segmentfault
一、子应用向window上添加一个全局变量:globalStr='child'
,若是此时基座应用也有一个相同的全局变量:globalStr='parent'
,此时就产生了变量冲突,基座应用的变量会被覆盖。缓存
二、子应用渲染后经过监听scroll
添加了一个全局监听事件前端框架
window.addEventListener('scroll', () => { console.log('scroll') })
当子应用被卸载时,监听函数却没有解除绑定,对页面滚动的监听一直存在。若是子应用二次渲染,监听函数会绑定两次,这显然是错误的。架构
接下来咱们就经过给微前端建立一个JS沙箱环境,隔离基座应用和子应用的JS,从而解决这两个典型的问题,app
因为每一个子应用都须要一个独立的沙箱,因此咱们经过class建立一个类:SandBox,当一个新的子应用被建立时,就建立一个新的沙箱与其绑定。框架
// /src/sandbox.js export default class SandBox { active = false // 沙箱是否在运行 microWindow = {} // // 代理的对象 injectedKeys = new Set() // 新添加的属性,在卸载时清空 constructor () {} // 启动 start () {} // 中止 stop () {} }
咱们使用Proxy进行代理操做,代理对象为空对象microWindow
,得益于Proxy强大的功能,实现沙箱变得简单且高效。函数
在constructor
中进行代理相关操做,经过Proxy代理microWindow
,设置get
、set
、deleteProperty
三个拦截器,此时子应用对window的操做基本上能够覆盖。
// /src/sandbox.js export default class SandBox { active = false // 沙箱是否在运行 microWindow = {} // // 代理的对象 injectedKeys = new Set() // 新添加的属性,在卸载时清空 constructor () { this.proxyWindow = new Proxy(this.microWindow, { // 取值 get: (target, key) => { // 优先从代理对象上取值 if (Reflect.has(target, key)) { return Reflect.get(target, key) } // 不然兜底到window对象上取值 const rawValue = Reflect.get(window, key) // 若是兜底的值为函数,则须要绑定window对象,如:console、alert等 if (typeof rawValue === 'function') { const valueStr = rawValue.toString() // 排除构造函数 if (!/^function\s+[A-Z]/.test(valueStr) && !/^class\s+/.test(valueStr)) { return rawValue.bind(window) } } // 其它状况直接返回 return rawValue }, // 设置变量 set: (target, key, value) => { // 沙箱只有在运行时能够设置变量 if (this.active) { Reflect.set(target, key, value) // 记录添加的变量,用于后续清空操做 this.injectedKeys.add(key) } return true }, deleteProperty: (target, key) => { // 当前key存在于代理对象上时才知足删除条件 if (target.hasOwnProperty(key)) { return Reflect.deleteProperty(target, key) } return true }, }) } ... }
建立完代理后,咱们接着完善start
和stop
两个方法,实现方式也很是简单,具体以下:
// /src/sandbox.js export default class SandBox { ... // 启动 start () { if (!this.active) { this.active = true } } // 中止 stop () { 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.js import loadHtml from './source' + import Sandbox from './sandbox' export default class CreateApp { constructor ({ name, url, container }) { ... + this.sandbox = new Sandbox(name) } ... mount () { ... + this.sandbox.start() // 执行js this.source.scripts.forEach((info) => { (0, eval)(info.code) }) } /** * 卸载应用 * @param destory 是否彻底销毁,删除缓存资源 */ unmount (destory) { ... + this.sandbox.stop() // destory为true,则删除应用 if (destory) { appInstanceMap.delete(this.name) } } }
咱们在上面建立了沙箱实例并启动沙箱,这样沙箱就生效了吗?
显然是不行的,咱们还须要将子应用的js经过一个with函数包裹,修改js做用域,将子应用的window指向代理的对象。形式如:
(function(window, self) { with(window) { 子应用的js代码 } }).call(代理对象, 代理对象, 代理对象)
在sandbox中添加方法bindScope
,修改js做用域:
// /src/sandbox.js export default class SandBox { ... // 修改js做用域 bindScope (code) { window.proxyWindow = this.proxyWindow return `;(function(window, self){with(window){;${code}\n}}).call(window.proxyWindow, window.proxyWindow, window.proxyWindow);` } }
而后在mount方法中添加对bindScope
的使用
// /src/app.js export default class CreateApp { mount () { ... // 执行js this.source.scripts.forEach((info) => { - (0, eval)(info.code) + (0, eval)(this.sandbox.bindScope(info.code)) }) } }
到此沙箱才真正起做用,咱们验证一下问题示例中的第一个问题。
先关闭沙箱,因为子应用覆盖了基座应用的全局变量globalStr
,当咱们在基座中访问这个变量时,获得的值为:child
,说明变量产生了冲突。
开启沙箱后,从新在基座应用中打印globalStr
的值,获得的值为:parent
,说明变量冲突的问题已经解决,沙箱正确运行。
第一个问题已经解决,咱们开始解决第二个问题:全局监听事件。
再来回顾一下第二个问题,错误的缘由是在子应用卸载时没有清空事件监听,若是子应用知道本身将要被卸载,主动清空事件监听,这个问题能够避免,但这是理想状况,一是子应用不知道本身什么时候被卸载,二是不少第三方库也有一些全局的监听事件,子应用没法所有控制。因此咱们须要在子应用卸载时,自动将子应用残余的全局监听事件进行清空。
咱们在沙箱中重写window.addEventListener
和window.removeEventListener
,记录全部全局监听事件,在应用卸载时若是有残余的全局监听事件则进行清空。
建立一个effect
函数,在这里执行具体的操做
// /src/sandbox.js // 记录addEventListener、removeEventListener原生方法 const rawWindowAddEventListener = window.addEventListener const rawWindowRemoveEventListener = window.removeEventListener /** * 重写全局事件的监听和解绑 * @param microWindow 原型对象 */ function effect (microWindow) { // 使用Map记录全局事件 const eventListenerMap = new Map() // 重写addEventListener microWindow.addEventListener = function (type, listener, options) { const listenerList = eventListenerMap.get(type) // 当前事件非第一次监听,则添加缓存 if (listenerList) { listenerList.add(listener) } else { // 当前事件第一次监听,则初始化数据 eventListenerMap.set(type, new Set([listener])) } // 执行原生监听函数 return rawWindowAddEventListener.call(window, type, listener, options) } // 重写removeEventListener microWindow.removeEventListener = function (type, listener, options) { const listenerList = eventListenerMap.get(type) // 从缓存中删除监听函数 if (listenerList?.size && listenerList.has(listener)) { listenerList.delete(listener) } // 执行原生解绑函数 return rawWindowRemoveEventListener.call(window, type, listener, options) } // 清空残余事件 return () => { console.log('须要卸载的全局事件', eventListenerMap) // 清空window绑定事件 if (eventListenerMap.size) { // 将残余的没有解绑的函数依次解绑 eventListenerMap.forEach((listenerList, type) => { if (listenerList.size) { for (const listener of listenerList) { rawWindowRemoveEventListener.call(window, type, listener) } } }) eventListenerMap.clear() } } }
在沙箱的构造函数中执行effect方法,获得卸载的钩子函数releaseEffect,在沙箱关闭时执行卸载操做,也就是在stop方法中执行releaseEffect函数
// /src/sandbox.js export default class SandBox { ... // 修改js做用域 constructor () { // 卸载钩子 + 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() } } }
这样重写全局事件及卸载的操做基本完成,咱们验证一下是否正常运行。
首先关闭沙箱,验证问题二的存在:卸载子应用后滚动页面,依然在打印scroll,说明事件没有被卸载。
开启沙箱后,卸载子应用,滚动页面,此时scroll再也不打印,说明事件已经被卸载。
从截图中能够看出,除了咱们主动监听的scroll
事件,还有error
、unhandledrejection
等其它全局事件,这些事件都是由框架、构建工具等第三方绑定的,若是不进行清空,会致使内存没法回收,形成内存泄漏。
沙箱功能到此就基本完成了,两个问题都已经解决。固然沙箱须要解决的问题远不止这些,但基本架构思路是不变的。
JS沙箱的核心在于修改js做用域和重写window,它的使用场景不限于微前端,也能够用于其它地方,好比在咱们向外部提供组件或引入第三方组件时均可以使用沙箱来避免冲突。
下一篇文章咱们会完成微前端的样式隔离。