前言
沙箱,即sandbox。
通常解释为:沙箱是一种安全机制,为运行中的程序提供隔离环境。常用于执行未经测试或者不受信任的程序或代码,它会为待执行的程序创建一个独立的执行环境,内部程序的执行不会影响外部程序的运行。
常见的一些沙箱使用场景:
- 在线代码编辑器,如
codesandbox、leetcode等 jsonp请求的数据,未知的第三方js的测试执行等vue服务端渲染等、模板中表达式计算等
通用概念的简单描述:
- 主应用:在微前端方案中,主应用通常负责全局资源加载、分配、控制,也称为基座。如用户的登录和全局状态管理等
- 子应用:在微前端方案中,子应用通常是一个独立运行的
web应用,也称为微应用 qiankun:一款开源解决方案
实现方式
基于技术方案,大致有如下几种实现:
-
仅作为
demo参考,非完整解决方案 -
基于属性
diff实现 -
基于
iframe实现 -
基于
Proxy实现 -
基于
ShadowRealm实现
部分内容官方实现比较繁复,我们这里尽量简化说明。本次不会涉及到太多关于微前端相关的知识。下面来看详细介绍。
JS沙箱
IIFE
我们知道在JavaScript中目前有三种作用域(scope):全局作用域(global scope)、函数作用域(function scope)、块级作用域(block scope)。
通常可以通过把一段代码封装到一个函数中实现作用域的隔离。这种方式是基于IIFE(Immediately Invoked Function Expression)立即执行函数来实现。
简单示例
(function testFn(){
const a = 1;
console.log(a);// 1
})();
console.log(a) // Uncaught ReferenceError: a is not defined
经典jquery实现
(function (window) {
var jQuery = function (selector, context) {
return new jQuery.fn.init(selector, context);
}
jQuery.fn = jQuery.prototype = function () {
//原型上的方法,即所有jQuery对象都可以共享的方法和属性
}
jQuery.fn.init.prototype = jQuery.fn;
window.jQeury = window.$ = jQuery; // 暴露到全局的方法
})(window);
需要注意的是 IIFE 只能实现一个简易的沙箱,并不算一个独立的运行环境。虽然外部不能访问函数内部,但函数内部可以访问外部的全局变量,有污染全局的风险。
eval
eval() 函数会将传入的字符串当做 JavaScript 代码进行执行。
eval的副作用是非常大的,官方反复声明这一点。一般出于安全和性能方面的考虑,我们不建议在实际业务代码中使用eval。
简单示例
console.log(eval("1 + 2")); // 3
const person = eval("({name:'张三'})");
console.log(person.name); // "张三"
由于eval 执行的代码可以访问闭包和全局,因此会导致代码注入等安全问题,如:
console.log(eval( this.window === window )); // true
关于eval还有一个比较有意思的地方:直接调用和间接调用,详细的用法这里推荐一篇文章:eval 的一些不为人知道的用法 。下面看一个示例
function testEval() {
var x = 1, y = 2;
// 直接调用
console.log(eval("x + y")); // 3
// 间接调用1
var copyEval = eval;
console.log(copyEval("x + y")); // ReferenceError: x is not defined
// 间接调用2
(0, eval)("x + y"); // ReferenceError: x is not defined
}
testEval();
使用eval实现沙箱:沙箱中执行的程序,所访问的变量应该来源于沙箱环境,而非全局环境,因此最简单的做法就是给待执行程序添加上下文环境。但这要求程序中获取变量要添加一个执行上下文环境的前缀,显然是不友好的。
// 执行上下文环境
const ctx = {
func: (v) => {
console.log(v);
},
foo: "foo",
};
function sandbox(code, ctx) {
eval(code); // 为执行程序构造了一个函数作用域
}
// 待执行程序
const code = `
ctx.foo = 'bar'
ctx.func(ctx.foo)
`;
sandbox(code, ctx); // bar
new Function
Function构造函数创建一个新的Function 对象。直接调用这个构造函数可以动态创建函数。
new Function ([arg1[, arg2[, ...argN]],] functionBody)
简单示例
const sum = new Function('a', 'b', 'return a + b');
console.log(sum(1, 2)); //3
new Function默认被创建于全局环境,因此运行时只能访问全局变量和自身的局部变量。不能访问它被创建时所在作用域的变量。
let a = 1;
function testFunc() {
let a = 2;
return new Function('return a;');
}
console.log(testFunc()); // 1
new Function是 eval更好的替代方案。但还是没有解决访问全局作用域的问题。
with
with 一般用于扩展一个语句的作用域链。它是半沙箱模式。什么是半沙箱模式?with将某个对象添加到作用域链的顶部,如果在沙箱中有某个未使用命名空间的变量,跟作用域链中的某个属性同名,则这个变量将指向这个属性值。如果沒有同名的属性,则拋出 ReferenceError异常。当然在严格模式下是禁止使用with语句的。
简单示例
function foo(obj) {
with (obj) {
a = 2;
}
}
var o1 = {
a: 3
};
var o2 = {
b: 3
}
foo(o1);
console.log(o1.a);// 2
foo(o2);
console.log(o2.a);// underfined
console.log(a);// 2,a被泄漏到全局作用域上
由于with是半沙箱模式,会优先从沙箱提供的执行上下文中查找变量,但同时,当提供的执行上下文环境中没有找到某变量时,会沿着作用域链向上查找(在非严格模式下,会自动在全局作用域创建一个全局变量),有可能会对外部环境产生影响。
// 执行上下文环境
const ctx = {
func: (v) => {
console.log(v);
},
foo: "foo",
};
function sandbox(code, ctx) {
with (ctx) {
// 将 ctx 添加到作用域顶端
eval(code);
}
}
// 待执行程序
const code = `
foo = 'bar'
func(foo)
`;
sandbox(code, ctx); // bar
with + new Function
配合 with用法可以稍加限制沙箱作用域,当提供的执行环境中找不到某一变量时,还是会去上一级作用域链中进行遍历查找,污染或篡改全局环境。
const ctx = {
func: (v) => {
console.log(v);
},
foo: "foo",
};
function sandbox(code) {
code = "with (ctx) {" + code + "}";
return new Function("ctx", code);
}
// 待执行程序
const code = `
foo = 'bar'
func(foo)
`;
sandbox(code)(ctx); // bar
基于diff实现的快照沙箱(SnapshotSandbox)
此方式实现较为简单,主要用于某些不支持 proxy 的低版本浏览器中,原理是基于 diff 方式实现的。
简单来说,存在两个变量: windowSnapshot 保存 window 上面的快照信息,modifyPropsMap 保存沙箱环境与外部环境不同的快照信息。
当沙箱激活后,将 window 的全部属性存储到 windowSnapshot,同时将 modifyPropsMap 存储的沙箱环境加载到 window 上;退出沙箱后,利用 windowSnapshot 恢复 window 环境,将发生变化的属性存储到 modifyPropsMap。
但由此会产生一个问题:沙箱启用过程有可能会存在部分新增属性,如示例中的 window.age ,当沙箱关闭后,虽然还原后 window.age 值为 undefined,但该属性依旧存在,污染了全局环境。
这种方式无法支持多实例,因为运行期间所有的属性都是保存在window上的。
源码参考:https://github.com/umijs/qiankun/blob/master/src/sandbox/snapshotSandbox.ts

