社區(qū)精選|淺析微前端沙箱
今天小編為大家?guī)淼氖巧鐓^(qū)作者 Grewer 的文章,讓我們一起來學(xué)習(xí)淺析微前端沙箱。
前言
在大型項目中,微前端是一種常見的優(yōu)化手段,本文就微前端中沙箱的機(jī)制及原理,作一下講解。
首先什么是微前端
Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. -- Micro Frontends
前端是一種多個團(tuán)隊通過獨立發(fā)布功能的方式來共同構(gòu)建現(xiàn)代化 web 應(yīng)用的技術(shù)手段及方法策略。
常見的微前端實現(xiàn)機(jī)制

iframe
如果你還是不了解什么是微前端, 那么就將它當(dāng)做一種 iframe 即可, 但我們又為什么不直接用它呢?
iframe 最大的特性就是提供了瀏覽器原生的硬隔離方案,不論是樣式隔離、js 隔離這類問題統(tǒng)統(tǒng)都能被完美解決。但他的最大問題也在于他的隔離性無法被突破,導(dǎo)致應(yīng)用間上下文無法被共享,隨之帶來的開發(fā)體驗、產(chǎn)品體驗的問題。
url 不同步。瀏覽器刷新 iframe url 狀態(tài)丟失、后退前進(jìn)按鈕無法使用。
UI 不同步,DOM 結(jié)構(gòu)不共享。想象一下屏幕右下角 1/4 的 iframe 里來一個帶遮罩層的彈框,同時我們要求這個彈框要瀏覽器居中顯示,還要瀏覽器 resize 時自動居中..
全局上下文完全隔離,內(nèi)存變量不共享。iframe 內(nèi)外系統(tǒng)的通信、數(shù)據(jù)同步等需求,主應(yīng)用的 cookie 要透傳到根域名都不同的子應(yīng)用中實現(xiàn)免登效果。
慢。每次子應(yīng)用進(jìn)入都是一次瀏覽器上下文重建、資源重新加載的過程。
其中有的問題比較好解決(問題 1),有的問題我們可以睜一只眼閉一只眼(問題 4),但有的問題我們則很難解決(問題 3)甚至無法解決(問題 2),而這些無法解決的問題恰恰又會給產(chǎn)品帶來非常嚴(yán)重的體驗問題, 最終導(dǎo)致我們舍棄了 iframe 方案。
取自文章:Why Not Iframe
微前端沙箱
在微前端的場景,由于多個獨立的應(yīng)用被組織到了一起,在沒有類似 iframe 的原生隔離下,勢必會出現(xiàn)沖突,如全局變量沖突、樣式?jīng)_突,這些沖突可能會導(dǎo)致應(yīng)用樣式異常,甚至功能不可用。這時候我們就需要一個獨立的運(yùn)行環(huán)境,而這個環(huán)境就叫做沙箱,即 sandbox。
實現(xiàn)沙盒的第一步就是創(chuàng)建一個作用域。這個作用域不會包含全局的屬性對象。首先需要隔離掉瀏覽器的原生對象,但是如何隔離,建立一個沙箱環(huán)境呢?
基于代理(Proxy)的沙箱
假設(shè)當(dāng)前一個頁面中只有一個微應(yīng)用在運(yùn)行,那他可以獨占整個 window 環(huán)境, 在切換微應(yīng)用時,只有將 window 環(huán)境恢復(fù)即可,保證下一個的使用。
這便是單實例場景。
單實例
一個最簡單的實現(xiàn) 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 即可實現(xiàn)一個 window 的代理,將數(shù)據(jù)存儲到 varBox 中,而不影響原有的 window 的值
而在某些文章里,他把沙箱實現(xiàn)的更加具體,還擁有啟用和停用功能:
// 修改全局對象 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();// 持續(xù)記錄更新的(新增和修改的)全局變量的 map,用于在任意時刻做沙箱激活currentUpdatedPropsValueMap = new Map();// 應(yīng)用沙箱被激活active() {// 根據(jù)之前修改的記錄重新修改 window 的屬性,即還原沙箱之前的狀態(tài)this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));}// 應(yīng)用沙箱被卸載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); // 創(chuàng)建一個原型為 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)) {// 如果當(dāng)前 window 對象有該屬性,且未更新過,則記錄該屬性在 window 上的初始值const originalValue = window[prop];modifiedPropsOriginalValueMap.set(prop, originalValue);}// 記錄修改屬性以及修改后的值currentUpdatedPropsValueMap.set(prop, value);// 設(shè)置值到全局 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;// 關(guān)閉沙箱newSandBox.inactive();console.log(window.test, proxyWindow.test); // undefined undefined;// 重啟沙箱newSandBox.active();console.log(window.test, proxyWindow.test) // 1 1 ;
添加了沙箱的 active 和 inactive 方案來激活或者卸載沙箱,核心的功能 proxy 的創(chuàng)建則在構(gòu)造函數(shù)中 原理和上述的簡單 demo 中的實現(xiàn)類似,但是沒有直接攔截 window, 而是創(chuàng)建一個 fakeWindow,這就引出了我們要講的多實例沙箱
多實例
我們把 fakeWindow 使用起來,將微應(yīng)用使用到的變量放到 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) {// 優(yōu)先使用共享對象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 十分關(guān)鍵,因為我們的 fakeWindow 是一個空對象,window 上的屬性都沒有,* 實際項目中這里的 context 應(yīng)該包含大量的 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;/*** 每個環(huán)境的私有屬性是隔離的*/console.log(newSandBox1.test, newSandBox2.test, window.test); // 1 2 3;/*** 共享屬性是沙盒共享的,這里 newSandBox2 環(huán)境中的 globalData 也被改變了*/newSandBox1.globalData = '123';console.log(newSandBox1.globalData, newSandBox2.globalData); // 123 123;
基于 diff 的沙箱
他也叫做快照沙箱,顧名思義,即在某個階段給當(dāng)前的運(yùn)行環(huán)境打一個快照,再在需要的時候把快照恢復(fù),從而實現(xiàn)隔離。
類似玩游戲的 SL 大法,在某個時刻保存起來,操作完畢再重新 Load,回到之前的狀態(tài)。
他的實現(xiàn)可以說是單實例的簡化版,分為激活與卸載兩個部分的操作。
active() {// 緩存active狀態(tài)的沙箱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];// 還原windowwindow[item] = this.windowSnapshot[item];}}}
在 activate 的時候遍歷 window 上的變量,存為 windowSnapshot
在 deactivate 的時候再次遍歷 window 上的變量,分別和 windowSnapshot 對比,將不同的存到 modifyMap 里,將 window 恢復(fù)
當(dāng)應(yīng)用再次切換的時候,就可以把 modifyMap 的變量恢復(fù)回 window 上,實現(xiàn)一次沙箱的切換。
class Sandbox {private windowSnapshotprivate modifyMapactivate: () => void;deactivate: () => void;}const sandbox = new Sandbox();sandbox.activate();// 執(zhí)行任意代碼sandbox.deactivate();
此方案在實際項目中實現(xiàn)起來要復(fù)雜的多,其對比算法需要考慮非常多的情況,比如對于 window.a.b.c = 123 這種修改或者對于原型鏈的修改,這里都不能做到回滾到應(yīng)用加載前的全局狀態(tài)。所以這個方案一般不作為首選方案,是對老舊瀏覽器的一種降級處理。
在 qiankun 中也有該降級方案,被稱為 SnapshotSandbox。
基于 iframe 的沙箱
在上文講述了 iframe 作為微前端的一種實現(xiàn)方式,在沙箱中 iframe 也有他的獨特作用。
const iframe = document.createElement('iframe', { url: 'about:blank' });const sandboxGlobal = iframe.contentWindow;sandbox(sandboxGlobal);
注意:只有同域的 iframe 才能取出對應(yīng)的的 contentWindow。所以需要提供一個宿主應(yīng)用空的同域 URL 來作為這個 iframe 初始加載的 URL。根據(jù) HTML 的規(guī)范 這個 URL 用了 about:blank 一定保證保證同域,也不會發(fā)生資源加載。
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) {// 優(yōu)先使用共享對象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' 沙箱環(huán)境全局變量// window.abc 為 undefined
總結(jié)一些,利用 iframe 沙箱可以實現(xiàn)以下特性:
全局變量隔離,如
setTimeout,location,react不同版本隔離路由隔離,應(yīng)用可以實現(xiàn)獨立路由,也可以共享全局路由
多實例,可以同時存在多個獨立的微應(yīng)用同時運(yùn)行
安全策略,可以配置微應(yīng)用對
Cookie,localStorage資源加載的限制
在沙箱方案上 iframe 是比較好的,但是仍然存在以下問題:
兼容性問題, 不同的瀏覽器之間的實現(xiàn)方案可能存在差異,會導(dǎo)致兼容性問題。
額外的性能開銷
相對于其他的方案,應(yīng)用間的通信手段更麻煩
基于 ShadowRealm 的沙箱
ShadowRealm 提議提供了一種新的機(jī)制,可在新的全局對象和 JavaScript 內(nèi)置程序集的上下文中執(zhí)行 JavaScript 代碼。
const sr = new ShadowRealm();// Sets a new global within the ShadowRealm onlysr.evaluate('globalThis.x = "my shadowRealm"');globalThis.x = "root"; //const srx = sr.evaluate('globalThis.x');srx; // "my shadowRealm"x; // "root"
除了直接指向字符串代碼, 還可以引用文件執(zhí)行:
const sr = new ShadowRealm();const redAdd = await sr.importValue('./inside-code.js', 'add');let result = redAdd(2, 3);console.assert(result === 5);
點此查看詳細(xì)介紹
回到正題,ShadowRealm 在安全性上的限制很多,并且缺少一些信息交互手段,最后他的兼容性也是一大痛點:
截止目前 Chrome 版本 117.0.5938.48, 并未支持此 API,我們?nèi)匀恍枰?polyfill 才能使用。
基于 VM 沙箱
VM 沙箱使用類似于 node 的 vm 模塊,通過創(chuàng)建一個沙箱,然后傳入需要執(zhí)行的代碼。
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); // 42console.log(context.y); // 17console.log(x); // 1; y is not defined.
vm 雖然在 node 中已實現(xiàn)了 sandbox, 但是在前端項目的微前端實現(xiàn)上并沒有起到太大的作用。
總結(jié)
本文列舉了多種沙箱的實現(xiàn)方案,在目前的前端領(lǐng)域中,有著各類沙箱的實現(xiàn),現(xiàn)在并沒有一個完美的解決方案,更多的是在適合的場景采用適合的解決方案。
引用
-
https://www.garfishjs.org/blog -
https://qiankun.umijs.org/zh/guide -
https://zqianduan.com/pages/micro-app-sandbox.html
往期推薦
社區(qū)精選|現(xiàn)代 CSS 解決方案:原生嵌套(Nesting)
社區(qū)精選|都用 HTTPS 了,還能被查出瀏覽記錄?
社區(qū)精選|談?wù)?H5 移動端適配原理
