qiankun沙箱隔离
qiankun的css沙箱分为shadow DOM/Scoped CSS/Web Component
qiankun的js沙箱分为snapshotSandbox(快照沙箱)/proxySandbox(代理沙箱)/LegacySandbox
不管是哪种沙箱,都是在mounted阶段激活,unmounted阶段卸载
snapshotSandbox
基于diff实现的沙箱,用于不支持proxy的低版本浏览器
实现原理:
- 快照保存window对象(window对象的键值对以hashMap类型存储)
- 微应用mount时,modifyPropsMap存在,将变更modifyPropsMap应用到全局window,没有则跳过该步骤;浅拷贝window键值对,用于卸载时恢复环境
- 微应用unmount时,快照的键值对和window的键值对进行diff比较,diff结果用于恢复微应用环境
class SnapshotSandBox{
windowSnapshot = {};
modifyPropsMap = {};
active(){
for(const prop in window){
this.windowSnapshot[prop] = window[prop];
}
Object.keys(this.modifyPropsMap).forEach(prop=>{
window[prop] = this.modifyPropsMap[prop];
});
}
inactive(){
for(const prop in window){
if(window[prop] !== this.windowSnapshot[prop]){
this.modifyPropsMap[prop] = window[prop];
window[prop] = this.windowSnapshot[prop];
}
}
}
}
// 验证:
let snapshotSandBox = new SnapshotSandBox();
snapshotSandBox.active();
window.city = 'Beijing';
console.log("window.city-01:", window.city);
snapshotSandBox.inactive();
console.log("window.city-02:", window.city);
snapshotSandBox.active();
console.log("window.city-03:", window.city);
snapshotSandBox.inactive();
//输出:
//window.city-01: Beijing
//window.city-02: undefined
//window.city-03: Beijing
从上面可以看出,快照沙箱存在两个重要的问题:
- 会改变全局window的属性,如果同时运行多个微应用,多个应用同时改写window上的属性,势必会出现状态混乱,这也就是为什么快照沙箱无法支持多各微应用同时运行的原因。关于这个问题,下文中支持多应用的代理沙箱可以很好的解决这个问题;
- 会通过
for(prop in window){}的方式来遍历window上的所有属性,window属性众多,这其实是一件很耗费性能的事情。关于这个问题支持单应用的代理沙箱和支持多应用的代理沙箱都可以规避。
#
proxySandbox
snapshotSandbox和legacySandBox都是单例模式下的沙箱(即同时只激活一个微应用),proxySandbox解决的是多个微应用同时激活时的沙箱隔离问题
实现原理:
-
将主应用window上的原生属性拷贝出来(比如location,document等),放在一个单独的对象fakeWindow上
-
每个微应用维护自己的window,当在微应用中读写原生属性时优先修改window,不是原生属性的读写的是fakeWindow
class ProxySandBox{
proxyWindow;
isRunning = false;
active(){
this.isRunning = true;
}
inactive(){
this.isRunning = false;
}
constructor(){
const fakeWindow = Object.create(null);
this.proxyWindow = new Proxy(fakeWindow,{
set:(target, prop, value, receiver)=>{
if(this.isRunning){
target[prop] = value;
}
},
get:(target, prop, receiver)=>{
return prop in target ? target[prop] : window[prop];
}
});
}
}
// 验证:
let proxySandBox1 = new ProxySandBox();
let proxySandBox2 = new ProxySandBox();
proxySandBox1.active();
proxySandBox2.active();
proxySandBox1.proxyWindow.city = 'Beijing';
proxySandBox2.proxyWindow.city = 'Shanghai';
console.log('active:proxySandBox1:window.city:', proxySandBox1.proxyWindow.city);
console.log('active:proxySandBox2:window.city:', proxySandBox2.proxyWindow.city);
console.log('window:window.city:', window.city);
proxySandBox1.inactive();
proxySandBox2.inactive();
console.log('inactive:proxySandBox1:window.city:', proxySandBox1.proxyWindow.city);
console.log('inactive:proxySandBox2:window.city:', proxySandBox2.proxyWindow.city);
console.log('window:window.city:', window.city);
Proxy是新ES6的新事物,低版本浏览器无法兼容所以SnapshotSandbox还会长期存在。虽然这里极简版本逻辑很少,但是由于ProxySandbox要支持多个微应用运行,里面的逻辑会SnapshotsSandbox、LegacySandbox的都要丰富一些。
LegacySandbox
基于proxy实现的沙箱
class LegacySandBox{
addedPropsMapInSandbox = new Map();
modifiedPropsOriginalValueMapInSandbox = new Map();
currentUpdatedPropsValueMap = new Map();
proxyWindow;
setWindowProp(prop, value, toDelete = false){
if(value === undefined && toDelete){
delete window[prop];
}else{
window[prop] = value;
}
}
active(){
this.currentUpdatedPropsValueMap.forEach((value, prop)=>this.setWindowProp(prop, value));
}
inactive(){
this.modifiedPropsOriginalValueMapInSandbox.forEach((value, prop)=>this.setWindowProp(prop, value));
this.addedPropsMapInSandbox.forEach((_, prop)=>this.setWindowProp(prop, undefined, true));
}
constructor(){
const fakeWindow = Object.create(null);
this.proxyWindow = new Proxy(fakeWindow,{
set:(target, prop, value, receiver)=>{
const originalVal = window[prop];
if(!window.hasOwnProperty(prop)){
this.addedPropsMapInSandbox.set(prop, value);
}else if(!this.modifiedPropsOriginalValueMapInSandbox.has(prop)){
this.modifiedPropsOriginalValueMapInSandbox.set(prop, originalVal);
}
this.currentUpdatedPropsValueMap.set(prop, value);
window[prop] = value;
},
get:(target, prop, receiver)=>{
return target[prop];
}
});
}
}
// 验证:
let legacySandBox = new LegacySandBox();
legacySandBox.active();
legacySandBox.proxyWindow.city = 'Beijing';
console.log('window.city-01:', window.city);
legacySandBox.inactive();
console.log('window.city-02:', window.city);
legacySandBox.active();
console.log('window.city-03:', window.city);
legacySandBox.inactive();
从上面的代码可以看出,其实现的功能和快照沙箱是一模一样的, 不同的是,通过三个变量来记住沙箱激活后window发生变化过的所有属性, 这样在后续的状态还原时候就不再需要遍历window的所有属性来进行对比, 提升了程序运行的性能。 但是这仍然改变不了这种机制仍然污染了window的状态的事实,因此也就无法承担起同时支持多个微应用运行的任务。
#
qiankun的通信设计
用法
qiankun提供了initGlobalState(state)定义全局状态,返回通信方法:
- onGlobalStateChange 监听state变化,通知触发更新
- setGlobalState 更新state
- offGlobalStateChange 注销函数,不再监听state
主应用
import { initGlobalState, MicroAppStateActions } from 'qiankun';
// 初始化 state
const actions: MicroAppStateActions = initGlobalState(state);
actions.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
actions.setGlobalState(state);
actions.offGlobalStateChange();
微应用
// 从生命周期 mount 中获取通信方法,使用方式和 master 一致
export function mount(props) {
props.onGlobalStateChange((state, prev) => {
// state: 变更后的状态; prev 变更前的状态
console.log(state, prev);
});
props.setGlobalState(state);
}
源码解析
qiankun的通信基于发布订阅模式设计
定义了两个变量,globalState用于记录全局数据状态,deps用于记录依赖
初始化全局状态函数,主要做了以下几件事情:
- 数据没有发生变化就告警
- 数据变化了,记录新旧state,触发全局监听函数
export function initGlobalState(state: Record<string, any> = {}) {
...
// 当前state等于globalState(数据没有发生变化)
if (state === globalState) {
// 打印告警提示数据没有改变
console.warn('[qiankun] state has not changed!');
} else {
// cloneDeep是lodash深拷贝方法
// 记录旧的state
const prevGlobalState = cloneDeep(globalState);
// 新的state赋值给globalState
globalState = cloneDeep(state);
// 触发全局状态变更函数,第一个参数为新的state,第二个参数为旧的state
emitGlobal(globalState, prevGlobalState);
}
return getMicroAppStateActions(`global-${+new Date()}`, true);
}
JS沙箱的常见解决方案
在实现JS沙箱问题之前我们需要有两点需要注意:
- 构建独立的上下文环境
- 模拟浏览器的原生对象
基于这两点,目前给出以下几种方案:
with
with语句将改变作用域,会让内部的访问优先从传入的对象上查找。怎么理解呢,我们来看一下这一段代码:
jsx
复制代码
const obj = {
a: 1
}
const obj2 = {
b: 2
}
const a = 9
const fun = (obj) => {
with(obj) { // 相当于{}内访问的变量都会从obj上查找
console.log(a)
a = 3
}
}
fun(obj) // 1
console.log(obj) // { a: 3 }
在当前的内部环境中找不到某个变量时,会沿着作用作用域链一层层向上查找,如果找不到就拋出ReferenceError异常。我们看下下面这个例子:
jsx
复制代码
const obj = {
a: 1
}
const obj2 = {
b: 2
}
const b = 9
const fun = (obj) => {
with(obj) {
console.log(a, b)
}
}
fun(obj) // 1 9
fun(obj2) // ReferenceError: a is not defined
虽然with实现了在当前上下文中查找变量的效果,但是仍然存在一下问题:
- 找不到时会沿着作用域链往上查找
- 当修改存在的变量时,会同步修改外层的变量
除此之外with还有其他的一些弊端👉详细了解
ES6 proxy
为了解决with存在的问题,我们来了解下proxy方法。proxy用于创建一个对象的代理,从而实现对基本操作的拦截以及自定义。
基本语法
jsx
复制代码
/**
* @param {*} target - 使用Proxy包装的目标对象
* @param {*} handler - 通常为一个函数,函数的各个属性分别定义了执行各个操作时的代理行为
*/
const p = new Proxy(target, handler)
👉详细了解
改进流程
code实现
jsx
复制代码
const obj = {
a: 1
}
const obj2 = {
b: 2
}
let b = 3
// 用with改变作用域
const withedCode = (code) => {
code = `with(obj) { ${ code } }`
const fun = new Function('obj', code)
return fun
}
// 执行代码
const code = 'console.log(b)'
// 白名单
const whiteList = ['console', 'b']
// // 访问拦截
const ctxProxy = (ctx) => new Proxy(ctx, {
has: (target, prop) => { // 当返回false的时候会沿着作用域向上查找,true为在当前作用域进行查找
if(whiteList.includes(prop)) {
return false
}
if(!target.hasOwnProperty(prop)) {
throw new Error(`can not find - ${prop}!`)
}
return true
},
})
withedCode(code)(ctxProxy(obj2)) // 3
思考🤔:为啥需要把console添加到whiteList中?
Tips:该案例在浏览器中运行正常,在node中运行可能出现问题,原因是使用了new Function,创建的函数只能在全局作用域中运行,而在node中顶级的作用域不是全局作用域,当前全局声明的变量是在当前模块的作用域里的。详细查看:Function
这样一个简单的沙箱是不是完成了,那我们现在会不会想这样一个问题? 解决完对象的访问控制,我们现在解决第二个问题如何模拟浏览器的全局对象———iframe
还有人不清楚为啥需要模拟浏览器的对象吗🤔?
- 一些全局对象方法的使用比如上面的
console.log等 - 获取全局对象中的一些初始变量
with + proxy + iframe
我们把原生浏览器的对象取出来
jsx
复制代码
const iframe = document.createElement('iframe')
document.body.append(iframe)
const globalObj = iframe.contentWindow
创建一个全局代理对象的类
jsx
复制代码
class GlobalProxy{
constructor(shareState) {
return new Proxy(globalObj, {
has: (target, prop) => {
if(shareState.includes(prop)) {
return false
}
if(!target.hasOwnProperty(prop)) {
throw new Error(`can not find - ${prop}!`)
}
return true
}
})
}
}
实际效果:
jsx
复制代码
// 创建共享白名单
const shareState = []
// 创建一个沙箱实例
const sandBox = new GlobalProxy(shareState)
const withedCode = (code) => {
code = `with(obj) { ${ code } }`
const fun = new Function('obj', code)
return fun
}
sandBox.abc = 123
// 执行代码
const code = 'console.log(abc)'
withedCode(code)(sandBox)
console.log(abc)
//------console------
// 123
// undefined
Web Workers
通过创建一个独立的浏览器线程来达到隔离的目的,但是具有一定的局限性
- 不能直接操作
DOM节点 - 不能使用window
window对象的默认方法和属性
原因在于workers运行在另一个全局上下文中,不同于当前的window。 因此适用的场景大概类似于一些表达式的计算等。
通信方式
workers和主线程之间通过消息机制进行数据传递
postMessage——发送消息onmessage——处理消息
我们来简单看个例子:
jsx
复制代码
// index.js
window.app = '我是元数据'
const myWorker = new Worker('worker.js')
myWorker.onmessage = (oEvent) => {
console.log('Worker said : ' + oEvent.data)
}
myWorker.postMessage('我是主线程!')
// worker.js
postMessage('我是子线程!');
onmessage = function (oEvent) {
postMessage("Hi " + oEvent.data);
console.log('window', window)
console.log('DOM', document)
}
// -------------console-------------
// Worker said : 我是子线程!
// Worker said : Hi 我是主线程!
// Uncaught ReferenceError: window is not defined
沙箱逃逸
沙箱逃逸即通过各种手段摆脱沙箱的束缚,访问沙箱外的全局变量甚至是篡改它们,实现一个沙箱还需要预防这些情况的发生。
window.parent
可以在沙箱的执行上下文中通过该方法拿到外层的全局对象
tsx
复制代码
const iframe = document.createElement('iframe')
document.body.append(iframe)
const globalObj = iframe.contentWindow
class GlobalProxy{
constructor(shareState) {
return new Proxy(globalObj, {
has: (target, prop) => {
if(shareState.includes(prop)) {
return false
}
if(!target.hasOwnProperty(prop)) {
throw new Error(`can not find - ${prop}!`)
}
return true
},
get: (target, prop) => {
// 处理Symbol.unscopables逃逸
if(prop === Symbol.unscopables) return undefined
return target[prop]
}
})
}
}
// 创建共享白名单
const shareState = []
// 创建一个沙箱实例
const sandBox = new GlobalProxy(shareState)
const withedCode = (code) => {
code = `with(obj) { ${ code } }`
const fun = new Function('obj', code)
return fun
}
sandBox.abc = 123
sandBox.aaa = 789
sandBox[Symbol.unscopables] = {
aaa: true
}
var aaa = 123
// 执行代码
const code = 'console.log(parent.test = 789)'
withedCode(code)(sandBox)
console.log(window.test) // 789
改进方案
glsl
复制代码
get: (target, prop) => {
// 处理Symbol.unscopables逃逸
if(prop === Symbol.unscopables) return undefined
// 阻止window.parent逃逸
if(prop === 'parent') {
return target
}
return target[prop]
}
原型链逃逸
通过某个变量的原型链向上查找,从而达到篡改全局对象的目的
tsx
复制代码
const code = `([]).constructor.prototype.toString = () => {
return 'Escape!'
}`
console.log([1,2,3].toString()) // Escape!
未来可尝试的新方案
ShadowRealms
它是未来JS的一项功能,目前已经进入stage-3。通过它我们可以创建一个单独的全局上下文环境来执行JS。 关于ShadowRealms的更多详情:
Portals
类似于iframe的新标签。 关于portals的更多详情
Figma 沙箱
尝试1:沙箱
const scene = await figma.loadScene() // gets data from the main thread
scene.selection[0].width *= 2
scene.createNode({
type: 'RECTANGLE',
x: 10, y: 20,
...
})
await figma.updateScene() // flush changes back, to the main thread
关键在于插件通过调用 loadScene(发送消息给 Figma 获取 document 的副本)进行初始化,并以调用 updateScene(将插件所做的更改发回给 Figma)结束。 注意:
-
我们获取 document 的副本,而不是每次读写属性都使用消息传递。消息传递的开销约为每个往返0.1ms,这样每秒只能处理1000条左右的消息。
-
不直接使用 postMessage,因为使用起来很麻烦。
缺点:
- 问题1:async/await关键字对用户来说不够友好
- 问题2:复制操作的开销过大
方法的第二个问题是,在将文档的大部分内容发送到插件之前,需要先对其进行序列化。
思考
在主线程上运行的好处是插件可以:
- 直接修改 document 而不是副本,消除了加载时间的问题。
- 运行复杂的组件更新和约束逻辑,无需两份代码。
- 进行同步 API 调用,加载或刷新不会造成混淆。
- 用更直观的方式编写:插件只是自动执行用户原本可以使用 UI 手动执行的操作。
但是,现在我们遇到了以下问题:
-
插件可能会挂起,且无法中断。
-
插件可以向 figma.com 发送网络请求。
-
插件可以访问和修改全局状态。包括修改 UI,在 API 外部建立对内部应用状态的依赖,或进行彻头彻尾的恶意操作,例如更改 ({}).proto 的值,这会使所有 JavaScript 对象都中毒。
尝试2:将 JavaScript 解释器编译为 WebAssembly
对于像我们这样的小型创业公司来说,实现 JavaScript 太繁重了,为了验证这种方法,我们使用 Duktape(一种 C++ 编写的轻量级 JavaScript 解释器),将其编译为 WebAssembly。
我们在上面运行了标准 JavaScript 测试套件 test262,它通过了所有 ES5 测试,一些不重要的测试除外。使用 Duktape 运行插件代码,需要调用已编译解释器的 eval 函数。
这种方法的特性如下:
-
解释器运行在主线程中,意味着可以创建基于主线程的 API。
-
容易推理出是安全的。Duktape 不支持任何浏览器 API,此外,它作为 WebAssembly 运行,而 WebAssembly 本身是一个沙箱环境,无法访问浏览器 API。换句话说,默认情况下,插件代码只能通过明确列入白名单的 API 与外界通信。
-
比常规 JavaScript 慢,因为该解释器不是 JIT 的,但这没关系。
-
需要浏览器编译一个中等大小的 WASM 二进制文件,需要一定的成本。
-
浏览器调试工具默认情况下不可用,我们花了一天时间为解释器实现一个控制台,说明至少可以调试插件。
-
Duktape 仅支持 ES5,但是使用 Babel 这样的工具交叉编译较新的 JavaScript 版本已成为网络社区的常规操作。
尝试3:Realms
****尽管我们有一种编译 JS 解释器的好方法,但还有另外一种工具,由 Agoric 创造的称为 Realms shim 的技术。该技术将创建沙箱和支持插件作为潜在用例,Realms API 大大致如下:
复制代码
let g = window; // outer global
let r = new Realm(); // realm object
let f = r.evaluate("(function() { return 17 })");
f() === 17 // true
Reflect.getPrototypeOf(f) === g.Function.prototype // false
Reflect.getPrototypeOf(f) === r.global.Function.prototype // true
实际上,可以使用已有的(尽管鲜为人知的)JavaScript 功能来实现该技术,沙箱可以隐藏全局变量,shim 起作用的核心大致如下:
** 大致就是 proxy + iframe + with **
考虑到默认情况下沙箱是不包含 console 对象的,毕竟 console 是浏览器 API,而不是 JavaScript 的功能,可以将其作为全局变量传递到沙箱。
复制代码
realm.evaluate(USER_CODE, { log: console.log })
或者将原始值隐藏在函数中,这样沙箱就无法修改:
复制代码
realm.evaluate(USER_CODE, { log: (...args) => { console.log(...args) } })
不幸的是,这是一个安全漏洞。即使在第二个例子中,匿名函数也是在 realm 之外创建的,然后直接提供给了 realm,这意味着插件可以沿着 log 函数的原型链到达沙箱外。
一个解释器一个 API
问题在于,直接基于 Realms 创建 Figma API 会使每个 API 端点都需要审核,包括输入和输出值,这范围太大了。
使用此接口,对象 { x: 10,y: 10 } 可以这样传递给沙箱:
复制代码
let vm: LowLevelJavascriptVm = createVm()
let jsVector = { x: 10, y: 10 }
let vmVector = vm.createObject()
vm.setProp(vmVector, "x", vm.newNumber(jsVector.x))
vm.setProp(vmVector, "y", vm.newNumber(jsVector.y))
Figma 节点对象 ”opacity” 属性的 API 如下所示:
复制代码
vm.defineProp(vmNodePrototype, 'opacity', {
enumerable: true,
get: function(this: VmHandle) {
return vm.newNumber(getNode(vm, this).opacity)
},
set: function(this: VmHandle, val: VmHandle) {
getNode(vm, this).opacity = vm.getNumber(val)
return vm.undefined
}
})
使用 Realms 沙箱同样可以很好地实现这个底层接口,这样实现的代码量是相对少的(我们的例子中大约 500 行代码)。然后就是仔细审核代码,一旦完成,便可以基于这些接口创建新的 API,而不用担心沙盒相关的安全性问题。 在文献中,这称为膜模式。
本质上,这是将 JavaScript 解释器和 Realms 沙箱都视为 “运行 JavaScript 的某些独立环境”。