微前端03 : 乾坤的沙箱容器分析(Js沙箱机制建立后的具体应用)

2022-09-27 14:16:11 浏览数 (1)

“在微前端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.activesandbox.inactive两个方法让沙箱激活或者失活。如果是这样的话,这个沙箱容器的存在的意义就不大了,但我在介绍mount、unmount两个方法中的其他逻辑之前,我们来先看看代码片段一中占位1处的三行代码:

代码语言:javascript复制
// 代码片段二,所属文件:src/sandbox/index.ts
const bootstrappingFreers = patchAtBootstrapping(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter);
let mountingFreers: Freer[] = []; 
let sideEffectsRebuilders: Rebuilder[] = []; 

函数patchAtBootstrapping

我们先暂时只关注第一行代码,这里调用了函数patchAtBootstrapping:

代码语言:javascript复制
// 代码片段三,所属文件: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的代码吧:

代码语言:javascript复制
// 代码片段四,所属文件: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处所省略的代码:

代码语言:javascript复制
// 代码片段六,所属文件: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对应的配置对象,那么则定一个初始化配置对象,并以proxykey,以这个配置对象为value,存储到缓存变量proxyAttachContainerConfigMap中。三是从containerConfig中获取dynamicStyleSheetElements。这里有几个点值得推敲。首先,proxy是什么,为什么要以proxykey将配置对象存储在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样式,如下所示:

代码语言:javascript复制
// 注意虽然样式呈现的效果等价,但实际上通过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中的代码:

代码语言:javascript复制
// 代码片段七,所属文件: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);,代码如下:

代码语言:javascript复制
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函数改变以及恢复状态。

0 人点赞