前言
在大型项目中,微前端是一种常见的优化手段,本文就微前端中沙箱的机制及原理,作一下讲解。
首先什么是微前端
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
, 但是在前端项目的微前端实现上并没有起到太大的作用。
总结
本文列举了多种沙箱的实现方案,在目前的前端领域中,有着各类沙箱的实现,现在并没有一个完美的解决方案,更多的是在适合的场景采用适合的解决方案。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。