乐趣区

关于qiankun:清晰简单易懂的qiankun主流程分析

微前端系列之:
一、记一次微前端技术选型
二、清晰简略易懂的 qiankun 主流程剖析
三、记一次落地 qiankun

本文是系列之二。

综述

qiankun 是在 single-spa 根底上进行二次开发的。本文外围剖析 利用加载 利用切换 利用隔离 这三个外围性能原理。

一开始打算是间接从源码角度上介绍流程和原理的,但会导致篇幅过长,且重点抓不住。

所以打算把一些主流程间接用流程图和文字说明,一些外围实现,能够再联合原来来解读。

残缺流程图:https://github.com/Rockergmai…

主流程

路由劫持

路由劫持性能,是在 single-spa 实现的。

hashchange / popstate 全局事件做监听,触发 reroute 办法。

window.addEventListener window.removeEventListener window.history.pushState window.history.replaceState做劫持。

外围性能是 reroute,下文会重点介绍,这个办法被好几个办法都调用了。

注册微利用

用户调用 qiankun 提供的 registerMicroApps 注册子利用。它其实是调用了 single-sparegisterApplication 来进行注册的,其中的 appOrLoadApp 这个选项用来传入加载微利用那个的逻辑。当路由匹配 activeWhen 时,会用来加载微利用。

qiankun 帮咱们实现了加载微利用的逻辑,是通过 loadApp 办法来加载的,其外围是应用到了 import-html-entry 这个库来实现的。

最初还是会调用 reroute 办法。

启动 qiankun

用户调用 qiankunstart 办法,启动 qiankun

启用预加载微利用策略,依据不同的策略,加载微利用。外围实现就是用 requestIdelCallbackcpu 闲暇时预加载微利用的入口文件、以及近程 scriptsstyles

最初也会调用 reroute 办法。

reroute

调用 getAppChanges 获取 toUnload toUnmount toLoad toMount 这四种状态的微利用(依据以后 url 和微利用注册的门路是否匹配,以及微利用以后状态来做过滤的)。

如果 qiankun 已启动,则对 toUnload toUnmount toLoad toMount 中的微利用对象做一些相应改变(如 state 的扭转、生命周期钩子的增删等等),以及触发对应的生命周期钩子。其中 toLoad 的微利用会先进行加载微利用,再进行微利用对象的改变。

如果 qiankun 未启动,对 toLoad 的微利用会进行加载微利用操作。

加载微利用

加载微利用是 single-spa 调用 registerApplicationappOrLoadApp 这个传入的办法实现的。传入的办法是 qiankunloadApp 办法,是通过 import-html-entry 这个库来是事实的。

通过 fetch 办法来加载入口 html。失去 html 文本后通过正则剖析出 inline scripts、近程 scripts、inline style 和近程 styles。加载近程 styles 之后,把 inline styles 和近程下载下来的 styles 文本笼罩到 html 的 style 和 link 标签。而后返回 template(html 文本)、assetPublicPath、getExternalScripts、getExternalStyleSheets、execScripts(script 执行器)。

用 div#__qiankun_microapp_wrapper_for_微利用名字__ 来包裹入口 html 内容,而后挂载到微利用容器中。在挂载前,如果设置了款式隔离会进行款式隔离。

创立沙箱。

调用 script 执行器(execScripts),传入沙箱的代理对象作为微利用的全局对象。

取出微利用导出的 bootstrap、mount、unmount、update 生命周期函数。取出用于跟以后微利用通信的办法:onGlobalStateChange, setGlobalState, offGlobalStateChange。

返回微利用对象(app),其中有 bootstrap、mount 和 unmount 这三个生命周期钩子属性。

bootstrap 就是 微利用提供的 bootstrap 办法

mount 封装了以下办法:挂载前用户提供的 loader(true)、确保每次利用加载前容器 dom 构造曾经设置结束,对款式做隔离 、挂载沙箱、触发 beforeMount 钩子、 微利用提供的 mount 办法、再次确保每次利用加载前容器 dom 构造曾经设置结束、触发 afterMount 钩子、挂载后用户提供的 loader(false)

