沙箱
前言
当多个子应用独立运行时,每个应用都会有自己的运行环境,这看起来很美好,对吧。但是运行在宿主应用 里应用,需要如何避免自身的环境被 宿主应用 影响,或者影响 宿主应用,即 应用间运行时如何相互隔离,比如说常见的如何实现 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 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。
- url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。(好解决)
- UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中。 (难以解决)
- 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。(难以解决)
- 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程,加载优化、运行优化问题 (好解决)
为什么不选择 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 变量 进行操作,大致执行步骤是
- 存储当前执行环境
- 执行具备有副作用的代码
- 恢复执行环境
Fly to code path: packages/browser-snapshot/src/sandbox.ts:L31
- CSS
- JS
// 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
);
}
// ...
}
const hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn(obj: any, key: PropertyKey): boolean {
return hasOwnProperty.call(obj, key);
}
export class PatchGlobalVal {
public snapshotOriginal = new Map();
private snapshotMutated: any = new Map();
// ...
// 1. Trigger hooks, life cycle willActivate enabled (going to)
// 2. Will disable the current group of other box, and triggers the switch life cycle
// 3. The current window object properties for caching
// 4. Restore the sandbox side effects during operation
// 1. 触发钩,生命周期将 willActivate 启用
// 2. 将禁用当前的其他盒子,并触发开关生命周期
// 3. 当前窗口对象属性用于缓存
// 4. 运行过程中的沙盒副作用
public activate() {
// Recorded before the global environment, restore side effects of a variable
this.safeIterator((i: string) => {
this.snapshotOriginal.set(i, this.targetToProtect[i]);
});
this.snapshotMutated.forEach((val, mutateKey) => {
this.targetToProtect[mutateKey] = this.snapshotMutated.get(mutateKey);
});
}
// 1. 在启动变量更改期间的沙箱,记录变更记录
// 2. 在启动期间放置沙箱以删除变量,记录变更记录
// 1. Restore the sandbox during startup variables change, record the change record
// 2. Restore the sandbox during startup to delete variables, record the change record
public deactivate() {
const deleteMap: any = {};
const updateMap: any = {};
const addMap: any = {};
// Restore the sandbox before running Windows properties of environment, and difference value for caching
this.safeIterator((normalKey: string) => {
if (this.snapshotOriginal.get(normalKey) !== (this.targetToProtect[normalKey] as any)) {
this.snapshotMutated.set(normalKey, this.targetToProtect[normalKey]); // deleted key will be defined as undefined on
this.targetToProtect[normalKey] = this.snapshotOriginal.get(normalKey); // || this.targetToProtect[i]
// Collection of delete, modify variables
if (this.targetToProtect[normalKey] === undefined) {
addMap[normalKey] = this.snapshotMutated.get(normalKey);
} else {
updateMap[normalKey] = this.snapshotMutated.get(normalKey);
}
}
this.snapshotOriginal.delete(normalKey);
});
this.snapshotOriginal.forEach((val, deleteKey) => {
this.snapshotMutated.set(deleteKey, this.targetToProtect[deleteKey]);
this.targetToProtect[deleteKey] = this.snapshotOriginal.get(deleteKey);
deleteMap[deleteKey] = this.targetToProtect[deleteKey];
});
}
// ...
}
VM 沙箱
原理
在 JavaScript 中的作用域只有全局作用域(global scope)、函数作用域(function scope)以及从 ES6 开始才有的块级作用域(block scope)。
如果要将一段代码中的变量、函数等的定义隔离出来,我们一般会通过这么几种方式来达到目的:
- IIFE
- eval
- new Function
- with
当函数变成立即执行的函数表达式时,表达式中的变量不能从外部访问,它拥有独立的词法作用域。不仅避免了外界访问 IIFE 中的变量,而且又不会污染全局作用域,弥补了 JavaScript 在 scope 方面的缺陷。
(function foo() {
const name = 'Rain120';
console.log(name);
})();
const getUser = new Function("console.log({name: 'Rain120'})");
console.log(getUser());
Note:
- 不能访问当前环境变量,但可以访问全局变量,安全性高
- 只需要处理传入的字符串一次,后面重复执行都是同一个函数,而 eval 需要每次都处理,性能更高
var a, x, y;
var r = 10;
with (Math) {
a = PI * r * r;
x = r * cos(PI);
y = r * sin(PI / 2);
}
Note:
- with 语句可以在不造成性能损失的情況下,减少变量的长度。
- with 语句使得程序在查找变量值时,都是先在指定的对象中查找。(with 是通过 in 来判断是否在当前作用域内的)
CSS
- style
- link
- 重新挂载 & 卸载CSS Rules
// 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();
}
// packages/browser-vm/src/dynamicNode/processor.ts:L331
// ...
// The link node of the request css needs to be changed to style node
else if (this.is('link')) {
parentNode = this.findParentNodeInApp(context, 'head');
if (this.el.rel === 'stylesheet' && this.el.href) {
convertedNode = this.addDynamicLinkNode((styleNode) =>
this.nativeAppend.call(parentNode, styleNode),
);
} else {
convertedNode = this.el;
this.monitorChangesOfLinkNode();
}
}
// packages/browser-vm/src/dynamicNode/index.ts:L142
export function recordStyledComponentCSSRules(
dynamicStyleSheetElementSet: Set<HTMLStyleElement>,
styledComponentCSSRulesMap: WeakMap<HTMLStyleElement, CSSRuleList>
) {
dynamicStyleSheetElementSet.forEach(styleElement => {
if (isStyledComponentsLike(styleElement) && styleElement.sheet) {
styledComponentCSSRulesMap.set(styleElement, styleElement.sheet.cssRules);
}
});
}
export function rebuildCSSRules(
dynamicStyleSheetElementSet: Set<HTMLStyleElement>,
styledComponentCSSRulesMap: WeakMap<HTMLStyleElement, CSSRuleList>
) {
dynamicStyleSheetElementSet.forEach(styleElement => {
const cssRules = styledComponentCSSRulesMap.get(styleElement);
if (cssRules && (isStyledComponentsLike(styleElement) || cssRules.length)) {
for (let i = 0; i < cssRules.length; i++) {
const cssRule = cssRules[i];
// re-insert rules for styled-components element
styleElement.sheet?.insertRule(
cssRule.cssText,
styleElement.sheet?.cssRules.length
);
}
}
});
}
JS
- Execute Script
// 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;
}