| 導語 在過去,浏覽器沙箱(sandbox)主要應用在前端安全領域,随着應用架構複雜,微前端方案的出現,js運作環境沙箱在浏覽器中的需求越來越多。特别是近幾年比較火的微前端領域,js沙箱是其比較核心一個技術點,是整個微前端方案的實作的關鍵點之一。
微前端對于沙箱的訴求
沙箱在微前端架構中不是必須要做的事情,因為如果規範做的足夠好,是能夠避免掉一些變量沖突讀寫,CSS 樣式沖突的情況。但是如果你在一個足夠大的體系中,僅僅通過規範來保證應用的可靠性面臨較大的風險,還是需要技術手段去治理運作時的一些沖突問題,這個也是沙箱方案成為微前端技術體系的一部分原因。
傳統的js沙箱主要用于執行一些不可信任的js腳本,其對沙箱的包裝隻需要一個可執行的js環境即可,一般會屏蔽對location document等重要全局對象的通路,同時一般為一次性執行,執行完第三方腳本後會釋放沙箱環境。微前端領域的沙箱對于提出了更高的訴求,需要可能通路幾乎所有的全局對象,因為我們很難限制一個子應用在開發過程中使用的全局變量。需要同時支援多個沙箱環境存在,每個沙箱需要有加載、解除安裝、再次恢複的能力,其對應着微應用的運作生命周期。
在主流的微前端方案中,有一個關鍵點決定了沙箱如何做:同一時刻是單執行個體還是多執行個體存在宿主應用中。這決定了沙箱的複雜度和技術實作。
• 單執行個體:同一個時刻隻有一個微應用執行個體存在,此刻浏覽器所有浏覽器資源都是這個應用獨占的,這種方案要解決的很大程度是應用切換的時候的變量污染清理與應用再次啟動時的變量恢複。這種一般通過全局對象的代理來實作。
• 多執行個體:資源不是應用獨占,需要解決資源共享的問題,比如路由,樣式,全局變量讀寫,DOM。這種情況下不同沙盒需要共享着一些全局變量,甚至涉及到不同微應用間的通信訴求。實作起來一般比較複雜,容易造成變量的全局沖突。
主流實作方案
一個js沙箱是一個獨立的執行上下文或者叫作用域,我們把代碼傳入後,其執行不會影響到其他的沙盒環境。是以實作沙盒的第一步就是建立一個作用域。這個作用域不會包含全局的屬性對象。首先需要隔離掉浏覽器的原生對象,但是如何隔離,建立一個沙箱環境呢?Node 中 有 vm 子產品,來實作類似的能力,在浏覽器中我們可以利用了閉包的能力,利用變量作用域去模拟一個沙箱上下文環境,比如下面的代碼:
function sandbox(global) {
console.log(global.document);
}
foo({
document: '我是自定義屬性';
});
上面這段代碼執行 輸出 我是自定義屬性。而不是浏覽器的document對象。
有了上下文環境後,我們需要實作應用運作時依賴的window上的全局對象,比如location、history、document、XMLHttpRequest等微應用運作時需要用到的全局對象。這些全局對象的模拟實作起來成本非常高,然而通過 new iframe對象,把裡面的原生浏覽器對象通過contentWindow取出來,這個iframe的window天然具有所有全局屬性,而且與主應用運作的window環境隔離。
基于iframe的沙箱環境實作
基于iframe方案實作上比較取巧,利用浏覽器iframe環境隔離的特性。iframe标簽可以創造一個獨立的浏覽器原生級别的運作環境,這個環境被浏覽器實作了與主環境的隔離。同時浏覽器提供了postmessage等方式讓主環境與iframe環境可以實作通信,這就讓基于iframe的沙箱環境成為可能。
const iframe = document.createElement('iframe',{url:'about:blank'});
const sandboxGlobal = iframe.contentWindow;
foo(sandboxGlobal);
// 此時的document為iframe内部對象
注意:隻有同域的 iframe 才能取出對應的的 contentWindow. 是以需要提供一個宿主應用空的同域URL來作為這個 iframe 初始加載的 URL. 根據 HTML 的規範 這個 URL 用了 about:blank 一定保證保證同域,也不會發生資源加載。
在微前端方案中,我們除了需要一個隔離的window環境外,其實還需要共享一些全局對象,比如histroy,如果希望子應用的路由可以通過目前浏覽器的傳回前進按鈕操作,那麼微應用環境中的histroy需要使用目前浏覽器環境的history對象。而像XMLHttpRequest這種請求對象則可以使用iframe環境中的。同時所有微應用主動建立的全局變量都在iframe的window環境中,是以,在具體運作時,我們需要把共享的全局對象傳入微應用的運作環境中,這裡我們使用Proxy代理對象通路來實作。
class SandboxWindow {
constructor(options, context, frameWindow){
return new Proxy(frameWindow,{
set(target, name, value){
if(Object.keys(context).includes(name)){
context[name] = value;
}
target[name] = value;
},
get(target,name){
// 優先使用共享對象
if(Object.keys(context).includes(name)){
return context[name];
}
if( typeof target[ name ] === 'function' && /^[a-z]/.test( name ) ){
return target[ name ].bind && target[ name ].bind( target );
}else{
return target[ name ];
}
}
})
}
// ...
}
const iframe = document.createElement('iframe',{url:'about:blank'});
document.body.appendChild(iframe);
const sandboxGlobal = iframe.contentWindow;
// 需要全局共享的變量
const context = { document:window.document, history: window.histroy }
const newSandBoxWindow = new SandboxWindow({}, context, sandboxGlobal)
// newSandBoxWindow.history 全局對象
// newSandBoxWindow.abc 為 'abc' 沙箱環境全局變量
// window.abc 為 undefined
上面的實作newSandBoxWindow就是一個帶有隔離運作環境的全局上下文,我們的微應用js代碼就可以在這個上下文中運作,做到不污染全局環境的目的,當然我們也可以對我們的運作環境進行配置,比如能否使用全局的alert方案,能否讀取cookie,通路全局localStorage對象等,比如為了文檔能夠被加載在同一個 DOM 樹上,對于 document, 大部分的 DOM 操作的屬性和方法還是直接用的宿主浏覽器中的 document 的屬性和方法。
由于子應用有自己的沙箱環境,之前所有獨占式的資源現在都變成了應用獨享(比如 常用的redux、fetch等),是以子應用也能同時被加載. 并且對于一些變量的我們還能在 proxy 中設定一些通路權限的事情,進而限制子應用的能力,比如 Cookie, LocalStoage 讀寫。
當這個 iframe 被移除時,寫在 newSandBoxWindow 的變量和設定的一些 timeout 時間也會一并被移除。(當然 DOM 事件需要沙箱記錄,然後在宿主中移除)。當然,應用解除安裝時一般不會解除安裝iframe,當再次進入這個微應用時,其運作環境都還在 newSandBoxWindow 上。
總結一些,利用iframe沙箱可以實作以下特性:
- 全局變量隔離,如setTimeout、location、react不同版本隔離
- 路由隔離,應用可以實作獨立路由,也可以共享全局路由
- 多執行個體,可以同時存在多個獨立的微應用同時運作
- 安全政策,可以配置微應用對Cookie localStorage 資源加載的限制
有了以上的全局環境,那麼通過建構一個閉包運作微應用代碼,就可以讓應用run起來了。
const newSandBoxWindow = SandboxWindow({}, context, sandboxGlobal);
const codeStr = 'var test = 1;'
const run = (code)=>{
window.eval(`
;(function(global, self){with(global){;${code}}}).bind(newSandBoxWindow)(newSandBoxWindow, newSandBoxWindow);
`)
}
run(codeStr);
console.log(newSandBoxWindow.window.test); // 1
console.log(window.test); // undefined
// 操作沙箱環境下的全局變量
newSandBoxWindow.history.pushState(null, null, '/index');
newSandBoxWindow.locaiton.hash = 'about'
基于Proxy+window更新的沙箱實作
在單執行個體的場景中,同一時刻隻有一個微應用在運作,是以其可以單獨占用window環境,不會存在與其他微應用變量沖突的問題。但是當應用切換時,我們需要提供一個幹淨的window環境,保證下一個微應用的正常運作。一個微應用的生命周期大概分為加載、mount、umount等過程。那麼我們可以在微應用解除安裝之後,删除其對window環境的修改,為下一個微應用的渲染準備環境。這樣每次微應用切換時其都有一個幹淨的全局環境。
基于以上思路,我們可以通過對window對象的修改進行記錄,在解除安裝時删除這些記錄,在應用再次激活時恢複這些記錄,來達到模拟沙箱環境的目的。
主要方案為生成一個代替window對象的委托,set,get時實際操作的window對象屬性同時記錄操作行為,active,inactive時釋放操作行為使window對象還原。
/** 修改全局對象window方法 */
const setWindowProp = (prop,value,isDel)=>{
if (value === undefined || isDel) {
delete window[prop];
} else {
window[prop] = value;
}
}
class Sandbox {
name;
proxy = null;
/** 沙箱期間新增的全局變量 */
addedPropsMap = new Map();
/** 沙箱期間更新的全局變量 */
modifiedPropsOriginalValueMap = new Map();
/** 持續記錄更新的(新增和修改的)全局變量的 map,用于在任意時刻做沙箱激活 */
currentUpdatedPropsValueMap = new Map();
/** 應用沙箱被激活 */
active() {
// 根據之前修改的記錄重新修改window的屬性,即還原沙箱之前的狀态
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
}
/** 應用沙箱被解除安裝 */
inactive() {
// 1 将沙箱期間修改的屬性還原為原先的屬性
this.modifiedPropsOriginalValueMap.forEach((v, p) => setWindowProp(p, v));
// 2 将沙箱期間新增的全局變量消除
this.addedPropsMap.forEach((_, p) => setWindowProp(p, undefined, true));
}
constructor(name) {
this.name = name;
const fakeWindow = Object.create(null);
const { addedPropsMap, modifiedPropsOriginalValueMap, currentUpdatedPropsValueMap } = this;
const proxy = new Proxy(fakeWindow, {
set(_, prop, value) {
if(!window.hasOwnProperty(prop)){
// 如果window上沒有的屬性,記錄到新增屬性裡
addedPropsMap.set(prop, value);
}else if(!modifiedPropsOriginalValueMap.has(prop)){
// 如果目前window對象有該屬性,且未更新過,則記錄該屬性在window上的初始值
const originalValue = window[prop];
modifiedPropsOriginalValueMap.set(prop,originalValue);
}
// 記錄修改屬性以及修改後的值
currentUpdatedPropsValueMap.set(prop, value);
// 設定值到全局window上
setWindowProp(prop,value);
console.log('window.prop',window[prop])
return true;
},
get(target, prop) {
return window[prop];
},
});
this.proxy = proxy;
}
}
// 初始化一個沙箱
const newSandBox = new Sandbox('app1');
const proxyWindow = newSandBox.proxy;
proxyWindow.test = 1;
console.log(window.test, proxyWindow.test) // 1 1;
// 關閉沙箱
newSandBox.inactive();
console.log(window.test, proxyWindow.test); // undefined undefined;
// 重新開機沙箱
newSandBox.active();
console.log(window.test, proxyWindow.test) // 1 1 ;
上面的方案中,我們實作了對全局對象的代理,通過沙箱的active 和inactive方案來激活或者解除安裝沙箱,達到更新window環境的目的。我們的proxyWindow隻是一個空代理,所有的變量還是存在全局的window上的。以上方式有一個明顯的劣勢,同一時刻隻能有一個激活的沙箱,否則全局對象上的變量會有兩個以上的沙箱更新,造成全局變量沖突。所有這種方案比較适合單執行個體的微前端場景。
基于Proxy+fakeWinodw的多執行個體沙箱實作
在上面的方案中,我們的fakeWindow是一個空的對象,其沒有任何儲存變量的功能,微應用建立的變量最終實際都是挂載在window上的,這就限制了同一時刻不能有兩個激活的微應用。能否實作多執行個體沙箱呢,答案是肯定的,我們可以把fakeWindow使用起來,将微應用使用到的變量放到fakeWindow中,而共享的變量都從window中讀取。需要注意的是,這種場景下需要判斷特殊屬性,比如不可配置修改的屬性,就直接從window中擷取,需要建立一個共享window變量的配置表。
class Sandbox {
name;
constructor(name, context={}){
this.name = name;
const fakeWindow = Object.create({});
return new Proxy(fakeWindow,{
set(target, name, value){
if(Object.keys(context).includes(name)){
context[name] = value;
}
target[name] = value;
},
get(target,name){
// 優先使用共享對象
if(Object.keys(context).includes(name)){
return context[name];
}
if( typeof target[ name ] === 'function' && /^[a-z]/.test(name)){
return target[ name ].bind && target[ name ].bind( target );
}else{
return target[ name ];
}
}
})
}
// ...
}
/*
* 注意這裡的context十分關鍵,因為我們的fakeWindow是一個空對象,window上的屬性都沒有,
* 實際項目中這裡的context應該包含大量的window屬性,
*/
// 初始化2個沙箱,共享doucment與一個全局變量
const context = { document: window.document, globalData:'abc'};
const newSandBox1 = new Sandbox('app1',context);
const newSandBox2 = new Sandbox('app2',context);
newSandBox1.test = 1;
newSandBox2.test = 2;
window.test = 3;
/*
* 每個環境的私有屬性是隔離的
*/
console.log(newSandBox1.test, newSandBox2.test, window.test) // 1 2 3;
/*
* 共享屬性是沙盒共享的,這裡newSandBox2環境中的globalData也被改變了
*/
newSandBox1.globalData = '123';
console.log(newSandBox1.globalData, newSandBox2.globalData) // 123 123;
以上是在對于多執行個體沙箱的簡單實作,實際項目的需要考慮的問題非常多,比如說對于全局屬性的可通路性配置,通過constroctor通路原型鍊等,都可以讀取到原生的window。
基于diff實作沙箱
以上的方案中是基于es6 的Proxy API的,IE11以下版本的浏覽器不支援Proxy API,社群也有一種降級的實作方式。在運作的時候儲存一個快照window對象,将目前window對象的全部屬性都複制到快照對象上,子應用解除安裝的時候将window對象修改做個diff,将不同的屬性用個modifyMap儲存起來,再次挂載的時候再加上這些修改的屬性。本質類似于proxy單執行個體的方案。這種方式也無法支援多執行個體,因為運作期間所有的屬性都是儲存在window上的。
以上是基于快照實作的一個簡易微前端沙箱環境。通過其激活與解除安裝來記錄與修改window上增加的全局變量,進而在不同微前端切換時候,能有一個幹淨的運作環境。而當應用二次進入時則再恢複至 mount 前的狀态的,進而確定應用在 remount 時擁有跟第一次 mount 時一緻的全局上下文。此方案在實際項目中實作起來要複雜的多,其對比算法需要考慮非常多的情況,比如對于window.a.b.c = 123 這種修改或者對于原型鍊的修改,這裡都不能做到復原到應用加載前端的全局狀态。是以這個方案一般不作為首選方案,是對老舊浏覽器的一種降級處理。
當然沙箱裡做的事情還遠不止這些,其他的還包括一些對全局事件監聽的劫持等,以確定應用在切出之後,對全局事件的監聽能得到完整的解除安裝,同時也會在 remount 時重新監聽這些全局事件,進而模拟出與應用獨立運作時一緻的沙箱環境。
總結
傳統的js沙箱注意是考慮的安全領域,鎖定不信任腳本的執行,防止全局變量的擷取與修改等。在微前端領域,側重點不太一樣,重要的是不能有全局變量的污染,這也決定了微前端的沙箱實作重點是對于獨立運作環境的構造。由于js的靈活性,在微前端的沙盒裡通過原型鍊等方式是可以拿到全局window變量的内容的。這個時候我們就還需要配合一定的規範來做代碼隔離,而不僅僅是依靠沙箱環境。

近期熱文
一場AI技術與“CD光牒行動”的瘋狂實驗
基于時間線的Feed流背景系統設計
利用招投标資訊梳理行業打法的方法
喜歡本文?快點“在看”支援一下