unmount 封装了以下办法:触发 beforeUnmount 钩子、调用微利用提供的 unmount 办法、触发 afterUnmount 钩子、删除 div#__qiankun_microapp_wrapper_for_微利用名字__

查看微利用是否有导出 bootstrap、mount、unmount 生命周期函数,没有则报错

微利用对象 app,置 status 为 NOT_BOOTSTRAPPED,以及对几个参数做格式化

(图片是从下到上网上看)

reroute

当初再来看 reroute,如果 qiankun 已启动,则对 toUnload toUnmount toLoad toMount 中的微利用对象做一些相应改变(如 state 的扭转、生命周期钩子的增删等等),以及触发对应的生命周期钩子。这里的生命周期钩子,就是刚刚说的注册的 bootstrap、mount、unmount 生命钩子。

款式隔离

款式隔离,qiankun 提供了两种计划:shadow dom 计划、scoped 计划。

在挂载沙箱之前,进行款式隔离操作,能够在 qiankunsrc/loader.ts 查看源码

function createElement(
  appContent: string,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  appInstanceId: string,
): HTMLElement {const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div
  const appElement = containerElement.firstChild as HTMLElement;
  if (strictStyleIsolation) {if (!supportShadowDOM) {
      console.warn('[qiankun]: As current browser not support shadow dom, your strictStyleIsolation configuration will be ignored!',
      );
    } else {const { innerHTML} = appElement;
      appElement.innerHTML = '';
      let shadow: ShadowRoot;

      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;
    }
  }

  if (scopedCSS) {const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {appElement.setAttribute(css.QiankunCSSRewriteAttr, appInstanceId);
    }

    const styleNodes = appElement.querySelectorAll('style') || [];
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {css.process(appElement!, stylesheetElement, appInstanceId);
    });
  }

  return appElement;
}

如果是 shadow dom 模式,为每个微利用的容器包裹上一个 shadow dom 节点,从而确保微利用的款式不会对全局造成影响。

如果是 scoped 模式,能够到 qiankunsrc/sandbox/patchers/css.ts查看源码。次要做了,监听 style 节点的监听,而后有变动,则做 rewrite 操作。rewrite 次要做了以下工作:针对每个款式规定前都加 div# 微利用名 来实现 scoped 的成果。如原本是.test{width: 100%;},scoped 之后变成 div[data-qiankun= 微利用名] .test{width: 100%:}

沙箱

能够到 qiankunsrc/sandbox/index.ts 查看源码。

依据环境是否反对 proxy、以及是否多例模式,抉择不同的沙箱实现计划(共三种)。很多文章曾经做了源码剖析,我就不再重复劳动了。无非是针对全局变量的增、删、改操作做代理,在卸载的时候,把全局变量切回去。

script 执行器(execScripts)

能够到 import-html-entrysrc/index.js 查看源码。

export function execScripts(entry, scripts, proxy = window, opts = {}) {
    const {fetch = defaultFetch, strictGlobal = false, success, error = () => {}, beforeExec = () => {}, afterExec = () => {},} = opts;

    return getExternalScripts(scripts, fetch, error)
        .then(scriptsText => {const geval = (scriptSrc, inlineScript) => {const rawCode = beforeExec(inlineScript, scriptSrc) || inlineScript;
                const code = getExecutableScript(scriptSrc, rawCode, proxy, strictGlobal);

                evalCode(scriptSrc, code);

                afterExec(inlineScript, scriptSrc);
            };

            function exec(scriptSrc, inlineScript, resolve) {const markName = `Evaluating script ${scriptSrc}`;
                const measureName = `Evaluating Time Consuming: ${scriptSrc}`;

                if (process.env.NODE_ENV === 'development' && supportsUserTiming) {performance.mark(markName);
                }

                if (scriptSrc === entry) {noteGlobalProps(strictGlobal ? proxy : window);

                    try {
                        // bind window.proxy to change `this` reference in script
                        geval(scriptSrc, inlineScript);
                        const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
                        resolve(exports);
                    } catch (e) {
                        // entry error must be thrown to make the promise settled
                        console.error(`[import-html-entry]: error occurs while executing entry script ${scriptSrc}`);
                        throw e;
                    }
                } else {if (typeof inlineScript === 'string') {
                        try {
                            // bind window.proxy to change `this` reference in script
                            geval(scriptSrc, inlineScript);
                        } catch (e) {
                            // consistent with browser behavior, any independent script evaluation error should not block the others
                            throwNonBlockingError(e, `[import-html-entry]: error occurs while executing normal script ${scriptSrc}`);
                        }
                    } else {
                        // external script marked with async
                        inlineScript.async && inlineScript?.content
                            .then(downloadedScriptText => geval(inlineScript.src, downloadedScriptText))
                            .catch(e => {throwNonBlockingError(e, `[import-html-entry]: error occurs while executing async script ${inlineScript.src}`);
                            });
                    }
                }

                if (process.env.NODE_ENV === 'development' && supportsUserTiming) {performance.measure(measureName, markName);
                    performance.clearMarks(markName);
                    performance.clearMeasures(measureName);
                }
            }

            function schedule(i, resolvePromise) {if (i < scripts.length) {const scriptSrc = scripts[i];
                    const inlineScript = scriptsText[i];

                    exec(scriptSrc, inlineScript, resolvePromise);
                    // resolve the promise while the last script executed and entry not provided
                    if (!entry && i === scripts.length - 1) {resolvePromise();
                    } else {schedule(i + 1, resolvePromise);
                    }
                }
            }

            return new Promise(resolve => schedule(0, success || resolve));
        });
}

