微前端icestark源码解读-沙箱sandbox的创建详解(五)
快速链接
icestark源码解读(一):控制微应用加载与卸载的核心原理
沙箱(sandbox)
首先我们先了解什么是沙箱,一个用于程序独立运行的虚拟环境,并且外界无法修改该环境的任何信息。
为什么需要沙箱
对于我们前端的应用而言,无非就是js和css。当我们遇到应用之间的css或者js相互影响、污染的时候,就该考虑启用沙箱去解决。故沙箱分为css沙箱和js沙箱。
例如:
- 主应用和子应用的css文件中都有class为.name的样式,这样两个应用之间的样式就会相互影响
- 主应用中有对window.name的操作,子应用中也有对window.name的操作,这样两个应用之间在访问与修改window.name的时候 就会相互影响
css 沙箱
-
由于
icestark
从设计上是:页面运行时的同时只会存在一个子应用,故多个子应用之间是不存在样式污染的问题。 -
但是主应用和子应用是会同时存在的。官方给出的解决方案很直接,使用
CSS Modules
来解决,。 -
如果你的主应用和子应用都使用了类似于
ant design
这样的第三方UI库的话,那么可以给其中一个应用的组件库设置一下class的前缀进行解决。 -
对于引用类似
normalize.css
这种全局重置样式的话,统一从主应用引入,子应用避免修改全局样式。
js 沙箱
对于js的隔离,官方采用的是Proxy
代理实现js沙箱。我们跟随源码来具体看下其实现的过程。源码位于代码库中package/sandbox
文件夹下面。
Sandbox
是一个类,内部首先定义一些私有变量以供微前端运行时使用。看下面源码以及注释。
private sandbox: Window;
private multiMode = false; // 是否启用多实例模式
private eventListeners = {}; // 记录监听的事件
private timeoutIds: number[] = []; // 记录定时器的id
private intervalIds: number[] = []; // 记录定时器的id
private propertyAdded = {}; // 记录添加的原始window对象身上没有的属性
private originalValues = {}; // 原始window对象身上有的属性,记录下在更改其属性值之前的属性以及属性值
public sandboxDisabled: boolean; // 记录是否禁用沙箱
constructor 函数
在我们执行 new Sandbox
的时候,首先执行的是constructor函数,其内部代码很简单。先是判断下浏览器是否支持Proxy
,然后就是初始化一个this.sandbox
用于储存一个Proxy沙箱。 对于multiMode
参数,暂时不进行讲解,用到的地方不多。暂时可以忽略。
constructor(props: SandboxProps = {}) {
const { multiMode } = props;
if (!window.Proxy) {
console.warn('proxy sandbox is not support by current browser');
this.sandboxDisabled = true; // 浏览器不支持Proxy,则将sandboxDisabled置为true
}
// enable multiMode in case of create mulit sandbox in same time
this.multiMode = multiMode; // 是否启用多实例沙箱
this.sandbox = null; // 储存沙箱的代理
}
createProxySandbox 函数
该函数用于创建一个Proxy
沙箱。
-
首先是通过
Object.create(null)
去创建一个干净并且可高度定制的一个对象(其身上不会继承Object
对象身上的任何方法),用于后续通过Proxy
进行代理. -
然后给新创建的对象设置
addEventListener
、removeEventListener
、setTimeout
、setInterval
属性,去劫持原始window对象身上的这些方法,劫持的目的主要是将事件与定时器的id记录在Sandbox
的实例中 -
最后通过
Proxy
方法去代理我们创建的对象,通过get
和set
方法,在新对象设置属性以及访问新对象身上的属性的时候,我们可以做一些逻辑处理。 -
set
方法中, 在设置代理对象身上的属性的时候,首先看设置的属性在原始window对象身上有没有,没有的话记录在propertyAdded
之中,如果原始window对象身上有该属性的话,那么就将该属性以及该属性在原始window对象身上的值记录在originalValues
中。 如果multiMode
未启用的话,会将本次设置的属性以及属性值在设置给代理对象的同时,也会设置给原始window对象。 -
get
方法中,在访问代理对象身上的属性时候,首先判断该属性是不是Symbol.unscopables
。 对于Symbol.unscopables
的介绍如下图所示:MDN介绍
由于后面会采用with函数执行script,故这里要排除
Symbol.unscopables
属性。
-
get方法中,如果访问的key是
'top', 'window', 'self', 'globalThis'
这四个中任何一个,则直接返回代理对象本身。 -
如果访问的key是
hasOwnProperty
, 则首先去看代理对象身上,代理对象身上没有的话则返回原始的window对象hasOwnProperty
方法的结果 -
如果访问的key在代理对象身上可以找到对应的value,则直接返回。否则去
createProxySandbox
函数的参数身上去找有没有对应的value,有则返回,没有的话,紧接着会去原始window对象身上找。 -
对于原始window对象身上寻找key对应的value时,若访问的key是
eval
的话,直接返回eval函数。对于访问的key是window对象身上除eval函数以外的函数的话,首先使用bind
函数重置下this的指向,确保指向的是原始window对象。有的函数可能会增加新的属性(例如:Axios, Moment
), 故需要遍历一遍函数,将函数身上的属性,全部复制一遍到重置this之后的函数身上。 -
对于访问的key在window对象对应的value不是函数的话,则直接返回。
-
Proxy中的
has
方法很简单,key在代理对象中是否存在,不存在的话则返回key在原始window对象身上是否存在的结果。 -
最后一步是将
new Proxy
返回的代理对象,赋值到this.sandbox
身上。
补充知识点:
判断某个属性是否属于某个对象,有两个方式,hasOwnProperty
方法和in
关键字。
in
关键字用来判断某个属性属于某个对象,可以是对象的自有属性,也可以是通过prototype继承的属性hasOwnProperty
方法用来判断某个属性属于某个对象,只会检查对象的自有属性,通过prototype继承的属性不会检测
/**
* 创建Proxy沙箱
* @param injection
*/
createProxySandbox(injection?: object) {
const { propertyAdded, originalValues, multiMode } = this;
const proxyWindow = Object.create(null) as Window; // 创建一个干净且高度可定制的对象
const originalWindow = window; // 缓存原始window对象
const originalAddEventListener = window.addEventListener; // 缓存原始addEventListener事件绑定函数
const originalRemoveEventListener = window.removeEventListener;// 缓存原始removeEventListener事件移除函数
const originalSetInterval = window.setInterval; // 缓存原始定时器setInterval函数
const originalSetTimeout = window.setTimeout; // 缓存原始定时器setTimeout函数
// 劫持 addEventListener,将绑定的事件名以及事件的回调函数全部储存在this.eventListeners中
proxyWindow.addEventListener = (eventName, fn, ...rest) => {
this.eventListeners[eventName] = (this.eventListeners[eventName] || []);
this.eventListeners[eventName].push(fn);
return originalAddEventListener.apply(originalWindow, [eventName, fn, ...rest]);
};
// 劫持 removeEventListener, 将解绑的事件名以及事件的回调函数从this.eventListeners中移除掉
proxyWindow.removeEventListener = (eventName, fn, ...rest) => {
const listeners = this.eventListeners[eventName] || [];
if (listeners.includes(fn)) {
listeners.splice(listeners.indexOf(fn), 1);
}
return originalRemoveEventListener.apply(originalWindow, [eventName, fn, ...rest]);
};
// 劫持 setTimeout,将每一个定时器的id储存在this.timeoutIds
proxyWindow.setTimeout = (...args) => {
const timerId = originalSetTimeout(...args);
this.timeoutIds.push(timerId); // 存储timerId
return timerId;
};
// 劫持 setInterval,将每一个定时器的id储存在this.intervalIds
proxyWindow.setInterval = (...args) => {
const intervalId = originalSetInterval(...args);
this.intervalIds.push(intervalId); // 存储intervalId
return intervalId;
};
// 创建Proxy,代理proxyWindow
const sandbox = new Proxy(proxyWindow, {
/**
* 设置属性以及属性值
* @param target 代理的对象 proxyWindow
* @param p 属性名
* @param value 属性值
*/
set(target: Window, p: PropertyKey, value: any): boolean {
// eslint-disable-next-line no-prototype-builtins
if (!originalWindow.hasOwnProperty(p)) { // 说明原始window对象身上没有该属性
// record value added in sandbox
propertyAdded[p] = value; // 将该属性以及属性值记录在propertyAdded变量中
// eslint-disable-next-line no-prototype-builtins
} else if (!originalValues.hasOwnProperty(p)) { // 说明原始window对象身上有该属性, 需要在originalValues中记录下本次设置的属性以及属性值
// if it is already been setted in original window, record it's original value
originalValues[p] = originalWindow[p];
}
// set new value to original window in case of jsonp, js bundle which will be execute outof sandbox
if (!multiMode) {
originalWindow[p] = value; // 将window对象身上没有的属性设置到window对象身上
}
// eslint-disable-next-line no-param-reassign
target[p] = value; // 设置属性以及属性值到代理的对象身上
return true;
},
/**
* 获取代理对象身上的属性值
* @param target 代理的对象 proxyWindow
* @param p 属性名
*/
get(target: Window, p: PropertyKey): any {
// Symbol.unscopables 介绍 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Symbol/unscopables
if (p === Symbol.unscopables) {
return undefined;
}
if (['top', 'window', 'self', 'globalThis'].includes(p as string)) {
return sandbox;
}
// proxy hasOwnProperty, in case of proxy.hasOwnProperty value represented as originalWindow.hasOwnProperty
if (p === 'hasOwnProperty') {
// eslint-disable-next-line no-prototype-builtins
return (key: PropertyKey) => !!target[key] || originalWindow.hasOwnProperty(key);
}
const targetValue = target[p];
/**
* Falsy value like 0/ ''/ false should be trapped by proxy window.
*/
if (targetValue !== undefined) {
// case of addEventListener, removeEventListener, setTimeout, setInterval setted in sandbox
return targetValue;
}
// search from injection
const injectionValue = injection && injection[p];
if (injectionValue) {
return injectionValue;
}
const value = originalWindow[p];
/**
* use `eval` indirectly if you bind it. And if eval code is not being evaluated by a direct call,
* then initialise the execution context as if it was a global execution context.
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval
* https://262.ecma-international.org/5.1/#sec-10.4.2
*/
if (p === 'eval') {
return value;
}
if (isWindowFunction(value)) { // 判断是不是window对象身上的函数
// When run into some window's functions, such as `console.table`,
// an illegal invocation exception is thrown.
const boundValue = value.bind(originalWindow); // 更改this指向为原始window对象
// Axios, Moment, and other callable functions may have additional properties.
// Simply copy them into boundValue.
for (const key in value) {
boundValue[key] = value[key];
}
return boundValue;
} else {
// case of window.clientWidth、new window.Object()
return value;
}
},
/**
* 用于判断代理对象身上是否有指定的属性
* @param target 代理对象
* @param p 属性的key
*/
has(target: Window, p: PropertyKey): boolean {
return p in target || p in originalWindow;
},
});
this.sandbox = sandbox;
}
execScriptInSandbox 函数
从命名上我们可以看出,该函数的作用是执行沙箱里面的js代码。其核心是通过with
+ new Function
去创建沙箱运行环境,从而执行js。
- 在js的执行中,访问变量是通过作用域链来进行查找,在with块级作用域下,访问变量会先从with指定的对象身上查找,指定的对象身上找不到的话,则正常按照作用域链去向上找。MDN: with语句描述
- 使用new Function 关键字去创建函数,通过bind函数,将this绑定到代理对象
this.sandbox
上面,将代理对象this.sandbox
作为参数传入进去。MDN: new Function 描述
通过上面两个步骤可以将沙箱内js对window对象的访问与修改,转向去对代理对象this.sandbox
的访问与修改。
/**
* 执行沙箱里面的js代码
* @param script
*/
execScriptInSandbox(script: string): void {
if (!this.sandboxDisabled) {
// create sandbox before exec script
if (!this.sandbox) {
this.createProxySandbox();
}
try {
// with 语句中 执行的js,在访问变量的时候都会先从sandbox对象身上找
const execScript = `with (sandbox) {;${script}\n}`; // 要执行的js代码
// eslint-disable-next-line no-new-func
// 创建一个sandbox作为参数的函数
const code = new Function('sandbox', execScript).bind(this.sandbox);
// 将this.sandbox作为参数传入函数内部
code(this.sandbox);
} catch (error) {
console.error(`error occurs when execute script in sandbox: ${error}`);
throw error;
}
}
}
clear 函数
顾名思义其作用就是用来清空沙箱,当子应用卸载的时候,肯定要把为子应用创建的沙箱清空掉。其本质是将微应用沙箱执行js后产生的影响全部消除。
- 将
this.eventListeners
中储存的所有的事件全部解除绑定 - 清除所有的定时器(setInterval、setTimeout)
this.originalValues
中储存的原始window对象身上key对应的value,进行恢复- 根据
this.propertyAdded
,将原始window对象身上没有的属性全部移除
/**
* 清空沙箱
*/
clear() {
if (!this.sandboxDisabled) {
// remove event listeners
Object.keys(this.eventListeners).forEach((eventName) => {
(this.eventListeners[eventName] || []).forEach((listener) => {
window.removeEventListener(eventName, listener);
});
});
// clear timeout
this.timeoutIds.forEach((id) => window.clearTimeout(id));
this.intervalIds.forEach((id) => window.clearInterval(id));
// recover original values
Object.keys(this.originalValues).forEach((key) => {
window[key] = this.originalValues[key];
});
Object.keys(this.propertyAdded).forEach((key) => {
delete window[key];
});
}
}
沙箱的创建时机
上面我们介绍了沙箱的实现原理,这部分我们从源码的角度上看下,是在什么时候去创建的沙箱。
从官方文档上面对开启沙箱的描述来看,只需要在appConfig中配置sandbox
为true
即可开启子应用沙箱.
源码中,在监听路由变化,控制子应用加载与卸载的reroute
函数,创建子应用createMicroApp
函数中,会根据appConfig
的sandbox
配置,去执行createSandbox
函数创建沙箱。
createSandbox函数
该函数内部很简单,主要就是实例化Sandbox
类,得到一个沙箱的实例并返回。有了沙箱实例之后,我们就可以调用实例身上的createProxySandbox
方法,去创建window
的代理对象。
export function createSandbox(sandbox?: boolean | SandboxProps | SandboxConstructor) {
// Create appSandbox if sandbox is active
let appSandbox = null;
if (sandbox) {
if (typeof sandbox === 'function') {
// eslint-disable-next-line new-cap
appSandbox = new sandbox();
} else {
const sandboxProps = typeof sandbox === 'boolean' ? {} : (sandbox as SandboxProps);
// 实例一个沙箱
appSandbox = new Sandbox(sandboxProps);
}
}
return appSandbox;
}
在通过fetch的方式去加载子应用的jsloadScriptByFetch
函数中,去执行getGobalWindow
函数,获取子应用的window对象。
getGobalWindow 函数
export function getGobalWindow(sandbox?: Sandbox) {
if (sandbox?.getSandbox) {
// 开启了sandbox的话,则去创建沙箱的代理对象并返回
sandbox.createProxySandbox();
return sandbox.getSandbox();
}
// FIXME: If run in Node environment
return window; // 未开启sandbox直接返回原始window对象,作为子应用的全局window对象
}
在fetch获取到子应用的js字符串之后,执行js的函数executeScripts
中,通过沙箱实例身上的execScriptInSandbox
方法,去执行子应用的js,从而将子应用中对变量的访问与操作,全部转向从沙箱中的代理对象上访问与操作,以此隔绝了子应用对全局原始window对象的修改。
executeScripts 函数
function executeScripts(scripts: string[], sandbox?: Sandbox, globalwindow: Window = window) {
let libraryExport = null;
for (let idx = 0; idx < scripts.length; ++idx) {
const lastScript = idx === scripts.length - 1;
if (lastScript) {
noteGlobalProps(globalwindow);
}
if (sandbox?.execScriptInSandbox) {
sandbox.execScriptInSandbox(scripts[idx]); // 子应用开启了沙箱,则在沙箱中执行js
} else {
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval
// eslint-disable-next-line no-eval
(0, eval)(scripts[idx]); // 未开启沙箱的子应用,则通过eval 执行子应用的js
}
if (lastScript) {
libraryExport = getGlobalProp(globalwindow);
}
}
return libraryExport;
}
总结
从上面的介绍来看,icestark沙箱是基于Proxy进行创建的,并且一次只能运行一个微应用。对于其创建的过程,理解上并不是很难。
接下来我们简单总结下其整个过程:
- 创建子应用的时候,开始创建沙箱
- 创建一个代理window对象,通过
Proxy
进行代理 - fetch获取到子应用的js,通过
with
+new Function
的方式去执行子应用js,将子应用中对于window身上变量的访问与修改,改为从代理window对象身上去访问与修改。如果子应用访问代理对象身上的变量没找到的话,则会去从原始window对象身上访问。 - 这样下来,主应用是在操作原始windo对象,而子应用是在操作代理window对象,两者就不会相互影响了。
转载自:https://juejin.cn/post/7185429778059493413