淺析微前端沙箱

發表於2023-09-18

前言

在大型專案中,微前端是一種常見的最佳化手段,本文就微前端中沙箱的機制及原理,作一下講解。

首先什麼是微前端

Techniques, strategies and recipes for building a modern web app with multiple teams that can ship features independently. -- Micro Frontends

前端是一種多個團隊透過獨立釋出功能的方式來共同構建現代化 web 應用的技術手段及方法策略。

常見的微前端實現機制

img.png

iframe

如果你還是不瞭解什麼是微前端, 那麼就將它當做一種 iframe 即可, 但我們又為什麼不直接用它呢?

iframe 最大的特性就是提供了瀏覽器原生的硬隔離方案,不論是樣式隔離、js 隔離這類問題統統都能被完美解決。但他的最大問題也在於他的隔離性無法被突破,導致應用間上下文無法被共享,隨之帶來的開發體驗、產品體驗的問題。

  1. url 不同步。瀏覽器重新整理 iframe url 狀態丟失、後退前進按鈕無法使用。
  2. UI 不同步,DOM 結構不共享。想象一下螢幕右下角 1/4 的 iframe 裡來一個帶遮罩層的彈框,同時我們要求這個彈框要瀏覽器居中顯示,還要瀏覽器 resize 時自動居中..
  3. 全域性上下文完全隔離,記憶體變數不共享。iframe 內外系統的通訊、資料同步等需求,主應用的 cookie 要透傳到根域名都不同的子應用中實現免登效果。
  4. 慢。每次子應用進入都是一次瀏覽器上下文重建、資源重新載入的過程。

其中有的問題比較好解決(問題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 ;

新增了沙箱的 activeinactive 方案來啟用或者解除安裝沙箱,核心的功能 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 是比較好的,但是仍然存在以下問題:

  1. 相容性問題, 不同的瀏覽器之間的實現方案可能存在差異,會導致相容性問題。
  2. 額外的效能開銷
  3. 相對於其他的方案,應用間的通訊手段更麻煩

基於 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 沙箱使用類似於 nodevm 模組,透過建立一個沙箱,然後傳入需要執行的程式碼。

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, 但是在前端專案的微前端實現上並沒有起到太大的作用。

總結

本文列舉了多種沙箱的實現方案,在目前的前端領域中,有著各類沙箱的實現,現在並沒有一個完美的解決方案,更多的是在適合的場景採用適合的解決方案。

引用