下面以qiankun中的snapshotSandbox源码作为简单案例分析。
function iter(obj, callbackFn) {
for (const prop in obj) {
if (obj.hasOwnProperty(prop)) {
callbackFn(prop);
}
}
}
/**
* 基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
*/
class SnapshotSandbox {
constructor() {
this.proxy = window;
this.type = 'Snapshot';
this.sandboxRunning = true;
this.windowSnapshot = {};
this.modifyPropsMap = {};
this.active();
}
//激活
active() {
// 记录当前快照
this.windowSnapshot = {};
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
// 恢复之前的变更
Object.keys(this.modifyPropsMap).forEach((p) => {
window[p] = this.modifyPropsMap[p];
});
this.sandboxRunning = true;
}
//还原
inactive() {
this.modifyPropsMap = {};
iter(window, (prop) => {
if (window[prop] !== this.windowSnapshot[prop]) {
// 记录变更
this.modifyPropsMap[prop] = window[prop];
// 还原window
window[prop] = this.windowSnapshot[prop];
}
});
this.sandboxRunning = false;
}
}
// 这里我们可以简单测试一下
window.name = '李四';
let sandbox = new SnapshotSandbox();
let proxy = sandbox.proxy;
proxy.name = '张三';
proxy.age = 18;
console.log(window.name, window.age); // 张三,18
sandbox.inactive();
console.log(window.name, window.age); // 李四,undefined
sandbox.active();
console.log(window.name, window.age); // 张三,18
基于 proxy 的单例沙箱 (legacySandbox)
此方式类似于快照实现。新增了三个变量:用于记录沙箱新增的全局变量addedPropsMapInSandboxInSandbox、用于记录沙箱修改的全局变量 modifiedPropsOriginalValueMapInSandbox、用于记录有变动(包含新增和修改,方便在任意时刻做snapshot)的全局变量 currentUpdatedPropsValueMap。
当沙箱激活后,根据 currentUpdatedPropsValueMap 还原window;退出沙箱后,利用 modifiedPropsOriginalValueMapInSandbox 恢复被修改的变量,利用addedPropsMapInSandboxInSandbox删除新增的变量。
legacySandbox 依旧会对 window 进行操作,造成一定的污染,但不会对 window 对象进行遍历,性能优于快照沙箱。
这种实现方式无法支持多实例,否则全局会有多个沙箱更新,造成变量冲突。
源码参考:https://github.com/umijs/qiankun/blob/master/src/sandbox/legacy/sandbox.ts

