MontoApp(六) - js 沙箱:自执行 Script 与 快照隔离
本节继续,介绍微前端中 js 隔离的方案。前一讲的 iframe 隔离,本质上是利用了 V8 引擎的隔离机制,借助 iframe 实现的;本节说的快照隔离,本质上还是把子应用执行在主应用上下文中,但是对主应用的 winodw 做快照,在子应用卸载的时候恢复快照即可。
1. 快照隔离沙箱原理
仓库地址:sandbox/snapshot-script 分支
2. 主应用服务
我们启动主应用服务:
app.listen(port.main, host);
并在主应用里写入接口请求:
app.post("/microapps", function (req, res) {
// 这里可以是管理后台新增菜单后存储到数据库的数据
// 从而可以通过管理后台动态配置微应用的菜单
res.json([
{
name: "micro1",
id: "micro1",
// 这里暂时以一个入口文件为示例
script: `http://${host}:${port.micro}/micro1.js`,
style: `http://${host}:${port.micro}/micro1.css`,
// 挂载到 window 上的启动函数 window.micro1_mount
mount: "micro1_mount",
// 挂载到 window 上的启动函数 window.micro1_unmount
unmount: "micro1_unmount",
prefetch: true,
},
{
name: "micro2",
id: "micro2",
script: `http://${host}:${port.micro}/micro2.js`,
style: `http://${host}:${port.micro}/micro2.css`,
mount: "micro2_mount",
unmount: "micro2_unmount",
prefetch: true,
},
]);
});
子应用设置
我们分别在两个子应用中各自配置自己的执行代码,任务设置全局的变量来验证沙箱的隔离效果:
// micro1.js
let root = document.createElement("h1");
root.textContent = "微应用1";
root.id = 'micro1-dom';
root.onclick = () => {
console.log("微应用1 的 window.a: ", window.a);
};
document.body.appendChild(root);
window.a = 1;
// micro2.js
let root = document.createElement("h1");
root.textContent = "微应用2";
root.id = 'micro2-dom';
root.onclick = () => {
console.log("微应用2 的 window.a: ", window.a);
};
document.body.appendChild(root);
window.a = 2;
3. 主应用渲染
我们配置主应用的入口,并设置用于验证的同名全局变量:
<!-- 主应用导航 -->
<div id="nav"></div>
<!-- 主应用内容区 -->
<div id="container"></div>
<script>
window.a = 3;
</script>
接着在主应用启动的脚本写入沙箱配置。
4. 实现沙箱
沙箱的实现步骤可以参见 iframe 沙箱的流程,唯一不同的是 Sandbox 类的实现。
我们实现沙箱类构造函数:
// 类主要成员变量
// 微应用 JS 运行之前的主应用 window 快照
mainWindow = {};
// 微应用 JS 运行之后的 window 对象(用于理解)
microWindow = {};
// 微应用失活后和主应用的 window 快照存在差异的属性集合
diffPropsMap = {};
constructor(options) {
this.options = options;
// 重新包装需要执行的微应用 JS 脚本
this.wrapScript = this.createWrapScript();
}
createWrapScript() {
// 微应用的代码运行在立即执行的匿名函数中,隔离作用域
return `;(function(window){
${this.options.scriptText}
})(window)`;
}
同样是闭包执行脚本,不同的是不再使用 window 的代理了,这里的 window 是主应用 window 的快照,下面会介绍实现。
激活
沙箱创建完成后,就是激活和卸载功能了,先看激活:
active() {
// 记录微应用 JS 运行之前的主应用 window 快照
this.recordMainWindow();
// 恢复微应用需要的 window 对象
this.recoverMicroWindow();
if (this.exec) {
return;
}
this.exec = true;
// 执行微应用(注意微应用的 JS 代码只需要被执行一次)
this.execWrapScript();
}
其中 recordMainWindow 用于记录挂载之前主应用的 window 状态:
recordMainWindow() {
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
this.mainWindow[prop] = window[prop];
}
}
}
recoverMicroWindow 是恢复上一次挂载子应用时的 window 状态:
recoverMicroWindow() {
// 如果微应用和主应用的 window 对象存在属性差异
// 上一次微应用 window = 主应用 window + 差异属性(在微应用失活前会记录运行过程中涉及到更改的 window 属性值,再次运行之前需要恢复修改的属性值)
Object.keys(this.diffPropsMap).forEach((p) => {
// 更改 JS 运行之前的微应用 window 对象,注意微应用本质上共享了主应用的 window 对象,因此一个时刻只能运行一个微应用
window[p] = this.diffPropsMap[p];
});
this.microWindow = window;
}
然后就是执行 脚本 了:
execWrapScript() {
// 在全局作用域内执行微应用代码
(0, eval)(this.wrapScript);
}
(0, eval) 是一个逗号表达式,其返回最右边的值,等价于 eval,之所以这么写,是因为逗号表达式之后返回的是一个对 eval 的间接调用,而间接调用计算出来的是一个值,而不是引用,相当于把这个函数单独声明出来再执行。
上面的代码 (0, eval)(this.wrapScript),相当于改变了 eval 执行对象为 wrapScript,改变了内部执行的 this 指针。
失效
我们再来看看解除激活状态的实现。
inactive() {
// 清空上一次记录的属性差异
this.diffPropsMap = {};
// 记录微应用运行后和主应用 Window 快照存在的差异属性
this.recordDiffPropsMap();
console.log(
`${this.options.appId} diffPropsMap: `,
this.diffPropsMap
);
}
其中 recordDiffPropsMap:
recordDiffPropsMap() {
// 这里的 microWindow 是微应用失活之前的 window(在微应用执行期间修改过 window 属性的 window)
for (const prop in this.microWindow) {
// 如果微应用运行期间存在和主应用快照不一样的属性值
if (
window.hasOwnProperty(prop) &&
this.microWindow[prop] !== this.mainWindow[prop]
) {
// 记录微应用运行期间修改或者新增的差异属性(下一次运行微应用之前可用于恢复微应用这一次运行的 window 属性)
this.diffPropsMap[prop] = this.microWindow[prop];
// 恢复主应用的 window 属性值
window[prop] = this.mainWindow[prop];
}
}
}
大致流程就是记录一下与原来 window 的差异,然后恢复原来记录的主应用的 window。
我们将上面的设计思路总结如下:
其余类的实现与 iframe 隔离一致,可以参见:iframe sandbox
5. 小结
本隔离方式实际是动态 script 的变种,没有创建 script 标签,而是直接在 window 快照中执行微应用的 js,缺点很明显:
- 不能同时加载多个子应用
- 无法处理主子应用同时操作 window 产生冲突的问题