什么是JS沙箱:
沙箱,即 sandbox,顾名思义,就是让你的程序跑在一个隔离的环境下,不对外界的其他程序造成影响,通过创建类似沙盒的独立作业环境,在其内部运行的程序并不能对硬盘产生永久性的影响。
在现实与 JavaScript 相关的场景中,我们知道平时使用的浏览器就是一个沙箱,运行在浏览器中的 JavaScript 代码无法直接访问文件系统、显示器或其他任何硬件。
Chrome 浏览器中每个标签页也是一个沙箱,各个标签页内的数据无法直接相互影响,接口都在独立的上下文中运行。而在同一个浏览器标签页下运行 HTML 页面
在开发过程中,例如『用户希望可以自己写 js 代码运行』的需求,例如『要执行用户提交的不可信任的第三方代码』等需求,都需要要利用沙箱,来防止代码对全局产生影响。
JS沙箱的使用场景:
使用沙箱需求的诸多应用场景,譬如:
- 执行从不受信的源获取到的第三方 JavaScript 代码时(比如引入插件、处理 jsonp 请求回来的数据等)。
解析服务器所返回的 jsonp 请求时,如果不信任 jsonp 中的数据,可以通过创建沙箱的方式来解析获取数据;(TSW 中处理 jsonp 请求时,创建沙箱来处理和解析数据);执行第三方 js:当你有必要执行第三方 js 的时候,而这份 js 文件又不一定可信的时候;
- 在线代码编辑器场景(比如著名的 codesandbox)。
- 使用服务端渲染方案。
- vue 的服务端渲染实现中,通过创建沙箱执行前端的 bundle 文件;
- 在调用 createBundleRenderer 方法时候,允许配置 runInNewContext 为 true 或 false 的形式,判断是否传入一个新创建的 sandbox 对象以供 vm 使用;
- next.js也用到了沙箱,但是更复杂一些
- 模板字符串中的表达式的计算。
vue 模板中表达式的计算被放在沙盒中,只能访问全局变量的一个白名单,如 Math 和 Date 。你不能够在模板表达式中试图访问用户定义的全局变量。
JS沙箱的条件:
- 挂在 window 上的全局方法/变量(如 setTimeout、滚动等全局事件监听等)在子应用切换时的清理和还原。
- Cookie、LocalStorage 等的读写安全策略限制。
- 各子应用独立路由的实现。
- 多个微应用共存时相互独立的实现
JS隔离的几种方式:
谈到JS沙箱,市场上真正能落地的产品不多,从「iframe」到「single-spa」到「qiankun」,但其中的解决方案都有各自的缺点,基本都是微前端 && codebox 的场景中落地。
方案1 window快照还原
利用 window 上常用的常量和方法以及不支持 Proxy时降级通过快照实现备份还原,这一方案是比较原始的超脱方案
qiankun框架应该有这种方案的影子,从沙箱有两个 入口可以看出来(一个是proxySandbox.ts,另一个是snapshotSandbox.ts)
简单总结下其实现思路:起初版本使用了快照沙箱的概念,模拟ES6 的 Proxy API,通过代理劫持 window ,当子应用修改或使用window上的属性或方法时,把对应的操作记录下来,每次子应用挂载/卸载时生成快照,当再次从外部切换到当前子应用时,再从记录的快照中恢复,而后来为了兼容多个子应用共存的情况,又基于Proxy实现了代理所有全局性的常量和方法接口,为每个子应用构造了独立的运行环境。
方案2:阿里云开发平台的 Browser VM借助iframe的方案
原理就是接触iframe生成window,将子应用的包装成一个闭包方法,将iframe生成的window传入,借助iframe天生的优势(硬隔离),非常nice的解决了各个子应用的隔离,也不需要自行维护window这种情况,但缺点也很明显,所有执行必须通过postMessage
与主线程通信,易用性以及 postMessage 序列化带来的性能等问题都深深困扰的开发者。
方案3:Figma 采用的方案
基于目前还在草案阶段 Realm API,并将 JavaScript 解释器的一种 C++ 实现 Duktape 编译到了 WebAssembly,然后将其嵌入到Realm上下文中,实现了其产品下的三方插件的独立运行
这种方案和探索的基于 WebWorker的实现可能能够结合得更好,值得注意的是,Realm 同样可以使用 JavaScript 目前已有的特性来实现,即 with与Proxy。这也是目前社区比较流行的沙箱方案。
示例
快照沙箱
snapshotSandbox会污染全局window,但是可以支持不兼容Proxy的浏览器。
javaScript
/*
基于diff来实现的沙箱,用于不支持window.Proxy低版本浏览器, 快照沙箱
*/
const iter = (window, callback) => {
for (const prop in window) {
if (window.hasOwnProperty(prop)) {
callback(prop);
}
}
};
class SnapshotSandbox {
constructor() {
this.proxy = window;
this.modifyPropsMap = {};
}
// 激活沙箱时,将window的快照信息存到windowSnapshot中
active() {
// 缓存active状态的window
this.windowSnapshot = {};
// 如果modifyPropsMap有值,还需要还原上次的状态;激活期间,可能修改了window的数据;
iter(window, (prop) => {
this.windowSnapshot[prop] = window[prop];
});
Object.keys(this.modifyPropsMap).forEach((p) => {
window[p] = this.modifyPropsMap[p];
});
}
// 退出沙箱时,将修改过的信息存到modifyPropsMap里面,并且把window还原成初始进入的状态。
inactive() {
iter(window, (prop) => {
if (this.windowSnapshot[prop] !== window[prop]) {
// 记录变更
this.modifyPropsMap[prop] = window[prop];
// 还原window
window[prop] = this.windowSnapshot[prop];
}
});
}
}
// 进来的时候,记录一下当前window的属性
// 退出的时候,记录修改,并且把window还原到进入的时候
// 下次进入的时候,把修改属性放到window上
const sandbox = new SnapshotSandbox();
((window) => {
// 激活沙箱
sandbox.active();
window.sex = "男";
window.age = "22";
console.log(window.sex, window.age);
// 失活沙箱
sandbox.inactive();
console.log(window.sex, window.age);
// 激活沙箱
sandbox.active();
console.log(window.sex, window.age);
})(sandbox.proxy);
代理沙箱
qiankun基于es6的Proxy实现了两种应用场景不同的沙箱,一种是legacySandbox(单例),一种是proxySandbox(多例)。因为都是基于Proxy实现的,所以都称为代理沙箱。
以多例 proxySandbox为例
只有proxySandbox才是对window进行了一个真正的无污染环境
总结
JavaScript 沙箱隔离在社区是个经久不衰的话题,尤其是19年后,随着微前端而变的有所发展,最简单的 iframe 标签 Sandbox 属性就已经能做到 JavaScript 运行时的隔离,社区较为流行的是利用一些语言特性(with、realm、Proxy 等 API )屏蔽(或代理) Window、Document 等全局对象,建立白名单机制,对可能潜在危险操作的 API 重写(如阿里云 Console OS - Browser VM),另一种就是更高端的操作了,就好像Figma一样,使用这种尝试嵌入平台无关的 JavaScript 解释器,所有第三方代码都通过嵌入的解释器来执行。