前言
沙箱,即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
达到目的 -
一个相对完善的解决方案,必然是多种方案的组合。没有任何一个框架能独立解决所有问题,所以我们需要了解每种方案能够解决什么样的问题,并加以整合