前言
自從微前端框架micro-app開源后,很多小伙伴都非常感興趣,問我是如何實現的,但這並不是幾句話可以說明白的。為了講清楚其中的原理,我會從零開始實現一個簡易的微前端框架,它的核心功能包括:渲染、JS沙箱、樣式隔離、數據通信。由於內容太多,會根據功能分成四篇文章進行講解,這是系列文章的第二篇:沙箱篇。
通過這些文章,你可以了解微前端框架的具體原理和實現方式,這在你以后使用微前端或者自己寫一套微前端框架時會有很大的幫助。如果這篇文章對你有幫助,歡迎點贊留言。
相關推薦
- micro-app倉庫地址
- simple-micro-app倉庫地址
- 從零開始寫一個微前端框架-渲染篇
- 從零開始寫一個微前端框架-沙箱篇
- 從零開始寫一個微前端框架-樣式隔離篇
- 從零開始寫一個微前端框架-數據通信篇
- micro-app介紹
開始
前一篇文章中,我們已經完成了微前端的渲染工作,雖然頁面已經正常渲染,但是此時基座應用和子應用是在同一個window下執行的,這有可能產生一些問題,如全局變量沖突、全局事件監聽和解綁。
下面我們列出了兩個具體的問題,然后通過創建沙箱來解決。
問題示例
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 = 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,它的使用場景不限於微前端,也可以用於其它地方,比如在我們向外部提供組件或引入第三方組件時都可以使用沙箱來避免沖突。
下一篇文章我們會完成微前端的樣式隔離。