沙箱隔离
沙箱
沙箱是一种安全机制,沙箱内外的程序是互不干扰的。有自己独立的执行环境。
A,B程序中声明的程序各不干扰,比如在A程序声明了window.a = 111,B程序在访问window.a是undefined(在确保B程序访问window.a时A程序已经赋值完毕的情况下)
沙箱隔离
参考:zhuanlan.zhihu.com/p/578093950
天然沙箱iframe
iframe可以创建一个独立的浏览器原生级别的运行环境,这个环境由浏览器实现与主环境的隔离,沙箱的内容和外部的内容存在隔离,这个隔离主要是由于浏览器的同源策略所导致的。
H5后面为新增了一个属性sandbox,可以为iframe内部提供更加严格的隔离,如禁止脚本执行、禁止表单提交、限制跨域访问等,以实现更严格的隔离环境。
基于diff实现的快照沙箱
实现原理:进入沙箱前,使用一个变量存储沙箱外部的window数据;出了沙箱后,使用一个变量用来存储沙箱内部修改过的属性(对比外部的window)。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>基于diff实现的快照沙箱</title>
</head>
<body>
<script>
class Sanapshotbox {
// 激活
windowSnapshot = {} // 沙箱外部的window
modifyPropsMap = {} // 上一次沙箱内部修改的window
active() {
// 存储一个沙箱外部的快照
for (const prop in window) {
this.windowSnapshot[prop] = window[prop]
}
// 恢复上一次在运行微应用的时候改过的window 上的属性
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 上的属性状态 还原至微应用之前的状态
window[prop] = this.windowSnapshot[prop]
}
}
}
}
window.city="最开始的值"
let sanapshotbox = new Sanapshotbox()
console.log("改变前",window.city) // 最开始的值
sanapshotbox.active()
window.city="改变后的值"
console.log("改变后",window.city) // 改变后的值
sanapshotbox.inactive()
console.log('注销后',window.city) // 最开始的值
</script>
</body>
</html>
由结果可以看出来,sanapshotbox这个沙箱是符合要求的,因为沙箱激活前和注销后的全局window并没有发生变化,当再次进入沙箱,window的值仍然是上次出沙箱前修改的值。
但是缺点也很明显
- 沙箱一次只能使用一个实例激活,多个实例激活的话,沙箱内部window的值会互相污染
- 在沙箱注销前,沙箱内部的数据会污染沙箱外部的数据
- 虽然沙箱注销后恢复了外部的window数据,但是也只是把值变为undefined,沙箱内部新增的属性还是会挂在外部的window对象中。
- 每次激活和注销的时候,都需要遍历window的所有属性,性能比较差
基于 proxy 的单例沙箱legacySandbox
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>proxy代理沙箱LegacySandbox</title>
</head>
<body>
<script>
class LegacySandbox {
currentUpdatePropsValueMap = new Map() // 记录新增和修改的全局变量
modifiedPropsOriginalValueMapInSandbox = new Map() // 沙箱期间更新的全局变量
addedPropsMapInSandbox = new Map() // 沙箱期间新增的全局变量
propsWindow = {}
// 核心逻辑
constructor() {
const fakeWindow = Object.create(null)
// 设置值或者获取值
this.propsWindow = new Proxy(fakeWindow, {
set: (target, prop, value, receiver) => {
if (!window.hasOwnProperty(prop)) {
this.addedPropsMapInSandbox.set(prop, value)
} else if(!this.modifiedPropsOriginalValueMapInSandbox.has(prop)){
this.modifiedPropsOriginalValueMapInSandbox.set(prop, window[prop])
}
this.currentUpdatePropsValueMap.set(prop,value)
window[prop]= value
},
get: (target, prop, receiver) => {
return window[prop]
}
})
}
setWindowProp(prop, value, isToDelete) {
if (value === undefined && isToDelete) {
delete window[prop] // 直接删除window新增的属性
} else {
window[prop] = value
}
}
active() {
// 恢复上一次沙箱数据
this.currentUpdatePropsValueMap.forEach((value, prop) => {
this.setWindowProp(prop, value)
})
}
// 注销
inactive() {
// 还原window上的属性
this.modifiedPropsOriginalValueMapInSandbox.forEach((value, prop) => {
this.setWindowProp(prop, value)
})
// 删除在微应用运行期间 window 新增的属性
this.addedPropsMapInSandbox.forEach((_, prop) => {
this.setWindowProp(prop, undefined, true)
})
}
}
window.city="最开始的值"
let sanapshotbox = new LegacySandbox()
console.log("改变前",window.city)// 最开始的值
sanapshotbox.active()
sanapshotbox.propsWindow.city="改变后的值"
console.log("改变后",window.city)// 改变后的值
sanapshotbox.inactive()
console.log('注销后',window.city)// 最开始的值
</script>
</body>
</html>
legacySandbox解决了之前快照沙箱的一些痛点:
- 使用了proxy代理,不用每次再遍历window属性了,性能相对有变好
- 卸载沙箱后,会将沙箱内部新增的属性删除,直接将属性删除,相对与快照沙箱而言,减少了沙箱外部window的属性污染。
但是仍然还有一些缺点:
- legacySandbox核心还是沙箱内外部共用一个window对象,当沙箱内部更改且没有注销沙箱的时候,外部的window仍然还是会被污染。这也就决定了legacySandbox还是只能是单实例沙箱。
- 沙箱激活之后,并不是直接修改window对象,而是代理的propsWindow属性,修改起来相对有点麻烦。
基于Proxy实现的多例沙箱
对于多实例沙箱,我起初是不太理解是什么样子,在网上参考了一个代码实现的多实例沙箱,但是实现之后我发现这个跟沙箱应该没什么关系。
因为这个沙箱内新增属性也好,获取属性也好,都不是在window上面实现的。就算没有Proxy也是可以的,因为沙箱外和各个沙箱使用的都不是同一个对象,沙箱外window,沙箱1proxySandbox01,沙箱2proxySandbox01,不同对象的数据当然会相互隔离的。
后面想了想,或许多实例沙箱的实现原理就是这样。我之所以觉得他不是多例沙箱是因为他根传统的沙箱不太一样,传统沙箱都是沙箱内部修改window值,和沙箱外部window进行隔离。所以基于这个自己想重新实现一个
网上参考代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>proxy代理沙箱</title>
</head>
<body>
<script>
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];
}
})
}
}
window.city="beijing"
let proxySandbox01 = new ProxySandbox()
let proxySandbox02 = new ProxySandbox()
proxySandbox01.active()
proxySandbox02.active()
proxySandbox01.proxyWindow.city = 'shanghai'
proxySandbox02.proxyWindow.city = 'chengdu'
console.log("111111",window.city)
console.log("222222",proxySandbox01.proxyWindow.city)
console.log("333333",proxySandbox01.proxyWindow.city)
proxySandbox01.inactive()
proxySandbox02.inactive()
console.log("111111",window.city)
console.log("222222",proxySandbox01.proxyWindow.city)
console.log("333333",proxySandbox01.proxyWindow.city)
</script>
</body>
</html>
在实现之前我还是很迷茫,对于多个实例,A沙箱设置了window,B沙箱也设置了window,那么window属性要按照哪个处理呢?
然后我就去看了qiankun框架源码的多实例实现,还是不能理解多实例沙箱的存在,直到,一篇文章拯救了我文章
我就不复制代码了,直接上核心截图了
我的理解就是,在微应用中,每个微应用虽然修改的时候修改名字是window属性,但是实际上这个window只是一个入参而已,这也就理解了每个微应用之间相互不影响了,而且也不需要像之前的沙箱一样注销后还需要再恢复window属性。
看到这里我发现这不就是利用了对象之间相互隔离的原理嘛,所以说之前的参考的代码也就理解了。
那就上手造一下,看实现结果是否与预期一致
class ProxySandbox {
proxyWindow = {}
isRunning = false
active() {
this.isRunning = true
}
inactive() {
this.isRunning = false
}
constructor() {
this.fakeWindow = {};
this.isRunning = false;
this.proxyWindow = new Proxy(this.fakeWindow, {
get: (target, key, receiver) => {
return target[key] || window[key];
},
set: (target, key, value, receiver) => {
if (this.isRunning) {
target[key] = value;
}
return true
},
});
}
}
// 沙箱1
let proxySandbox1 = new ProxySandbox()
proxySandbox1.active()
function fn(window, self, globalThis) { // 沙箱1内部代码
window.a = 1;
console.log('沙箱1内部',window.a) // 1
}
const bindedFn = fn.bind(proxySandbox1.proxyWindow);
bindedFn(proxySandbox1.proxyWindow, proxySandbox1.proxyWindow, proxySandbox1.proxyWindow);
// 沙箱2
let proxySandbox2 = new ProxySandbox()
proxySandbox2.active()
function fn2(window, self, globalThis) { // 沙箱2内部代码
window.a = 2;
console.log('沙箱2内部',window.a) // 2
}
const bindedFn2 = fn2.bind(proxySandbox2.proxyWindow);
bindedFn2(proxySandbox2.proxyWindow, proxySandbox2.proxyWindow, proxySandbox2.proxyWindow);
console.log('沙箱外部',window.a) // undefined
根据上面运行结果来看,沙箱外部,沙箱1,沙箱2的window确实是相互隔离的。这里可以把fn内部当作我们每一个微应用的代码。
但是上面代码中,每加一个微应用就要造一套重复代码,能不能整理一下呢?
话不多说直接上代码
class ProxySandbox {
proxyWindow = {}
isRunning = false
active() {
this.isRunning = true
}
inactive() {
this.isRunning = false
}
constructor() {
this.fakeWindow = {};
this.isRunning = false;
this.proxyWindow = new Proxy(this.fakeWindow, {
get: (target, key, receiver) => {
return target[key] || window[key];
},
set: (target, key, value, receiver) => {
if (this.isRunning) {
target[key] = value;
}
return true
},
});
}
}
class SandBox {
constructor(code) {
this.code = code;
}
sandbox(window, self, globalThis,code) {
eval(code)
}
active() {
this.instance = new ProxySandbox()
this.instance.active()
let bindedFn = this.sandbox.bind(this.instance.proxyWindow);
bindedFn(this.instance.proxyWindow, this.instance.proxyWindow, this.instance.proxyWindow,this.code);
}
}
let sandBox1 = new SandBox("window.a = 1;console.log('沙箱1====',window.a)")
let sandBox2 = new SandBox("window.a = 2;console.log('沙箱2====',window.a)")
sandBox1.active() // 沙箱1====,1
sandBox2.active()// 沙箱2====,2
console.log('windoww========', window.a) // undefined
上面代码中,添加了一个SandBox类,这个其实就是工厂模式,SandBox类就是一个生产沙箱的工厂,其中每个微应用内部的代码实现肯定都是不一样的,不可能每次添加沙箱都声明名命一个函数,所以采用了eval。到这里我突然就明白了qiankun为什么使用了eval。
CSS隔离
其实现在已经有很多成熟的css插件实现隔离
- vue的scoped
scoped会在元素上添加唯一的属性(data-v-x形式),css编译后也会加上属性选择器,从而达到限制作用域的目的。
- postcss-selector-namespace插件 这个是会在原有的class前面添加一个类,如下面图片,在所有类前面添加了.vue1-class类来进行其他系统隔离
这个方案其实也是我在做微前端qiankun中遇到的问题后面使用的方案
- 我们项目中的子系统都会有一些全局的样式,但是如果两个子系统全局样式存在类名字一样的,则两个系统的样式就会污染
- 每个子系统使用的框架不一样,比如A使用了vue2,B使用了vue3+element-plus,无可避免的element和element-plus会有一些样式不一样问题,这个时候也会出现样式污染问题
postcss-selector-namespace这个插件就完美解决了上面的问题,但是缺点就是这个css权重会变大。
npm i -D postcss-loader postcss-selector-namespace
在项目根目录放置一个文件,postcss.config.js,内容如下:
module.exports = {
plugins: {
'postcss-selector-namespace': {
namespace(css) {
// 不需要添加命名空间的文件
if (css.includes('unNamespace')) return '';
return '.base-css'
}
}
}
}
然后在app.vue/index.html,添加一个class
最后需要重启一下才会生效!!!
3. postcss-change-css-prefix
这个插件个人感觉不太好用,他只能修改当前项目自己的css文件的前缀,不能修改从第三方导入的css文件
npm install postcss-change-css-prefix -D
新建文件postcss.config.js,代码添加如下面,添加之后需重启生效
const addCssPrefix = require('postcss-change-css-prefix')
module.exports = {
plugins: [
addCssPrefix({
prefix: 'el-',
replace: 'vue2-', //需与上面命名保持一致
}),
],
}
参考文章: