家资是何物,积帙列梁梠。这篇文章主要讲述从零开始写一个微前端框架-沙箱篇相关的知识,希望能为你提供帮助。
前言自从微前端框架micro-app开源后,很多小伙伴都非常感兴趣,问我是如何实现的,但这并不是几句话可以说明白的。为了讲清楚其中的原理,我会从零开始实现一个简易的微前端框架,它的核心功能包括:渲染、JS沙箱、样式隔离、数据通信。由于内容太多,会根据功能分成四篇文章进行讲解,这是系列文章的第二篇:沙箱篇。
通过这些文章,你可以了解微前端框架的具体原理和实现方式,这在你以后使用微前端或者自己写一套微前端框架时会有很大的帮助。如果这篇文章对你有帮助,欢迎点赞留言。
相关推荐
- micro-app仓库地址
- simple-micro-app仓库地址
- 从零开始写一个微前端框架-渲染篇
- 从零开始写一个微前端框架-沙箱篇
- 从零开始写一个微前端框架-样式隔离篇
- 从零开始写一个微前端框架-数据通信篇
- micro-app介绍
下面我们列出了两个具体的问题,然后通过创建沙箱来解决。
问题示例
1、子应用向window上添加一个全局变量:
globalStr=\'child\'
,如果此时基座应用也有一个相同的全局变量:globalStr=\'parent\'
,此时就产生了变量冲突,基座应用的变量会被覆盖。2、子应用渲染后通过监听
scroll
添加了一个全局监听事件window.addEventListener(\'scroll\', () =>
{
console.log(\'scroll\')
})
当子应用被卸载时,监听函数却没有解除绑定,对页面滚动的监听一直存在。如果子应用二次渲染,监听函数会绑定两次,这显然是错误的。
接下来我们就通过给微前端创建一个JS沙箱环境,隔离基座应用和子应用的JS,从而解决这两个典型的问题,
创建沙箱
由于每个子应用都需要一个独立的沙箱,所以我们通过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 = https://www.songbingjia.com/android/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.jsexport 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.jsexport 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.jsexport 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,它的使用场景不限于微前端,也可以用于其它地方,比如在我们向外部提供组件或引入第三方组件时都可以使用沙箱来避免冲突。
下一篇文章我们会完成微前端的样式隔离。
推荐阅读
- 浅析 Map 和 WeakMap 区别以及使用场景
- 讲透学烂二叉树(二叉树的遍历图解算法步骤及JS代码)
- 几种常用设计模式的简单示例
- #导入MD文档图片#WebSocket的前后端使用
- 讲透学烂二叉树(二叉树的笔试题:翻转|宽度|深度)
- Ant Design Pro V4.5 从服务器请求数据的坑(typescript版)#导入MD文档
- Vue实现原理(图文讲解)
- go开发游戏开服程序
- 鸿蒙内测三批直通车-51CTO HarmonyOS技术社区专属绿色通道开启