execScripts 的 entry,是取入口 html 的最初一个 script 作为入口 js。对每个 scripts 串行一一执行 exec,exec 又调用到 geval。直到执行到最初一个 scripts,就 resolve 掉 promise。

exec,如果 script 不是入口 js,则执行 geval。如果 script 是入口 js,执行 geval 之后,resolve 掉 promise,传入代理全局 window 的 proxy 对象。

geval,将 js 代码,用 iife 封装,且传入全局 window 的 proxy,即微利用的沙箱全局对象。

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);`;
}

这里的 (0, eval)('window') 是获取全局 window 对象的办法,为什么不 eval('window'),是因为这样执行,是在以后作用域上执行,获取到的 window 对象不能保障是全局 window 对象,有可能是在某层微利用中的作用域,指向了该微利用的全局对象。对于 eval 的黑魔法,能够看这两篇文章:1 2

而后执行 evalCode 办法。实质就是执行这段代码。

export function evalCode(scriptSrc, code) {
    const key = scriptSrc;
    if (!evalCache[key]) {const functionWrappedCode = `window.__TEMP_EVAL_FUNC__ = function(){${code}}`;
        (0, eval)(functionWrappedCode);
        evalCache[key] = window.__TEMP_EVAL_FUNC__;
        delete window.__TEMP_EVAL_FUNC__;
    }
    const evalFunc = evalCache[key];
    evalFunc.call(window);
}

能够看到,是通过 iife 封装微利用 js,而后传入全局 window 的 proxy 对象作为微利用的全局对象。以达到执行微利用、隔离微利用的成果。

assetPublicPath

assetPublicPath 是加载微利用资源的 publicPath,它默认的获取形式,是基于 defaultGetPublicPath 办法获取的

export function defaultGetPublicPath(entry) {if (typeof entry === 'object') {return '/';}
    try {const { origin, pathname} = new URL(entry, location.href);
        const paths = pathname.split('/');
        // 移除最初一个元素
        paths.pop();
        return `${origin}${paths.join('/')}/`;
    } catch (e) {console.warn(e);
        return '';
    }
}

能够看到,它会把 html 入口地址的 path,去掉最初一个元素,再返回,当做 assetPublicPath

这里的 assetPublicPath 就是 qiankun 注入到全局的 __INJECTED_PUBLIC_PATH_BY_QIANKUN__ 变量。

后记

很多细节没有在这篇写进去,如果感兴趣,能够去啃源码 + 打断点去摸索下。如以下这些:

  1. 如果是 singular 模式,会在加载微利用前,先卸载已有的微利用等等
  2. 统计性能
  3. reroute 调度,有一些事件被延后执行的
  4. qiankun 源码正文中,说 3.x 会废除 props 通信计划、shadow dom 款式隔离计划
  5. 利用通信,通过 props 通信
退出移动版