前言
在大型專案中,微前端是一種常見的最佳化手段,本文就微前端中沙箱的機制及原理,作一下講解。
首先什麼是微前端
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. -- Micro Frontends
前端是一種多個團隊透過獨立釋出功能的方式來共同構建現代化 web 應用的技術手段及方法策略。
常見的微前端實現機制
iframe
如果你還是不瞭解什麼是微前端, 那麼就將它當做一種 iframe
即可, 但我們又為什麼不直接用它呢?
iframe
最大的特性就是提供了瀏覽器原生的硬隔離方案,不論是樣式隔離、js 隔離這類問題統統都能被完美解決。但他的最大問題也在於他的隔離性無法被突破,導致應用間上下文無法被共享,隨之帶來的開發體驗、產品體驗的問題。
- url 不同步。瀏覽器重新整理 iframe url 狀態丟失、後退前進按鈕無法使用。
- UI 不同步,DOM 結構不共享。想象一下螢幕右下角 1/4 的 iframe 裡來一個帶遮罩層的彈框,同時我們要求這個彈框要瀏覽器居中顯示,還要瀏覽器 resize 時自動居中..
- 全域性上下文完全隔離,記憶體變數不共享。iframe 內外系統的通訊、資料同步等需求,主應用的 cookie 要透傳到根域名都不同的子應用中實現免登效果。
- 慢。每次子應用進入都是一次瀏覽器上下文重建、資源重新載入的過程。
其中有的問題比較好解決(問題1),有的問題我們可以睜一隻眼閉一隻眼(問題4),但有的問題我們則很難解決(問題3)甚至無法解決(問題2),而這些無法解決的問題恰恰又會給產品帶來非常嚴重的體驗問題, 最終導致我們捨棄了 iframe 方案。
取自文章:Why Not Iframe
微前端沙箱
在微前端的場景,由於多個獨立的應用被組織到了一起,在沒有類似 iframe
的原生隔離下,勢必會出現衝突,如全域性變數衝突、樣式衝突,這些衝突可能會導致應用樣式異常,甚至功能不可用。
這時候我們就需要一個獨立的執行環境,而這個環境就叫做沙箱,即 sandbox
。
實現沙盒的第一步就是建立一個作用域。這個作用域不會包含全域性的屬性物件。
首先需要隔離掉瀏覽器的原生物件,但是如何隔離,建立一個沙箱環境呢?
基於代理(Proxy)的沙箱
假設當前一個頁面中只有一個微應用在執行,那他可以獨佔整個 window
環境, 在切換微應用時,只有將 window
環境恢復即可,保證下一個的使用。
這便是單例項場景。
單例項
一個最簡單的實現 demo
:
const varBox = {};
const fakeWindow = new Proxy(window, {
get(target, key) {
return varBox[key] || window[key];
},
set(target, key, value) {
varBox[key] = value;
return true;
},
});
window.test = 1;
透過一個簡單的 proxy
即可實現一個 window
的代理,將資料儲存到 varBox
中,而不影響原有的 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); // 建立一個原型為 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
方案來啟用或者解除安裝沙箱,核心的功能 proxy
的建立則在建構函式中
原理和上述的簡單 demo
中的實現類似,但是沒有直接攔截 window
, 而是建立一個 fakeWindow
,這就引出了我們要講的
多例項沙箱
多例項
我們把 fakeWindow
使用起來,將微應用使用到的變數放到 fakeWindow
中,而共享的變數都從 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;
基於 diff 的沙箱
他也叫做快照沙箱,顧名思義,即在某個階段給當前的執行環境打一個快照,再在需要的時候把快照恢復,從而實現隔離。
類似玩遊戲的 SL 大法,在某個時刻儲存起來,操作完畢再重新 Load,回到之前的狀態。
他的實現可以說是單例項的簡化版,分為啟用與解除安裝兩個部分的操作。
active() {
// 快取active狀態的沙箱
this.windowSnapshot = {};
for (const item in window) {
this.windowSnapshot[item] = window[item];
}
Object.keys(this.modifyMap).forEach(p => {
window[p] = this.modifyMap[p];
})
}
inactive() {
for (const item in window) {
if (this.windowSnapshot[item] !== window[item]) {
// 記錄變更
this.modifyMap[item] = window[item];
// 還原window
window[item] = this.windowSnapshot[item];
}
}
}
在 activate
的時候遍歷 window
上的變數,存為 windowSnapshot
在 deactivate
的時候再次遍歷 window
上的變數,分別和 windowSnapshot
對比,將不同的存到 modifyMap
裡,將 window
恢復
當應用再次切換的時候,就可以把 modifyMap
的變數恢復回 window 上,實現一次沙箱的切換。
class Sandbox {
private windowSnapshot
private modifyMap
activate: () => void;
deactivate: () => void;
}
const sandbox = new Sandbox();
sandbox.activate();
// 執行任意程式碼
sandbox.deactivate();
此方案在實際專案中實現起來要複雜的多,其對比演算法需要考慮非常多的情況,比如對於 window.a.b.c = 123
這種修改或者對於原型鏈的修改,這裡都不能做到回滾到應用載入前的全域性狀態。所以這個方案一般不作為首選方案,是對老舊瀏覽器的一種降級處理。
在 qiankun
中也有該降級方案,被稱為 SnapshotSandbox
。
基於 iframe 的沙箱
在上文講述了 iframe
作為微前端的一種實現方式,在沙箱中 iframe
也有他的獨特作用。
const iframe = document.createElement('iframe', { url: 'about:blank' });
const sandboxGlobal = iframe.contentWindow;
sandbox(sandboxGlobal);
注意:只有同域的 iframe 才能取出對應的的 contentWindow。所以需要提供一個宿主應用空的同域 URL 來作為這個 iframe 初始載入的 URL. 根據 HTML 的規範 這個 URL 用了 about:blank 一定保證保證同域,也不會發生資源載入。
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
總結一些,利用 iframe
沙箱可以實現以下特性:
- 全域性變數隔離,如
setTimeout
,location
,react
不同版本隔離 - 路由隔離,應用可以實現獨立路由,也可以共享全域性路由
- 多例項,可以同時存在多個獨立的微應用同時執行
- 安全策略,可以配置微應用對
Cookie
,localStorage
資源載入的限制
在沙箱方案上 iframe
是比較好的,但是仍然存在以下問題:
- 相容性問題, 不同的瀏覽器之間的實現方案可能存在差異,會導致相容性問題。
- 額外的效能開銷
- 相對於其他的方案,應用間的通訊手段更麻煩
基於 ShadowRealm 的沙箱
ShadowRealm
提議提供了一種新的機制,可在新的全域性物件和 JavaScript
內建程式集的上下文中執行 JavaScript
程式碼。
const sr = new ShadowRealm();
// Sets a new global within the ShadowRealm only
sr.evaluate('globalThis.x = "my shadowRealm"');
globalThis.x = "root"; //
const srx = sr.evaluate('globalThis.x');
srx; // "my shadowRealm"
x; // "root"
除了直接指向字串程式碼, 還可以引用檔案執行:
const sr = new ShadowRealm();
const redAdd = await sr.importValue('./inside-code.js', 'add');
let result = redAdd(2, 3);
console.assert(result === 5);
回到正題,ShadowRealm
在安全性上的限制很多,並且缺少一些資訊互動手段,最後他的相容性也是一大痛點:
截止目前 Chrome
版本 117.0.5938.48
, 並未支援此 API,我們仍然需要 polyfill
才能使用。
基於 VM 沙箱
VM 沙箱使用類似於 node
的 vm
模組,透過建立一個沙箱,然後傳入需要執行的程式碼。
const vm = require('node:vm');
const x = 1;
const context = { x: 2 };
vm.createContext(context); // Contextify the object.
const code = 'x += 40; var y = 17;';
// `x` and `y` are global variables in the context.
// Initially, x has the value 2 because that is the value of context.x.
vm.runInContext(code, context);
console.log(context.x); // 42
console.log(context.y); // 17
console.log(x); // 1; y is not defined.
vm
雖然在 node
中已實現了 sandbox
, 但是在前端專案的微前端實現上並沒有起到太大的作用。
總結
本文列舉了多種沙箱的實現方案,在目前的前端領域中,有著各類沙箱的實現,現在並沒有一個完美的解決方案,更多的是在適合的場景採用適合的解決方案。