下面以qiankun中singular模式下的legacySandbox源码作为简单案例分析。
function setWindowProp(prop, value, toDelete) {
if (value === undefined || toDelete) {
delete window[prop];
} else {
window[prop] = value;
}
};
/**
* 基于 Proxy 实现的单例沙箱
*/
class SingularProxySandbox {
constructor() {
this.proxy = null;
this.tyep = 'LegacyProxy';
this.sandboxRunning = true;
// 存放新增的全局变量
this.addedPropsMapInSandbox = new Map();
// 存放沙箱期间更新的全局变量
this.modifiedPropsOriginalValueMapInSandbox = new Map();
// 存在新增和修改的全局变量,在沙箱激活的时候使用
this.currentUpdatedPropsValueMap = new Map();
const {
addedPropsMapInSandbox,
modifiedPropsOriginalValueMapInSandbox,
currentUpdatedPropsValueMap
} = this;
const rawWindow = window;
//Object.create(null)的方式,传入一个不含有原型链的对象
const fakeWindow = Object.create(null);
const proxy = new Proxy(fakeWindow, {
set: (_, p, value) => {
if (this.sandboxRunning) {
// 如果 window 没有该属性,代表发生了新增,记录到新增属性里
if (!rawWindow.hasOwnProperty(p)) {
addedPropsMapInSandbox.set(p, value);
} else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
// 如果当前window对象有该属性,且未更新过,则记录该属性在window上的初始值
const originalValue = rawWindow[p];
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
}
// 记录修改属性以及修改后的值
currentUpdatedPropsValueMap.set(p, value);
// 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
rawWindow[p] = value;
return true;
}
// strict-mode
return true;
},
get(_, p) {
return rawWindow[p];
},
});
this.proxy = proxy;
}
active() {
if (!this.sandboxRunning) {
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
}
this.sandboxRunning = true;
}
inactive() {
//删除添加的属性,修改已有的属性
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
}
// 这里我们可以简单测试一下
window.name = '李四';
const sandbox = new SingularProxySandbox();
const proxy = sandbox.proxy;
proxy.name = '张三';
proxy.age = 18;
console.log(window.name, window.age); // 张三,18
sandbox.inactive();
console.log(window.name, window.age); // 李四,undefined
sandbox.active();
console.log(window.name, window.age); // 张三,18
基于Proxy实现的多例沙箱(ProxySandbox)
在上述单实例的场景中,fakeWindow是一个空对象,没有存储变量的功能,微应用创建的变量最终都是挂载在window上的,这就限制了在同一时刻不能激活多个微应用。
为了支持多沙箱同时运行,我们需要让fakeWindow发挥作用。即对 fakeWindow 进行代理,在激活沙箱后,先找自己沙箱环境的fakeWindow,如果找不到,则去外部环境rawWindow进行查找;当对沙箱内部的 window 对象赋值的时候,会直接操作 fakeWindow,而不会影响到 rawWindow。
源码参考:https://github.com/umijs/qiankun/blob/master/src/sandbox/proxySandbox.ts

