Skip to main content

沙箱

前言​

当多个子应用独立运行时,每个应用都会有自己的运行环境,这看起来很美好,对吧。但是运行在宿主应用 里应用,需要如何避免自身的环境被 宿主应用 影响,或者影响 宿主应用,即 应用间运行时如何相互隔离,比如说常见的如何实现 JS 隔离,CSS 隔离。

一般的,常见的方案会有下面这些:

  • 基于 iframe + 消息通信的实现
  • 基于 Proxy 快照存储 + window 修改的实现
  • 基于 Proxy 代理拦截 + window 激活/卸载的实现
  • 基于普通对象快照存储的 window 属性 diff 实现
  • 基于 ShadowRealm 提案的实现
  • 基于 with + eval 的简单实现
  • Webpack 5 Module Federation(模块联邦)

为什么不选择 iframe​

const iframe = document.createElement('iframe', {
src: 'about:blank',
});

document.body.appendChild(iframe);

const sandboxGlobal = iframe.contentWindow;

更多参考 iframe sandbox

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。(好解决)
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中。 (难以解决)
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。(难以解决)
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程,加载优化、运行优化问题 (好解决)

为什么不选择 Module Federation​

Module Federation 支持多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。

这个方案中有两个主体: Remote 和 Host ,可以把 Remote 理解为想要引入的子应用,把 Host 理解为主应用 中的作用域(scope) 只有全局作用域(global scope) 、函数作用域(function scope) 以及从 ES6 开始才有的块级作用域(block scope) 。但是一个应用既可以是 Remote 也可以是 Host,并不矛盾) 。

但是如果接入的应用对 shared 依赖包 有强依赖关系,就没办法使用这种方案,必须保持一致。

Module Federation 的核心在于 ModuleFederationPlugin 这个插件:

new ModuleFederationPlugin({
name: 'App1',
library: {type: 'var', name: 'App1'},
filename: 'remoteEntry.js',
remotes: {
app_02: 'App2',
app_03: 'App3',
},
exposes: {
antd: './src/antd',
button: './src/button',
},
shared: ['react', 'react-dom'],
});
  • name: 必须,唯一 ID,作为输出的模块名,使用的时通过 name/{expose} 的方式使用;
  • library: 必须,其中这里的 name 为作为 umd 的 name。
  • remotes: 可选,表示作为 Host 时,去消费哪些 Remote。
  • exposes: 可选,表示作为 Remote 时,export 哪些属性被消费。
  • shared: 可选,优先用 Host 的依赖,如果 Host 没有,再用自己的。

定义​

又称为 sandbox, 指一个允许你独立运行程序的虚拟环境。它能够有效地隔离、收集、清除应用在运行期间所产生的副作用,保证 当前执行环境 作用域和 外部 其他作用域相互独立,互不影响。

比如说全局变量、全局事件、定时器、网络请求、localStorage、Style 样式、DOM 元素。

沙盒的优势​

  • 开发者体验不到环境的区别
  • 运行没有环境差异

分类​

一般的分为 快照沙箱 和 VM 沙箱。

二者对比​

特性快照VM
子应用之间隔离✅✅
主子应用隔离❌✅
变量隔离✅✅
样式隔离❌✅
多实例隔离✅✅
前置

本文会通过 Garfish 的实现原理来分析,欢迎和我一起看代码

快照沙箱​

它主要是通过对全局的 window 变量 进行操作,大致执行步骤是

  1. 存储当前执行环境
  2. 执行具备有副作用的代码
  3. 恢复执行环境

Fly to code path: packages/browser-snapshot/src/sandbox.ts:L31

