“在微前端01 : 乾坤的Js隔离机制(快照沙箱、两种代理沙箱)中,我们知道了乾坤的沙箱的核心原理和具体实现。但知道这些还不够,因为沙箱本身就像是一个工具,有了工具还得应用到实践中,这个工具才能创造价值发挥作用。我们也在微前端02 : 乾坤的微应用加载流程分析(从微应用的注册到loadApp方法内部实现)中提到了在加载微应用过程中跟沙箱相关的部分逻辑,但受限于篇幅并未展开。本文将会详细讲解乾坤对沙箱的具体应用。 ”
沙箱容器的主逻辑
对沙箱机制的具体应用,本质上就是对沙箱容器的控制,至于什么是沙箱容器,我们直接看代码:
代码语言:javascript复制// 代码片段一,所属文件:src/sandbox/index.ts
/**
* @param appName
* @param elementGetter
* @param scopedCSS
* @param useLooseSandbox
* @param excludeAssetFilter
* @param globalContext
*/
export function createSandboxContainer(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
scopedCSS: boolean,
useLooseSandbox?: boolean,
excludeAssetFilter?: (url: string) => boolean,
globalContext?: typeof window,
) {
let sandbox: SandBox;
if (window.Proxy) {
sandbox = useLooseSandbox ? new LegacySandbox(appName, globalContext) : new ProxySandbox(appName, globalContext);
} else {
sandbox = new SnapshotSandbox(appName);
}
// 此处省略许多代码... 占位1
return {
instance: sandbox,
async mount() {
// 此处省略许多代码... 占位2
sandbox.active();
// 此处省略许多代码... 占位3
},
async unmount() {
// 此处省略许多代码... 占位4
sandbox.inactive();
// 此处省略许多代码... 占位5
}
};
}
由代码片段一可知,所谓沙箱容器,就是一个对象。该对象包括三个属性instance、mount、unmount
,其中instace
代表沙箱实例,mount、unmount
是两个方法,供沙箱容器持有者在合适的时机进行调用。关于沙箱实例,我们先看创建沙箱实例的时候传入了globalContext
,还记得我们在微前端01 : 乾坤的Js隔离机制(快照沙箱、两种代理沙箱)中各沙箱的极简版吧,当时我直接用的window
,那为什么在真实源码中要通过传入globalContext
而不是直接使用window
呢。答案其实很简单,参数存在的意义就是参数值可变,否则都直接写死了,换句话说更灵活了。举个例子,如果我们的微应用的载体是另一个微应用呢?如果没有这种灵活性,就不能很好的支持复杂多变的场景,乾坤作为业界知名框架,在众多开发者的打磨下,对于细节的处理确实很值得学习。聊完了沙箱实例的创建,我们再来看看mount、unmount
这两个方法。如果忽略省略的代码片段注释处省略的代码,那mount、unmount
仅仅是调用sandbox.active
、sandbox.inactive
两个方法让沙箱激活或者失活。如果是这样的话,这个沙箱容器的存在的意义就不大了,但我在介绍mount、unmount
两个方法中的其他逻辑之前,我们来先看看代码片段一中占位1处的三行代码:
// 代码片段二,所属文件:src/sandbox/index.ts
const bootstrappingFreers = patchAtBootstrapping(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter);
let mountingFreers: Freer[] = [];
let sideEffectsRebuilders: Rebuilder[] = [];
函数patchAtBootstrapping
我们先暂时只关注第一行代码,这里调用了函数patchAtBootstrapping
:
// 代码片段三,所属文件:src/sandbox/patchers/index.ts
export function patchAtBootstrapping(
appName: string,
elementGetter: () => HTMLElement | ShadowRoot,
sandbox: SandBox,
scopedCSS: boolean,
excludeAssetFilter?: CallableFunction,
): Freer[] {
const patchersInSandbox = {
[SandBoxType.LegacyProxy]: [
() => patchLooseSandbox(appName, elementGetter, sandbox.proxy, false, scopedCSS, excludeAssetFilter),
],
[SandBoxType.Proxy]: [
() => patchStrictSandbox(appName, elementGetter, sandbox.proxy, false, scopedCSS, excludeAssetFilter),
],
[SandBoxType.Snapshot]: [
() => patchLooseSandbox(appName, elementGetter, sandbox.proxy, false, scopedCSS, excludeAssetFilter),
],
};
return patchersInSandbox[sandbox.type]?.map((patch) => patch());
}
函数patchAtBootstrapping
只做了一件事情,就是根据不同的沙箱类型,执行后并以数组的形式返回执行结果。为什么是数组类型呢?就这个方法本身而言,直接返回函数值没有任何问题,因为从代码中可以看出,不管何种沙箱类型,在patchAtBootstrapping
中,都只执行了一个函数。之所以包装成数组,是因为其他和patchAtBootstrapping
发挥作用类似的函数,比如patchAtMounting
,里面就会有多个函数需要执行。这样做的好处是,保证了数据格式的统一,利于后续相关逻辑进行统一处理,同时也有很好的可扩展性,将来如果函数patchAtBootstrapping
需要执行多个函数,不需要改动代码整体结构。这是我们值得学习的。
函数patchStrictSandbox
至于patchLooseSandbox、patchStrictSandbox、patchLooseSandbox
这三个函数。接下来我只深入到patchStrictSandbox
中去,因为patchStrictSandbox
最重要,其他两个函数的内部主体逻辑和patchStrictSandbox
类似,感兴趣的朋友们可以自行阅读,如果有不清楚的地方可以留言交流。接下来我们就看看函数patchStrictSandbox
的代码吧:
// 代码片段四,所属文件:src/sandbox/patchers/dynamicAppend/forStrictSandbox.ts
export function patchStrictSandbox(
appName: string,
appWrapperGetter: () => HTMLElement | ShadowRoot,
proxy: Window,
mounting = true,
scopedCSS = false,
excludeAssetFilter?: CallableFunction,
): Freer {
// 此处省略许多代码... 占位1
return function free() {
// 此处省略许多代码... 占位2
return function rebuild() {
// 此处省略许多代码... 占位3
};
};
}
在省略了许多代码后,我们可以直观的看到该函数的主体结构,这个过程我们可以用伪代码来描述这个调用过程:
代码语言:javascript复制// 代码片段五
let freeFunc = patchStrictSandbox(许多参数...); // 第一步:在这个函数里面执行了代码,影响了程序状态
let rebuidFun = freeFunc(); // 第二步:将第一步中对程序状态的影响撤销掉
rebuidFun();// 第三步:恢复到第一步执行完成时程序的状态
理解了patchStrictSandbox
的主逻辑,我们来看看代码片段四中占位1处所省略的代码:
// 代码片段六,所属文件:src/sandbox/patchers/dynamicAppend/forStrictSandbox.ts
export function patchStrictSandbox(
appName: string,
appWrapperGetter: () => HTMLElement | ShadowRoot,
proxy: Window,
mounting = true,
scopedCSS = false,
excludeAssetFilter?: CallableFunction,
): Freer {
//*********************第一部分*********************/
let containerConfig = proxyAttachContainerConfigMap.get(proxy);
if (!containerConfig) {
containerConfig = {
appName,
proxy,
appWrapperGetter,
dynamicStyleSheetElements: [],
strictGlobal: true,
excludeAssetFilter,
scopedCSS,
};
proxyAttachContainerConfigMap.set(proxy, containerConfig);
}
const { dynamicStyleSheetElements } = containerConfig;
/***********************第二部分*********************/
const unpatchDocumentCreate = patchDocumentCreateElement();
const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
(element) => elementAttachContainerConfigMap.has(element),
(element) => elementAttachContainerConfigMap.get(element)!,
);
// 此处省略许多代码...
}
函数patchStrictSandbox的第一部分逻辑
我们先来分析代码片段六中的第一部分,可以看到该部分有几个重要的变量,proxyAttachContainerConfigMap、dynamicStyleSheetElements、proxy、containerConfig
,这部分代码做了三件事,一是从缓存变量proxyAttachContainerConfigMap
中根据proxy
获取配置对象,如果有就赋值给变量containerConfig
。二是如果缓存中没有proxy
对应的配置对象,那么则定一个初始化配置对象,并以proxy
为key
,以这个配置对象为value
,存储到缓存变量proxyAttachContainerConfigMap
中。三是从containerConfig
中获取dynamicStyleSheetElements
。这里有几个点值得推敲。首先,proxy
是什么,为什么要以proxy
为key
将配置对象存储在proxyAttachContainerConfigMap
中?proxy
实际上就是在上文代码片段一中创建的沙箱实例,对应代码片段一中的sandbox
变量。
其次,在代码片段六中,proxyAttachContainerConfigMap
只赋值了初始值,既然有是从缓存变量proxyAttachContainerConfigMap
中根据proxy
获取配置对象的这个操作,说明proxyAttachContainerConfigMap
肯定在其他地方有更新containerConfig
的操作,否则没必要只缓存一个初始化值。具体应该在哪里更新这个containerConfig
,更新containerConfig
中的哪个属性对应的值,我们在后文会提到。
最后,dynamicStyleSheetElements
是什么?实际上其类型是HTMLStyleElement[]
,HTMLStyleElement
表示<style>
元素。我们这里不追究HTMLStyleElement
到底有多少属性和方法,但需要关注的是,HTMLStyleElement
实例中有一个sheet
属性,这个属性是一个CSSStyleSheet
对象。至于CSSStyleSheet
的概念和各种属性我就不在这里一一详述了,可以参阅相关文档了解。此时我们需要知道的是,CSSStyleSheet
的实例有个重要的属性cssRules
,该属性类型为CSSRuleList
,是一个CSSStyleRule
对象数组。关于CSSStyleRule
的详细内容就不继续介绍了,只需要知道CSSStyleRule
相当于代表了一条具体的css样式,如下所示:
// 注意虽然样式呈现的效果等价,但实际上通过CssStyleRule控制样式和普通的以文本的形式挂载到dom上的样式有着一些不同,这些不同会在后面提到
div{
color:red;
}
这里了解这些就足够了,后续在分析乾坤对css资源进行处理的时候还会涉及CSSStyleRule
,到时再继续探讨。
“注:请阅读英文版MDN文档,对于HTMLStyleElement的解释,中文版的 翻译还比较落后,与英文版的介绍有出入 ”
函数patchStrictSandbox的第二部分逻辑
这时我们将视野回到代码片段六中的第二部分,为了方便阅读将相关代码放到这里:
代码语言:javascript复制const unpatchDocumentCreate = patchDocumentCreateElement();
const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
(element) => elementAttachContainerConfigMap.has(element),
(element) => elementAttachContainerConfigMap.get(element)!,
);
patchDocumentCreateElement
我们先看看patchDocumentCreateElement
中的代码:
// 代码片段七,所属文件:src/sandbox/patchers/dynamicAppend/forStrictSandbox.ts
function patchDocumentCreateElement() {
// 省略许多代码...
const rawDocumentCreateElement = document.createElement;
Document.prototype.createElement = function createElement(
// 省略许多代码...
): HTMLElement {
const element = rawDocumentCreateElement.call(this, tagName, options);
// 关键点1
if (isHijackingTag(tagName)) {
// 省略许多代码
}
return element;
};
// 关键点2
if (document.hasOwnProperty('createElement')) {
document.createElement = Document.prototype.createElement;
}
// 关键点3
docCreatePatchedMap.set(Document.prototype.createElement, rawDocumentCreateElement);
}
return function unpatch() {
// 关键点4
//此次省略一些代码...
Document.prototype.createElement = docCreateElementFnBeforeOverwrite;
document.createElement = docCreateElementFnBeforeOverwrite;
};
}
在省略一些代码后,patchDocumentCreateElement
函数实现的功能,逐渐清晰可见。该函数主要做了三件事情。一是重写Document.prototype.createElement
,重写的目的在代码片段七中的关键点1体现,具体关键点1内部做了什么由于逻辑较简单暂不在这里介绍。二是保存重写后的createElement
和重写前的createElement
这二者的对应关系,对应关键点3。至于上面代码片段提到的关键点2,是对document
的一个变化,这个点应该和其他地方的逻辑有关系,否则没有必要对document
进行判断处理,暂时没发现用到这个处理的地方,后续找到了相关逻辑再补上这个细节,但意义不太大,再看情况决定。三是返回一个函数,该函数会还原重写Document.prototype.createElement
时候对Document.prototype.createElement
的影响。
由于篇幅较长,请将我们的视野再次移动到代码片段六中的第二部分:
代码语言:javascript复制const unpatchDocumentCreate = patchDocumentCreateElement();
const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
(element) => elementAttachContainerConfigMap.has(element),
(element) => elementAttachContainerConfigMap.get(element)!,
);
刚才我们分析了函数patchDocumentCreateElement
,现在可以知道代码片段中的unpatchDocumentCreate
是一个函数,执行后会清除对Document.prototype.createElement
的影响。这里我不再进入函数patchHTMLDynamicAppendPrototypeFunctions
中进行分析,原理和函数patchDocumentCreateElement
类似,只不过其影响和恢复的的是HTMLHeadElement.prototype.appendChild、HTMLHeadElement.prototype.removeChild、HTMLBodyElement.prototype.removeChild、HTMLHeadElement.prototype.insertBefore
等原型方法。
函数patchStrictSandbox的free函数
此时,请将视线移动到代码片段四中的占位2处,代码如下:
代码语言:javascript复制// 此处省略许多代码...
if (allMicroAppUnmounted) {
unpatchDynamicAppendPrototypeFunctions();
unpatchDocumentCreate();
}
recordStyledComponentsCSSRules(dynamicStyleSheetElements);
从上文的分析我们知道,执行unpatchDynamicAppendPrototypeFunctions、unpatchDocumentCreate
两个函数后,会清除重写相应原型函数的影响。我们重点看看recordStyledComponentsCSSRules(dynamicStyleSheetElements);
,代码如下:
export function recordStyledComponentsCSSRules(styleElements: HTMLStyleElement[]): void {
styleElements.forEach((styleElement) => {
if (styleElement instanceof HTMLStyleElement && isStyledComponentsLike(styleElement)) {
if (styleElement.sheet) {
styledComponentCSSRulesMap.set(styleElement, (styleElement.sheet as CSSStyleSheet).cssRules);
}
}
});
}
核心其实只有一行代码:styledComponentCSSRulesMap.set(styleElement, (styleElement.sheet as CSSStyleSheet).cssRules);
。上文我们知道了cssRules
代表着一条条具体的css样式,就这行代码而言,这些样式是从远程加载而来,相当于从网络上获取了一个css
文件,然后对其中的内容进行解析,生成一个style
标签,style
标签具体承载的样式并非以字符串的形式,这里的具体代码比较冗长暂时不贴出来。实际上就是保存一个style
标签对象和其中的内容之间的关系。这里保存的cssRules
在下文的分析中会用到。
函数patchStrictSandbox中free函数的rebuild函数
此时,请将视线移动到代码片段四中的占位3处,代码如下:
代码语言:javascript复制return function rebuild() {
rebuildCSSRules(dynamicStyleSheetElements, (stylesheetElement) => {
const appWrapper = appWrapperGetter();
if (!appWrapper.contains(stylesheetElement)) {
rawHeadAppendChild.call(appWrapper, stylesheetElement);
return true;
}
return false;
});
};
对应的rebuildCSSRules函数如下:
代码语言:javascript复制export function rebuildCSSRules(
styleSheetElements: HTMLStyleElement[],
reAppendElement: (stylesheetElement: HTMLStyleElement) => boolean,
) {
styleSheetElements.forEach((stylesheetElement) => {
const appendSuccess = reAppendElement(stylesheetElement);
if (appendSuccess) {
if (stylesheetElement instanceof HTMLStyleElement && isStyledComponentsLike(stylesheetElement)) {
const cssRules = getStyledElementCSSRules(stylesheetElement);
if (cssRules) {
for (let i = 0; i < cssRules.length; i ) {
const cssRule = cssRules[i];
const cssStyleSheetElement = stylesheetElement.sheet as CSSStyleSheet;
cssStyleSheetElement.insertRule(cssRule.cssText, cssStyleSheetElement.cssRules.length);
}
}
}
}
});
}
从代码逻辑看可以直观的看出两件事情,一是将前面生成的style
标签添加到微应用上;二是将之前保存的cssRule
插入到对应的style
标签上。为什么一定要执行insertRule
呢?通过cssRule
动态控制样式和普通style
标签控制样式有所不同。一旦cssRule
所关联的style
标签脱离document
,这些cssRule
都会失效。这也是为什么需要保存和重新设置的原因。
到此,本文代码片段一中的占位1处的代码就算执行完成了。对占位1的代码理解清楚后,本文也就基本完成了。因为mount、unmount
其实就是在利用占位1提供的bootstrappingFreers
函数改变以及恢复状态。