下面以qiankun中的proxySandbox源码作为简单案例分析。
// 复制一份fakeWindow
function createFakeWindow(globalContext) {
var propertiesWithGetter = new Map();
var fakeWindow = {};
// copy the non-configurable property of global to fakeWindow
// 处理过程省略...
return {
fakeWindow,
propertiesWithGetter,
}
}
/**
* 基于 Proxy 实现的多例沙箱
*/
class ProxySandbox {
active() {
this.sandboxRunning = true;
}
inactive() {
this.sandboxRunning = false;
}
constructor() {
this.proxy = null;
this.sandboxRunning = true;
const rawWindow = window;
const fakeWindow = createFakeWindow(window).fakeWindow;
// 代理 fakeWindow
const proxy = new Proxy(fakeWindow, {
set: (target, prop, value) => {
// 只有沙箱开启的时候才操作 fakeWindow
if (this.sandboxRunning) {
target[prop] = value;
return true;
}
// strict-mode
return true;
},
get: (target, prop) => {
// 先查找 fakeWindow,找不到再寻找 rawWindow
let value = prop in target ? target[prop] : rawWindow[prop];
return value;
},
});
this.proxy = proxy;
}
}
// 这里我们可以简单测试一下
window.name = '王五';
window.age = 18;
const sandbox1 = new ProxySandbox();
const sandbox2 = new ProxySandbox();
const proxy1 = sandbox1.proxy;
const proxy2 = sandbox2.proxy;
console.log("====激活时====");
proxy1.name = '张三';
proxy1.age = 12;
proxy2.name = '李四';
console.log("沙箱1:", proxy1.name, proxy1.age); // 张三 12
console.log("沙箱2:", proxy2.name, proxy2.age); // 李四 18,取不到自己的值,取全局
console.log("全局值:", window.name, window.age); //王五 18
console.log("====销毁====");
sandbox1.inactive();
sandbox2.inactive();
proxy1.name = '张三三';
proxy2.name = '李四四';
window.name = '王五五';
console.log("沙箱1:", proxy1.name); //张三
console.log("沙箱2:", proxy2.name); // 李四
console.log("全局值:", window.name); //王五五
天然沙箱iframe
iframe 可以创建一个独立的浏览器原生级别的运行环境,这个环境由浏览器实现与主环境的隔离。iframe 是天然的沙箱,也是当前主流的沙箱实现方案之一。
html5 为 iframe 新增了一个sandbox属性,可以实现带有额外限制的沙箱模式,配合使用postMessage实现子应用与主应用之间的通信。基础用法和属性值可参考 sandbox 属性。
当然,iframe并不是万能的,如果一个页面存在多个沙箱,每个子应用都要与主应用共享一些全局变量,那么实现起来是非常困难的,或者子应用需要与主应用共享路由是无法直接实现的。
其它实现
-
ShadowRealm API:此方案还在提案阶段,不做详述 -
NodeJS中的VM、VM2、Safeify等,不做详述
CSS沙箱
上面介绍的主要是针对js沙箱,那么对于css,有对应的沙箱吗?严格意义上的css沙箱是不存在的,当前解决方案主要是利用(作用域)隔离。
namespace
@namespace是定义要在CSS 样式表中使用的XML名称空间的规则。已定义的名称空间可用于限制通用类型和属性选择器,以仅选择该名称空间内的元素。
语法
/* Default namespace */
@namespace url(XML-namespace-URL);
@namespace "XML-namespace-URL";
/* Prefixed namespace */
@namespace prefix url(XML-namespace-URL);
@namespace prefix "XML-namespace-URL";
Dynamic StyleSheet
动态样式表,实现方式是通过JS运行时动态加载/卸载微应用样式表来避免样式的冲突。缺点是对于主应用本身与子应用之间会存在样式冲突,多个子应用之间也会有冲突。
css in js
CSS-IN-JS 是将 CSS代码写在JavaScript 代码中,而不是独立的.css文件。这样就可以使用一些JS相关的变量声明,函数判断等方式,实现自由化。优点是可以防止各个组件的样式冲突(自动局部css作用域),缺点是会自动添加选择器前缀,复杂度提升。
常见的CSS-IN-JS 库:Styled-components、Radium、Emotion等
Shadow DOM
这里推荐先查看Web Components和shadow DOM相关文档。
Shadow DOM可以让一个组件拥有自己的影子DOM树,这个DOM树不能在主文档中被访问,拥有局部样式规则和其他特性,具有良好的密封性。Shadow DOM 允许将隐藏的DOM树附加到常规的DOM树中,它以 shadow root 节点为起始根节点,在这个根节点的下方,可以是任意元素,和普通的DOM元素一样。因为这个特点,单个空标签才能渲染出各种各样的复杂内容。
我们可以通过开启show user agent shadow DOM来显示这部分内容。
以video为例,我们只需要设置视频地址,就可以实现播放、进度条、全屏等功能,实际上这部分内容在shadow dom中。

