MontoApp(六) - js 沙箱:自执行 Script 与 快照隔离

本节继续,介绍微前端中 js 隔离的方案。前一讲的 iframe 隔离,本质上是利用了 V8 引擎的隔离机制,借助 iframe 实现的;本节说的快照隔离,本质上还是把子应用执行在主应用上下文中,但是对主应用的 winodw 做快照,在子应用卸载的时候恢复快照即可。

1. 快照隔离沙箱原理

image.png

仓库地址: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。

我们将上面的设计思路总结如下:

image.png

其余类的实现与 iframe 隔离一致,可以参见:iframe sandbox

5. 小结

本隔离方式实际是动态 script 的变种,没有创建 script 标签,而是直接在 window 快照中执行微应用的 js,缺点很明显:

  • 不能同时加载多个子应用
  • 无法处理主子应用同时操作 window 产生冲突的问题

Was this page helpful?