qiankun 微前端总览

638 阅读3分钟

qiankun 微前端总览

前言

  • qiankun源码版本为:2.5.1
  • import-html-entry 源码版本为:1.11.1
  • single-spa源码版本为:5.9.3

总览

  • qiankunwindow 对象代理,子应用样式代理,fetch 请求 & eval执行 js 文件,html-entry 四个方面来达到简化 single-spa 使用的目的。
  • qiankun 引入了import-html-entry,其中window对象代理和子应用样式代理的部分是由qiankun 完成的;import-html-entry承担了fetch 请求 & eval执行 js 文件,html-entry 的功能。
  • 微前端的核心工作:路由劫持,子应用切换,还是需要single-spa来完成。

qiankun 部分

  • window代理

    src/sandbox/proxySandbox.ts的 第 69行~ 126行,196行~361行

    function createFakeWindow(globalContext: Window) {
        ...
    }
    ...
    export default class ProxySandbox implements SandBox {
        constructor(name: string, globalContext = window) {
            ...
    ​
        const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext);
            ...
            
        const proxy = new Proxy(fakeWindow, {
          set: ,
    ​
          get:,
    ​
          has,
    ​
          getOwnPropertyDescriptor,
    ​
         
          ownKeys,
    ​
          defineProperty,
    ​
          deleteProperty: ,
    ​
          getPrototypeOf() {
           
          },
        });
    ​
        this.proxy = proxy;
    }
    

    proxySandboxqiankun中多种沙箱的其中一种,是最常用的一种沙箱。

    这里有一个问题:new Proxy生成的代理对象,是在哪里使用的?也就是说,我们在子应用中使用的window对象肯定都是new Proxy生成的代理对象,那么这个new Proxy生成的代理对象是在什么时候替换window 对象的?

    答案就是import-html-entryeval 过程中,具体原理我会在import-html-entry 部分详细说一下。

  • 子应用样式代理

    子应用的样式在子应用的生命周期中过程,会经历添加,卸载,再添加,再卸载的过程。这个功能是qiankun 通过拦截linkstyle 标签的添加和移除实现的

    src/sandbox/patchers/dynamicAppend/forStrictSandbox.ts, 第78行~141行

    export function patchStrictSandbox() {
      let containerConfig = proxyAttachContainerConfigMap.get(proxy);
      
      const { dynamicStyleSheetElements } = containerConfig;
    ​
      const unpatchDocumentCreate = patchDocumentCreateElement();
    ​
      const unpatchDynamicAppendPrototypeFunctions = patchHTMLDynamicAppendPrototypeFunctions(
        (element) => elementAttachContainerConfigMap.has(element),
        (element) => elementAttachContainerConfigMap.get(element)!,
      );
    ​
      return function free() {
        if (allMicroAppUnmounted) {
          unpatchDynamicAppendPrototypeFunctions();
          unpatchDocumentCreate();
        }
    ​
        recordStyledComponentsCSSRules(dynamicStyleSheetElements);
      };
    

    src/sandbox/patchers/dynamicAppend/common.ts中, 第139行 ~ 264行,第306行 ~ 357行

    function getOverwrittenAppendChildOrInsertBefore() {
      return function appendChildOrInsertBefore() {
      
        ...
        
        if (element.tagName) {
         
          switch (element.tagName) {
            case LINK_TAG_NAME:
            case STYLE_TAG_NAME: {
            
              ...
              
              const mountDOM = appWrapperGetter();
    ​
              if (scopedCSS) {
                // exclude link elements like <link rel="icon" href="favicon.ico">
                const linkElementUsingStylesheet =
                  element.tagName?.toUpperCase() === LINK_TAG_NAME &&
                  (element as HTMLLinkElement).rel === 'stylesheet' &&
                  (element as HTMLLinkElement).href;
                if (linkElementUsingStylesheet) {
                  const fetch =
                    typeof frameworkConfiguration.fetch === 'function'
                      ? frameworkConfiguration.fetch
                      : frameworkConfiguration.fetch?.fn;
                  stylesheetElement = convertLinkAsStyle(
                    element,
                    (styleElement) => css.process(mountDOM, styleElement, appName),
                    fetch,
                  );
                  dynamicLinkAttachedInlineStyleMap.set(element, stylesheetElement);
                } else {
                  css.process(mountDOM, stylesheetElement, appName);
                }
              }
    ​
              // eslint-disable-next-line no-shadow
              dynamicStyleSheetElements.push(stylesheetElement);
              const referenceNode = mountDOM.contains(refChild) ? refChild : null;
              return rawDOMAppendOrInsertBefore.call(mountDOM, stylesheetElement, referenceNode);
            }
            
            ...
            
        }
    ​
        return rawDOMAppendOrInsertBefore.call(this, element, refChild);
      };
    }
    
    export function patchHTMLDynamicAppendPrototypeFunctions(
      isInvokedByMicroApp: (element: HTMLElement) => boolean,
      containerConfigGetter: (element: HTMLElement) => ContainerConfig,
    ) {
      // Just overwrite it while it have not been overwrite
      if (
        HTMLHeadElement.prototype.appendChild === rawHeadAppendChild &&
        HTMLBodyElement.prototype.appendChild === rawBodyAppendChild &&
        HTMLHeadElement.prototype.insertBefore === rawHeadInsertBefore
      ) {
        HTMLHeadElement.prototype.appendChild = getOverwrittenAppendChildOrInsertBefore({
          rawDOMAppendOrInsertBefore: rawHeadAppendChild,
          containerConfigGetter,
          isInvokedByMicroApp,
        }) as typeof rawHeadAppendChild;
        
        ...
    ​
        HTMLHeadElement.prototype.insertBefore = getOverwrittenAppendChildOrInsertBefore({
          rawDOMAppendOrInsertBefore: rawHeadInsertBefore as any,
          containerConfigGetter,
          isInvokedByMicroApp,
        }) as typeof rawHeadInsertBefore;
      }
    ​
      if (
        HTMLHeadElement.prototype.removeChild === rawHeadRemoveChild &&
        HTMLBodyElement.prototype.removeChild === rawBodyRemoveChild
      ) {
        HTMLHeadElement.prototype.removeChild = getNewRemoveChild(
          rawHeadRemoveChild,
          (element) => containerConfigGetter(element).appWrapperGetter,
        );
        
        ...
        
      }
    ​
      return function unpatch() {
        HTMLHeadElement.prototype.appendChild = rawHeadAppendChild;
        HTMLHeadElement.prototype.removeChild = rawHeadRemoveChild;
        HTMLBodyElement.prototype.appendChild = rawBodyAppendChild;
        HTMLBodyElement.prototype.removeChild = rawBodyRemoveChild;
    ​
        HTMLHeadElement.prototype.insertBefore = rawHeadInsertBefore;
      };
    }
    

    import-html-entry 部分

  • fetch 请求 & eval执行 js 文件

    浏览器端的javaScript的模块化规范有:AMD, CMD, ES模块等规范。 其中的流程都是要:请求js 文件,然后执行 js 文件。 AMDCMD 的实现,都是通过生成scritp标签来完成。 但是这样的话,就无法实现window对象的代理。因为qiankun 沙箱生成的window对象的代理, 需要通过eval 函数传递过来。

    • src/index.js中,execScripts方法中 第151行~237行,可以看到传递了一个 proxy的对象,proxy就是qiankun沙箱生成的window代理对象。
    export function execScripts(entry, scripts, proxy = window, opts = {}) {
    ​
        return getExternalScripts(scripts, fetch, error)
            .then(scriptsText => {
    ​
                const geval = (scriptSrc, inlineScript) => {
                   
                   ...
                   
                    const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal);
    ​
                    ...
    ​
                };
    ​
            });
    }
    
    • src/index.js中,第54行~65行
    function getExecutableScript(scriptSrc, scriptText, proxy, strictGlobal) {
        const sourceUrl = isInlineCode(scriptSrc) ? '' : `//# sourceURL=${scriptSrc}\n`;
    ​
        // 通过这种方式获取全局 window,因为 script 也是在全局作用域下运行的,所以我们通过 window.proxy 绑定时也必须确保绑定到全局 window 上
        // 否则在嵌套场景下, window.proxy 设置的是内层应用的 window,而代码其实是在全局作用域运行的,会导致闭包里的 window.proxy 取的是最外层的微应用的 proxy
        const globalWindow = (0, eval)('window');
        globalWindow.proxy = proxy;
        // TODO 通过 strictGlobal 方式切换 with 闭包,待 with 方式坑趟平后再合并
        return strictGlobal
            ? `;(function(window, self, globalThis){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`
            : `;(function(window, self, globalThis){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy, window.proxy);`;
    }
    
  • html-entry

    html-entry是相对于js-entry而言的,single-spa 要求每个子应用打包出来一个js 文件,例如vue项目就需要把 public/index.html在打包过程中给干掉(带来了复杂度)。 html-entry就避免了这种工作量,在打包过程中不做任何调整,对项目的侵入性就小了很多。

    这里还会引申出一个问题:例如,vue项目在使用webpack打包过程中,借助于代码分割, es 的懒加载功能,会生成多个js 文件。 webpack会编译代码为createElement script 标签。这些js 文件,是怎么被qiankun 框架拦截的?

    答案是在qiankuan的源码src/sandbox/patchers/dynamicAppend/common.ts中,第139行~264行, 在向dom 中新增script标签时,调用了 import-html-entry 开放的execScripts 方法,避免了script标签的插入。

    function getOverwrittenAppendChildOrInsertBefore() {
      return function appendChildOrInsertBefore() {
    ​
          switch (element.tagName) {
            
            ...
    ​
            case SCRIPT_TAG_NAME: {
              const { src, text } = element as HTMLScriptElement;
               
              ...
    ​
              const mountDOM = appWrapperGetter();
              const { fetch } = frameworkConfiguration;
              const referenceNode = mountDOM.contains(refChild) ? refChild : null;
    ​
              if (src) {
                execScripts(null, [src], proxy, {
                  fetch,
                  strictGlobal,
                  beforeExec: () => {
                    const isCurrentScriptConfigurable = () => {
                      const descriptor = Object.getOwnPropertyDescriptor(document, 'currentScript');
                      return !descriptor || descriptor.configurable;
                    };
                    if (isCurrentScriptConfigurable()) {
                      Object.defineProperty(document, 'currentScript', {
                        get(): any {
                          return element;
                        },
                        configurable: true,
                      });
                    }
                  },
                  success: () => {
                    manualInvokeElementOnLoad(element);
                    element = null;
                  },
                  error: () => {
                    manualInvokeElementOnError(element);
                    element = null;
                  },
                });
    ​
                const dynamicScriptCommentElement = document.createComment(`dynamic script ${src} replaced by qiankun`);
                dynamicScriptAttachedCommentMap.set(element, dynamicScriptCommentElement);
                return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicScriptCommentElement, referenceNode);
              }
    ​
              execScripts(null, [`<script>${text}</script>`], proxy, { strictGlobal });
              const dynamicInlineScriptCommentElement = document.createComment('dynamic inline script replaced by qiankun');
              dynamicScriptAttachedCommentMap.set(element, dynamicInlineScriptCommentElement);
              return rawDOMAppendOrInsertBefore.call(mountDOM, dynamicInlineScriptCommentElement, referenceNode);
            }
    ​
            ...
            
          }
        }
      };
    }
    

single-spa 部分

  • 路由劫持

    src/navigation/navigation-events.js 中, 第139行~197行

    if (isInBrowser) {
      window.addEventListener("hashchange", urlReroute);
      window.addEventListener("popstate", urlReroute);
     
      ...
    ​
      window.history.pushState = patchedUpdateState(
        window.history.pushState,
        "pushState"
      );
      window.history.replaceState = patchedUpdateState(
        window.history.replaceState,
        "replaceState"
      );
    }
    

single-spa-vue 部分

  • 生命周期钩子函数

    qiankun通过import-html-entry ,已经可以实现dom的挂载,卸载,更新逻辑,所以single-spa-vueqiankun框架中不需要了(但是需要导出相应的生命周期钩子函数)。

其他

欢迎大家关注微信公众号:赵公子聊前端