基础概念
Shadow host:一个常规DOM节点,Shadow DOM会被附加到这个节点上(宿主元素)Shadow tree:Shadow DOM内部的DOM树Shadow root:Shadow tree的根节点
基础用法:
我们可以使用Element.attachShadow来将一个shadow root附加到任意元素上。
/* mode
open: 可以通过页面内的 JavaScript 方法来获取 Shadow DOM
closed: 相反,即不可以~
*/
let shadow = elementRef.attachShadow({mode: 'open'});
let shadow = elementRef.attachShadow({mode: 'closed'});

CSS Module
CSS的规则都是全局的,任何一个组件的样式规则,都对整个页面有效。产生局部作用域的唯一方法,就是使用一个独一无二的类名,不会与其他选择器重名。
这可能是最为常见的处理方式。通过hash实现类似于命名空间的方法,类名是动态生成且是唯一的。我们(基于webpack)以Vue和React为例简单说明,当然我们只从局部作用域分析,其它功能的实现,如全局作用域、组合、定制、继承、变量等规则就不逐一介绍,各配置和用法自行查阅资料。
Vue
Vue Loader支持Scoped CSS和CSS Module,我们这里不讨论Scoped CSS。
示例如下,我们在两个组件中使用相同的类名,编译结果是不同的。

React
示例如下,我们在两个组件中使用相同的类名,编译结果是不同的。

沙箱逃逸
“沙箱于作者而言是一种安全策略,但于使用者而言可能是一种束缚”。从沙箱诞生之初,开发者都在尝试通过各种途径摆脱这种束缚,称为“沙箱逃逸”。在实际项目中,受限于技术或场景需求,我们一般要求部分内容处于沙箱之中,部分内容处于沙箱之外,这显然是对原有框架的“破坏”;
我们以qiankun为例(使用的是Dynamic StyleSheet),其中一个常见的css问题是:弹窗/下拉等组件通常为了避免出现定位问题,会优先选择插入到body下,我们加入一些样式后,在子应用独立运行时表现是正常的,而在基座中运行时会发现自定义的样式失效了。
为何出现这种情况,我们简单分析一下。
- 原则上各个应用之间应该是严格沙箱模式,即应用A的变量和样式不能影响到应用B的变量和样式,这个设计是没问题的
- 子应用被激活时,所有
css会被统一加上前缀div[data-qiankun="micro-xxx"]保证其生效作用域 - 子应用被激活时,其本身的
html、body等标签会被替换成div(标准中要保证这些标签全局唯一),此操作会导致append to body插入到文档根节点,而非我们认知的子应用的根节点。此时这些Dom其实已经“逃逸”出子应用了,而css还保留在原有的子应用局部作用域
那么如何解决呢
- 官方提供一个方案:将所有需要“逃离”的样式单独写在一个文件中,在激活子应用时,告知主应用不对
css处理,即保证这些样式是全局生效的。但是维护成本比较高,且不符合书写习惯 - 手动修改
append to body的位置,使其插入到子应用的根节点,而非文档根节点
补充说明
-
Proxy API:在ES6中提出,可以创建一个对象的代理,实现自定义的拦截和操作。了解Vue3响应式原理的话,对Proxy/Reflect的用法应该很清楚 -
对于
Module Federation(某种意义上更为轻量级的微前端实现方案),可以让跨应用间做到模块共享(真正的插拔式的便捷使用)。比如应用A想使用应用B中的组件C,通过模块联邦可以直接在A中import('B/C')。相关内容大家可以着重学习一下,很有意思 -
现代沙箱机制,大多是通过模拟、代理来实现环境隔离,因此必须要考虑一些特殊语法(如变量提升等)会导致作用域发生变化,诸如此类,需要特别注意
-
在实际项目中,会遇到当前方案没有完全解决的问题,需要我们自己根据所使用的技术、场景去特殊处理。如样式逃逸,在
ElementUI中需要手动更改插入body的时机和位置,而对于Ant Design来说可以通过设置getContainer达到目的 -
一个相对完善的解决方案,必然是多种方案的组合。没有任何一个框架能独立解决所有问题,所以我们需要了解每种方案能够解决什么样的问题,并加以整合



