// packages/browser-snapshot/src/patchers/style.ts
export class PatchStyle {
private domSnapshotBefore!: Snapshot;
private domSnapshotMutated!: SnapshotDiff | null;

public activate() {
// 记录当前dom节点、恢复之前dom节点副作用
this.domSnapshotBefore = Snapshot.take();

if (this.domSnapshotMutated) {
this.headInterceptor.add(
this.domSnapshotMutated.created,
this.domSnapshotMutated.removed
);
}
}

public deactivate() {
// 恢复沙盒运行前dom节点环境,并将差异值进行缓存
const domSnapshot = Snapshot.take();
this.domSnapshotMutated = domSnapshot.diff(this.domSnapshotBefore);

if (!this.domSnapshotMutated) return;
this.headInterceptor.remove(
this.domSnapshotMutated.created,
this.domSnapshotMutated.removed
);
}

// ...
}

VM 沙箱​

原理​

在 JavaScript 中的作用域只有全局作用域(global scope)、函数作用域(function scope)以及从 ES6 开始才有的块级作用域(block scope)。

如果要将一段代码中的变量、函数等的定义隔离出来,我们一般会通过这么几种方式来达到目的:

自执行函数

当函数变成立即执行的函数表达式时,表达式中的变量不能从外部访问,它拥有独立的词法作用域。不仅避免了外界访问 IIFE 中的变量,而且又不会污染全局作用域,弥补了 JavaScript 在 scope 方面的缺陷。

(function foo() {
const name = 'Rain120';

console.log(name);
})();

CSS​

// packages/browser-vm/src/dynamicNode/processor.ts:L315
// ...

// The style node needs to be placed in the sandbox root container
else if (this.is('style')) {
parentNode = this.findParentNodeInApp(context, 'head');

const manager = new StyleManager(this.el.textContent);
manager.correctPath(baseUrl);

if (styleScopeId) {
manager.setScope({
appName: namespace,
rootElId: styleScopeId(),
});
}
this.el.textContent = manager.transformCode(manager.styleCode);
convertedNode = this.el;

this.sandbox.dynamicStyleSheetElementSet.add(this.el);
this.monitorChangesOfStyle();
}

JS​

// packages/browser-vm/src/dynamicNode/processor.ts:L139

// Load dynamic js script
private addDynamicScriptNode() {
const { src, type, crossOrigin } = this.el;
const isModule = type === 'module';
const code = this.el.textContent || this.el.text || '';

if (!type || isJsType({ src, type })) {
// The "src" higher priority
const { baseUrl, namespace } = this.sandbox.options;
if (src) {
const fetchUrl = baseUrl ? transformUrl(baseUrl, src) : src;
this.sandbox.loader
.load<JavaScriptManager>({
scope: namespace,
url: fetchUrl,
crossOrigin,
defaultContentType: type,
})
.then(
(manager) => {
if (manager.resourceManager) {
const {
resourceManager: { url, scriptCode },
} = manager;
// 👇🏻 必须取到执行代码时不会触发到 `el.onerror` 事件
// It is necessary to ensure that the code execution error cannot trigger the `el.onerror` event
// 执行并劫持 script code
this.sandbox.execScript(scriptCode, {}, url, {
isModule,
noEntry: true,
});
} else {
warn(
`Invalid resource type "${type}", "${src}" can't generate scriptManager`,
);
}
this.dispatchEvent('load');
},
(e) => {
__DEV__ && warn(e);
this.dispatchEvent('error', {
error: e,
filename: fetchUrl,
});
},
);
} else if (code) {
this.sandbox.execScript(code, {}, baseUrl, { noEntry: true });
}
// 👇🏻 确保已删除正常的处理节点
// To ensure the processing node to normal has been removed
// 以注释的形式插入子容器中
const scriptCommentNode = this.DOMApis.createScriptCommentNode({
src,
code,
});
this.el[__REMOVE_NODE__] = () =>
this.DOMApis.removeElement(scriptCommentNode);
return scriptCommentNode;
}
return this.el;
}

参考资料​

Webpack 5 Module Federation(模块联邦)

一文彻底搞懂前端沙箱

沙箱机制