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(模块联邦)

一文彻底搞懂前端沙箱

沙箱机制