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

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

本文是系列之二。

综述

qiankun 是在 single-spa 基础上进行二次开发的。本文核心分析应用加载应用切换应用隔离 这三个核心功能原理。

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

所以打算把一些主流程直接用流程图和文字说明,一些核心实现,可以再结合原来来解读。

完整流程图:https://github.com/Rockergmai...

主流程

清晰简单易懂的qiankun主流程分析_第1张图片

路由劫持

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

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

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

核心功能是 reroute,下文会重点介绍,这个方法被好几个方法都调用了。

清晰简单易懂的qiankun主流程分析_第2张图片

注册微应用

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

qiankun 帮我们实现了加载微应用的逻辑,是通过 loadApp 方法来加载的,其核心是使用到了 import-html-entry 这个库来实现的。

最后还是会调用 reroute 方法。

清晰简单易懂的qiankun主流程分析_第3张图片

启动qiankun

用户调用 qiankunstart 方法,启动 qiankun

启用预加载微应用策略,根据不同的策略,加载微应用。核心实现就是用 requestIdelCallbackcpu 空闲时预加载微应用的入口文件、以及远程 scriptsstyles

最后也会调用 reroute 方法。

清晰简单易懂的qiankun主流程分析_第4张图片

reroute

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

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

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

清晰简单易懂的qiankun主流程分析_第5张图片

加载微应用

加载微应用是 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,以及对几个参数做格式化

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

清晰简单易懂的qiankun主流程分析_第6张图片

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通讯

你可能感兴趣的:(qiankun)