浅析微前端沙箱

前言

在大型项目中,微前端是一种常见的优化手段,本文就微前端中沙箱的机制及原理,作一下讲解。

首先什么是微前端

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

前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

常见的微前端实现机制

image

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, 但是在前端项目的微前端实现上并没有起到太大的作用。

总结

本文列举了多种沙箱的实现方案,在目前的前端领域中,有着各类沙箱的实现,现在并没有一个完美的解决方案,更多的是在适合的场景采用适合的解决方案。

引用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章