1:如何配置进行控制
registerMicroApps方法里面调用loadApp会传入frameworkConfiguration, 这个对象是乾坤框架对外提供的api用于控制应用加载,切换,沙箱等行为
loadMicroApp方法参数会传入configuration?: FrameworkConfiguration
FrameworkConfiguration 里面可配置的属性如下:
export let frameworkConfiguration: FrameworkConfiguration = {};
ts: FrameworkConfiguration = QiankunSpecialOpts & ImportEntryOpts & StartOpts;
QiankunSpecialOpts :
{
prefetch
sandbox?:
| boolean
| {
strictStyleIsolation?: boolean;
experimentalStyleIsolation?: boolean;
patchers?: Patcher[];
};
/*
with singular mode, any app will wait to load until other apps are unmouting
it is useful for the scenario that only one sub app shown at one time
*/
singular?: boolean | ((app: LoadableApp<any>) => Promise<boolean>);
excludeAssetFilter?: (url: string) => boolean; // skip some scripts or links intercept, like JSONP
}
ImportEntryOpts :
{
fetch?: typeof window.fetch;
getPublicPath?: (entry: Entry) => string;
getTemplate?: (tpl: string) => string;
}
StartOpts:
{
urlRerouteOnly?: boolean;
}
2:在什么时候,什么地方调用运行
loader.ts 里面 加载完子应用方法 里面
export async function loadApp<T extends object>(
app: LoadableApp<T>,
configuration: FrameworkConfiguration = {},
lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObject> {
.......
const { singular = false, sandbox = true, excludeAssetFilter, ...importEntryOpts } = configuration;
// get the entry html content and script executor
// 核心代码,加载app对应的入口文件,以及将文件处理成模板,和可执行脚本信息对象
const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
......
// 根据appContent 返回对应的对象,这里dom对象里面是有沙箱机制的
let element: HTMLElement | null = createElement(appContent, strictStyleIsolation);
.......
// 创建沙箱
if (sandbox) {
const sandboxInstance = createSandbox(
appName,
containerGetter,
Boolean(singular),
enableScopedCSS,
excludeAssetFilter,
);
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxInstance.proxy as typeof window;
}
// 执行子应用的脚本
const scriptExports: any = await execScripts(global, !singular);
...........
return parcelConfig;
}
在createElement里面, 利用shadow dom 构造独立的代码片段,类型iframe
// 创建元素,appContent 为里面的内容,strictStyleIsolation与沙箱有关
// 根据appContent 返回对应的对象,这里dom对象里面是有沙箱机制的
function createElement(appContent: string, strictStyleIsolation: boolean): HTMLElement {
.........
// 如果元素支持沙箱,否则创建沙箱
if (appElement.attachShadow) {
shadow = appElement.attachShadow({ mode: 'open' });
} else {
// createShadowRoot was proposed in initial spec, which has then been deprecated
shadow = (appElement as any).createShadowRoot();
}
shadow.innerHTML = innerHTML;
..........
return appElement;
}
shadow dom 会隔离css以及利用document不会找到里面的dom,但是对于js脚本而已并没有做到绝对的隔离,比如window.setInterval等里面的方法以及属性还是会与外界相互影响,此时乾坤框架createSandbox实现了对脚本的隔离。
沙箱分为3种:
1:singular=true,如果是单一应用切换则用LegacySandbox,
2:singular=false, 如果一个页面包含多个子应用则用ProxySandbox
3:如果浏览器不支持window.Proxy,则兼容用SnapshotSandbox
ProxySandbox - 多子应用情况
1: fakewindow + window的组合,每次new ProxySandbox() 会创建fakewindow实例作为proxy
2:set时值放到fakewindow里面,get时先从fakewindow里面取,取不到就到window里面取
2:判断是特殊属性比如不可配置,编辑,修改的属性,就直接从window里面取
export default class ProxySandbox implements SandBox {
/** window 值变更记录 */
private updatedValueSet = new Set<PropertyKey>();
name: string;
type: SandBoxType;
proxy: WindowProxy;
sandboxRunning = true; // 沙箱状态
active() {
// 记录激活的沙箱数量
if (!this.sandboxRunning) activeSandboxCount++;
this.sandboxRunning = true;
}
inactive() {
.........
this.sandboxRunning = false;
}
constructor(name: string) {
// 变量配置,这里rowWindow = window
.........
// 将不可编辑的特殊属性记录到propertiesWithGetter
const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);
.........
const proxy = new Proxy(fakeWindow, {
set(target: FakeWindow, p: PropertyKey, value: any): boolean {
// 如果本实例的沙箱正在运行
if (self.sandboxRunning) {
.........
// @ts-ignore
target[p] = value;
// 记录修改的值
updatedValueSet.add(p);
.........
}
.........
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},
get(target: FakeWindow, p: PropertyKey): any {
// 一些特殊属性,如[window,self,top,hasOwnProperty,document] 的特殊处理以及返回
.........
// eslint-disable-next-line no-bitwise
// 有getter的属性,直接访问window.p, 否则访问fakewindow.p
// 如果没有不可编辑且具有getter的属性,就先从fakewindow里面取,取不到就从window里面取
const value = propertiesWithGetter.has(p) ? (rawWindow as any)[p] : (target as any)[p] || (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
// trap in operator
// see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
has() {},
// 获取 FakeWindow || window 里面的自有属性
getOwnPropertyDescriptor() {}
// trap to support iterator with sandbox
// FakeWindow + window 里面的不重复的属性canvcat
ownKeys() {}
// 首先看这个属性是从哪来的,从window里面来的就在window定义
defineProperty() {},
// 这里只删除 fakeWindow里面的属性
deleteProperty() {},
});
this.proxy = proxy;
}
}
图示如下:
LegacySandbox - 单应用的情况,之后会使用ProxySandbox替代
/**
* 基于 Proxy 实现的沙箱
* TODO: 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换
* 生成一个代替window对象的委托,set,get时实际操作的window对象属性同时记录操作行为,active,inactive时释放操作行为使window对象还原
*/
export default class SingularProxySandbox implements SandBox {
/** 沙箱期间新增的全局变量 */
private addedPropsMapInSandbox = new Map<PropertyKey, any>();
/** 沙箱期间更新的全局变量 */
private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();
/** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();
.......
active() {
.......
// 根据之前修改的记录重新修改window的属性,即还原沙箱之前的状态
this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
.......
}
inactive() {
.......
// renderSandboxSnapshot = snapshot(currentUpdatedPropsValueMapForSnapshot);
// restore global props to initial snapshot
// 将沙箱期间修改的属性还原为原先的属性
this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => setWindowProp(p, v));
// 将沙箱期间新增的全局变量消除
this.addedPropsMapInSandbox.forEach((_, p) => setWindowProp(p, undefined, true));
this.sandboxRunning = false;
}
constructor(name: string) {
.......
const proxy = new Proxy(fakeWindow, {
// 在fakeWindow.p = v 执行前,会将p,v增加/编辑队列记录
set(_: Window, p: PropertyKey, value: any): boolean {
.......
// 新增p属性放入新增队列
addedPropsMapInSandbox.set(p, value);
.......
.......
// 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
const originalValue = (rawWindow as any)[p];
// 记录原始属性
modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
// 记录修改属性以及修改后的值
currentUpdatedPropsValueMap.set(p, value);
// 设置值
(rawWindow as any)[p] = value;
.......
// 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
return true;
},
get(_: Window, p: PropertyKey): any {
// 特殊属性处理
.......
const value = (rawWindow as any)[p];
return getTargetValue(rawWindow, value);
},
.......
},
});
this.proxy = proxy;
}
}
SnapshotSandbox- 不兼容window.Proxy的情况
基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器
- 代理实质为window,get,set,修改的是window的属性,但是active的时候,会生成window的快照,inactive的时候会根据快照还原
3:有什么用处,出于什么原因要设计沙箱机制
页面上多个子应用会造成 全局变量等Js冲突,Css&DOM冲突:样式文件相互影响,dom可能带有相同的class,id造成选中困难
Css&DOM冲突 可以用shadow dom来解决,但是js目前只能使用ProxySandbox脚本hack
5:流程图+结构图
沙箱补丁
启动阶段补丁:patchAtBootstrapping,生成沙箱createSandbox()的时候执行,在loadApp()加载应用文件,生成shadow dom后执行,之后才是导出并执行应用的启动阶段
挂载阶段补丁:生成沙箱createSandbox()的时候导出patchAtMounting,在应用的mount阶段执行
patchAtBootstrapping 主要是对dom的创建,插入,移除等原生方法进行了一层封装,主要有插入style后的css的样式生效以及scoped的隔离逻辑,插入script后自动执行脚本功能逻辑
const basePatchers = [
() => patchDynamicAppend(false),
]
// 执行并返回资源释放,原生方法还原的接口
return basePatchers .map(patch => patch());
patchAtMounting 除了上述patchAtBootstrapping的功能外,对Interval,addEventListener,historyListener等方法的封装
const basePatchers = [
() => patchInterval(sandbox.proxy),
() => patchWindowListener(sandbox.proxy),
() => patchHistoryListener(),
() => patchDynamicAppend(true),
]
// 执行并返回资源释放,原生方法还原的接口
return basePatchers .map(patch => patch());
调用时机:
loadApp() {
.......
// 创建沙箱
if (sandbox) {
const sandboxInstance = createSandbox(...); // 里面已经执行了 patchAtBootstrapping
// 用沙箱的代理对象作为接下来使用的全局对象
global = sandboxInstance.proxy as typeof window;
mountSandbox = sandboxInstance.mount;
unmountSandbox = sandboxInstance.unmount;
}
.......
// 返回封装好的生命周期钩子
return {
bootstrap: [...],
mount: [..., mountSandbox, ...],
unmount: [..., unmountSandbox, ...]
}
}
补丁在沙箱中如何执行:
createSandbox(...) {
sandbox = [LegacySandbox, ProxySandbox, SnapshotSandbox] // 根据条件选择里面一种
// some side effect could be be invoked while bootstrapping,
// such as dynamic stylesheet injection with style-loader, especially during the development phase
// 执行启动阶段补丁, 返回释放还原的接口列表
const bootstrappingFreers = patchAtBootstrapping()
return {
proxy: sandbox.proxy,
mount() {
sandbox.active();
// sideEffectsRebuilders 包含启动阶段的rebuild,和mount阶段的rebuld,将其拆分出来
// 启动阶段的rebuild执行
const satb = sideEffectsRebuilders.slice(0, bootstrappingFreers.length);
satb.forEach(rebuild => rebuild());
// 返回挂载阶段的释放还原的接口
mountingFreers = patchAtMounting()
// mount阶段的rebuld
const satm = sideEffectsRebuilders.slice(bootstrappingFreers.length);
satm.forEach(rebuild => rebuild());
},
unmount() {
// record the rebuilders of window side effects (event listeners or timers)
// note that the frees of mounting phase are one-off as it will be re-init at next mounting
// 启动阶段,挂载阶段的所有释放资源,同时返回rebuild列表并记录下来,下次挂载用
sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map(free => free());
sandbox.inactive();
}
}
}
dom操作的封装 :patchDynamicAppend
patchDynamicAppend 是 patchAtBootstrapping 和 patchAtMounting的核心方法
这里有一个场景,微应用加载后,执行微应用里面的脚本,我们知道由于沙盒的设置,里面的全局变量访问以proxy的方式进行,但是通过生成<script src>
加载并执行远程脚本的时候,proxy就无效了,通过js生成<style>内容(无论内联还是远程)的时候样式文件的scoped并未绑定对应微应用,这会导致样式与脚本变量会影响到全局,
为解决这个问题,乾坤在生成沙箱的时候会去mock掉createElement,apend,insertBefore
/**
* Just hijack dynamic head append, that could avoid accidentally hijacking the insertion of elements except in head.
* Such a case: ReactDOM.createPortal(<style>.test{color:blue}</style>, container),
* this could made we append the style element into app wrapper but it will cause an error while the react portal unmounting, as ReactDOM could not find the style in body children list.
*/
export default function patch(
appName: string,
appWrapperGetter: () => HTMLElement | ShadowRoot,
proxy: Window,
mounting = true,
singular = true,
scopedCSS = false,
excludeAssetFilter?: CallableFunction,
): Freer {
// 缓存,用于free和rebuild资源
let dynamicStyleSheetElements: Array<HTMLLinkElement | HTMLStyleElement> = [];
// 重写creatElement,返回还原的方法
const unpatchDocumentCreate = patchDocumentCreateElement();
// 重写append,返回还原的方法
//HTMLHeadElement.prototype.appendChild = rawHeadAppendChild;
//HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
//HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
//HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;
//HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore;
const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions();
............
// 返回释放的方法
return function free() {
............
unpatchDynamicAppendPrototypeFunctions(allMicroAppUnmounted);
unpatchDocumentCreate(allMicroAppUnmounted);
dynamicStyleSheetElements.forEach(setCachedRules);
return function rebuild() {
dynamicStyleSheetElements.forEach(getCachedRules);
............
};
};
}
patchDocumentCreateElement 方法对document.createElement方法封装,如果创建的标签是script,style,link,则通过document[attachDocProxySymbol]检查当前运行环境是否在微应用内部,若是,则创建的元素绑定attachElementContainerSymbol标识,用作后续的apend,insert
// 在创建元素时,会判断当前环境是否为shadow dom且生成tag为style,script,link, 若是则将生成的元素里面注入一个标识
function patchDocumentCreateElement() {
// 将proxy以及应用信息缓存起来,后续apend,insert操作会用上
proxyContainerInfoMapper.set(proxy, { appName, proxy, appWrapperGetter, dynamicStyleSheetElements, singular });
Document.prototype.createElement = function createElement(): HTMLElement {
// 执行原方法
const element = rawDocumentCreateElement.call(this, tagName, options);
// 是否style,script,link
if (tagName == [style,script,link]) {
// 判断当前documnent.createElement运行环境是否为对应微应用
const proxy = (this即document)[attachDocProxySymbol]
const proxyContainerInfo = proxyContainerInfoMapper.get(proxy);
if (proxyContainerInfo) {
// 将应用信息赋值给element,如果element为style,script,link
// 这里是为插入的时候,据此判断是否为shadow dom 插入style,script,若是,则作相关处理
Object.defineProperty(element, attachElementContainerSymbol, {
value: proxyContainerInfo,
enumerable: false,
});
}
}
return element;
};
// 还原
return function unpatch(recoverPrototype: boolean) {
proxyContainerInfoMapper.delete(proxy);
if (recoverPrototype) {
Document.prototype.createElement = rawDocumentCreateElement;
}
};
}
在上面上面补丁执行之前,在生成沙盒对象ProxySandbox的时候,会对proxy下访问document做处理
// mark the symbol to document while accessing as document.createElement could know is invoked by which
// sandbox for dynamic append patcher
class ProxySandbox {
get() {}
if (p === 'document') {
document[attachDocProxySymbol] = proxy;
// remove the mark in next tick, thus we can identify whether it in micro app or not
// this approach is just a workaround, it could not cover all the complex scenarios, such as the micr app
// runs in the same task context with master in som case
// fixme if you have any other good ideas
nextTick(() => delete document[attachDocProxySymbol]);
return document;
}
}
getOverwrittenAppendChildOrInsertBefore 封装原生apendChild,insertBefore方法,在此过程中,
若增加或插入的元素绑定了微应用,且绑定的应用是激活状态则
style:则将其存在数组中,将元素插入到mountDOM中,scrope绑定mountDOM
script:则加载并执行脚本,将对应注释插入到mountDOM中
// 获取重写的AppendChild, InsertBefore 方法,返回封装后的方法
// 用于挂载的时候
// 是shadow dom,且绑定的应用是激活状态,且为style,则将其存在数组中,将元素插入到mountDOM中,scoped绑定mountDOM
// 是shadow dom,且绑定的应用是激活状态,且为script,则加载并以proxy为上下文代替window执行脚本,将对应注释插入到mountDOM中
// 其他走正常流程插入
function getOverwrittenAppendChildOrInsertBefore(opts: {
appName: string;
proxy: WindowProxy;
singular: boolean;
dynamicStyleSheetElements: HTMLStyleElement[];
appWrapperGetter: CallableFunction;
rawDOMAppendOrInsertBefore: <T extends Node>(newChild: T, refChild?: Node | null) => T;
scopedCSS: boolean;
excludeAssetFilter?: CallableFunction;
}) {
return function appendChildOrInsertBefore(
this: HTMLHeadElement | HTMLBodyElement,
newChild: T,
refChild?: Node | null,
) {
let element = newChild as any;
// 原生方法
const { rawDOMAppendOrInsertBefore } = opts;
// 这里传入的element可能是字符串??正常dom对象是有tagName的
if (element.tagName) {
..........
// 如果element里面有微应用相关信息,说明当前dom操作是在微应用中,在之前createElement时缓存的信息
const storedContainerInfo = element[attachElementContainerSymbol];
// 如果要插入的元素是shadow dom,则将shadow dom里面携带的应用相关的配置信息覆盖掉上面参数传入的配置信息
if (storedContainerInfo) {
// 覆盖应用信息
}
// 检查element对应的应用是否是shadow dom且激活状态
..........
switch (element.tagName) {
// 如果是样式dom
case LINK_TAG_NAME:
case STYLE_TAG_NAME: {
const stylesheetElement = newChild;
// 如果不是shadow dom或者绑定的应用没激活,或者href被excludeAssetFilter排除在外
// 则直接走正常插入方法
if (!invokedByMicroApp || (excludeAssetFilter && href && excludeAssetFilter(href))) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
}
const mountDOM = appWrapperGetter();
// 需要将新插入的style元素对应的css样式作用域绑定到mountDOM上
if (scopedCSS) {
css.process(mountDOM, stylesheetElement, appName);
}
// eslint-disable-next-line no-shadow
// 将样式元素缓存起来,将来free,rebuild有用
dynamicStyleSheetElements.push(stylesheetElement);
// 将样式元素加入到mountDOM中
const referenceNode = mountDOM.contains(refChild) ? refChild : null;
return rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode);
}
case SCRIPT_TAG_NAME: {
const { src, text } = element;
// some script like jsonp maybe not support cors which should't use execScripts
// 如果不是shadow dom或者绑定的应用没激活,或者href被excludeAssetFilter排除在外
// 则直接走正常插入方法
if (!invokedByMicroApp || (excludeAssetFilter && src && excludeAssetFilter(src))) {
return rawDOMAppendOrInsertBefore.call(this, element, refChild) as T;
}
const mountDOM = appWrapperGetter();
const { fetch } = frameworkConfiguration;
const referenceNode = mountDOM.contains(refChild) ? refChild : null;
// 远程链接脚本执行,并触发onload事件,execScripts里面会将proxy作为执行环境上下文,import-html-entry里面
if (src) {
execScripts(null, [src], proxy, {
fetch,
strictGlobal: !singular,
beforeExec: () => {
Object.defineProperty(document, 'currentScript', {
get(): any {
return element;
},
configurable: true,
});
},
// 手动触发onload事件
success: () => {
element.onload(loadEvent) || element.dispatchEvent(loadEvent);
},
error: () => {
element.onerror(errorEvent) || element.dispatchEvent(errorEvent);
},
});
// 将注释插入到mountDOM中
const dynamicScriptCommentElement = document.createComment(`dynamic script ${src} replaced by qiankun`);
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicScriptCommentElement, referenceNode);
}
// 内联脚本的执行
execScripts(null, [`<script>${text}</script>`], proxy, {
strictGlobal: !singular,
success: element.onload,
error: element.onerror,
});
// 将注释插入到mountDOM中
const dynamicInlineScriptCommentElement = document.createComment('dynamic inline script replaced by qiankun');
return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
}
default:
break;
}
}
// refChild为null,则为appendChild, 否则为insertBefore
return rawDOMAppendOrInsertBefore.call(this, element, refChild);
};
}