Hi FE !
Ai
git
前端面试题
前端小tip
  • vite
  • webpack
npm
  • vue2
  • vue3
react
GitHub
Ai
git
前端面试题
前端小tip
  • vite
  • webpack
npm
  • vue2
  • vue3
react
GitHub
  • 2.微应用加载流程分析

2.微应用加载流程分析

乾坤的微应用加载流程分析(从微应用的注册到loadApp方法内部实现)

乾坤的微应用加载流程主要触发场景包括下面四个:

通过registerMicroApps注册微应用 通过loadMicroApp手动加载微应用 调用start时触发了预加载逻辑 手动调用prefetchApps执行预加载

其实不管通过什么场景触发微应用加载逻辑,进行微应用加载本身的执行方法都只有一个,那就是位于src/loaser.ts文件中的loadApp方法。为了方便大家理解,认识微应用加载逻辑在乾坤中的位置,我将主要触发场景列在上面,关于上面列出的方法,都是乾坤暴露出来的api,可以在乾坤文档上查阅到相关用途。本文会以具体的乾坤微应用的注册流程开始,进而引出loadApp方法中的实现细节。在介绍loadApp实现细节的过程中,我会先分析乾坤加载微应用的主体流程和关键环节。在大家对主流程了解的基础上,将其中需要注意的关键点,分成多个小节进行介绍。但对于一些内容可能会比较多的细节,我们会再用新的文章来进行详细分析。比如在微前端01 : 乾坤的Js隔离机制原理剖析(快照沙箱、两种代理沙箱)提到的三种沙箱,我们当时分析了其核心原理,但它们是如何在乾坤中的发挥作用的当时并没有提及,虽然微应用加载流程和沙箱机制有比较强的关联,但该部分内容又相对较多,所以我们会在后续的文章中结合沙箱相关代码和加载流程相关代码进行详细介绍。具体内容请见下文。

乾坤的微应用注册流程

  1. 筛选出未注册微应用(registerMicroApps 的第一个参数将要注册的微应用列表,其中包含已经注册过的微应用,一个微应用只需要注册一次)
  2. 记录已经注册的微应用
  3. 循环遍历,传入四个参数,逐个注册
  4. 在single-spa 中执行注册方法
  5. 执行乾坤注册时候传入第二个参数(第二个参数是一个函数,就是上面提到的加载方法,这个方法的意义就是获取微应用的各种资源,并对资源进行加工,然后返回微应用暴露的生命周期)
  6. 接收函数执行后的返回值并保存 分别对应流程图中的第4步和第5步:

微应用的注册,实际上发生在single-spa中 子应用暴露的生命周期函数,由乾坤提供的函数参数返回


export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // Each app only needs to be registered once
  const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));

  microApps = [...microApps, ...unregisteredApps];

  unregisteredApps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;

    registerApplication({
      name,
      app: async () => {
        loader(true);
        await frameworkStartedDefer.promise;

        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();

        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
  });
}

// 简化一下

// 代码片段一,所属文件: src/apis.ts
export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // 这里省略了其他代码...
  unregisteredApps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;
    registerApplication({//关键点1
      name,
      app: async () => {// 关键点2
        // 这里省略了其他代码...
        const { mount, ...otherMicroAppConfigs } = (
          // 关键点4
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();
        return {// 关键点3
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },
      activeWhen: activeRule,
      customProps: props,
    });
  });
}

下面我们先了解上面代码片段中的一些关键信息:

从上面代码片段中的关键点1处可以直观的看出,真正发起注册微应用的方法是registerApplication方法,而该方法是从single-spa中导入的。

关于微应用加载函数的返回值 上面代码片段中注释关键点2处指示的微应用加载函数,对应了流程图中的第5步,最核心的逻辑是代码注释关键点4所指示的loadApp方法。上文流程图中的第5步,对应上文代码片段中的关键点3。当关键点2处的app方法执行,返回了关键点3处的对象,该对象包括了mount、name、bootstrap、unmount等属性,这些属性其实就是single-spa注册微应用时候所需要的内容,因为single-spa中所谓的注册微应用,本质上就是获取微应用暴露的相关生命周期方法,在后续程序运转过程中,通过控制这些生命周期方法,进而实现对微应用的控制。

接下来,我们就把目光投向loadApp内部中去,微应用的加载,核心逻辑都在这里

loadApp的内部实现

loadApp的主体流程 请先简单看一下流程:

  1. 从参数中获取微应用的entry和name
  2. 根据名称生成微应用的实例id
  3. 根据entry和传入的参数获取微应用的资源,资源获取:template(html css相关),execScript(js 相关),assetPublicPath(页面内远程资源访问相对路径)
  4. template 外包裹一个div 并在div 上设置一些与实例id相关的属性,作为微应用的根元素,称之为appContent,此时appContent仍然是一个字符串。
  5. 将appContent 处理成HTML元素,称之为initialAppWrapperElement
  6. 将initialAppWrapperElement 挂载到节点 (注册微应用时候对微应用设置的挂载容器节点)
  7. 构造生命周期方法数组 beforeUnmount,afterUnmount,afterMount,beforeMount,beforeLoad
  8. 依次执行beforeLoad数组中的所有方法
  9. 执行步骤03中获取的execScript函数,并获取其中暴露出来的方法 boostrap,mount,unmount等重要属性
  10. 声明函数parcelConfigGetter,该函数会返回一个对象,对象中包括 name,boostrap,mount,unmount 等重要属性
  11. 返回函数parcelConfigGetter,此时loadApp方法执行完成。

loadApp内部逻辑比较复杂,在忽略一些细节的情况下,大家要明白loadApp的核心功能:那就是获取微应用的js/css/html等资源,并对这些资源进行加工,随后会构造和执行一生命周期中需要执行的方法,最终返回一个函数,而这个函数的返回值是一个对象,该对象包括了微应用的生命周期方法。有了这个最基本的认识,我们就可以进行下面的详细解读了。

loadApp中值得关注的细节 关于获取微应用资源的方法 关于微应用资源的获取,对应流程中的第3步,具体功能实现依赖了库import-html-entry中的importEntry函数,这个函数解决了两个问题,一是如何把资源获取到本地,二是如何将这些进行恰当处理以满足实际需要。调用该函数的对应代码如下:

  // 代码片段二,对应文件:src/loader.ts
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
  /**
   * 先对这几个变量有个简单的了解,后续在合适的地方会详细介绍
   * template: 一个字符串,内部包含了html、css资源
   * execScripts:一个函数,执行该函数后会返回一个对象
   * assetPublicPath:访问页面远程资源的相对路径
   * /

将获取到的template(涉及html/css)转化成DOM节点 代码片段二中我们提到,template是一个字符串,为什么是一个字符串呢,其实很简单,资源以字节流的形式从网络上到达本地后只能转化成字符串进行处理。我们这里需要把字符串转化成具体可用的Dom节点。那怎么转化?具体代码涉及两部分:

// 代码片段三,对应文件:src/loader.ts
const appContent = getDefaultTplWrapper(appInstanceId)(template);
let initialAppWrapperElement: HTMLElement | null = createElement(
    appContent,
    strictStyleIsolation,
    scopedCSS,
    appInstanceId,
  );


// 代码片段四,对应文件:src/utils.ts
export function getDefaultTplWrapper(name: string) {
  return (tpl: string) => `<div id="${getWrapperId(name)}" data-name="${name}" data-version="${version}">${tpl}</div>`;
}
```js
代码片段三中的appContent对应流程中第4步提到的appContent。代码片段中三中的initialAppWrapperElement就是流程图中第5步提到的DOM元素initialAppWrapperElement。从代码中可以看出,函数getDefaultTplWrapper中对获取到的template外层包裹一个div,在该div上设置id、data-name、data-version等属性。
为什么要包裹这样一个标签呢?
有两个好处,第一是能够保证template转化为DOM节点后的根节点只有一个,这样将来对微应用挂载、卸载等操作的时候能够保证准确性;
第二是在该标签上设置具有标识性的属性,可以避免与微应用原有的根元素上的属性冲突。

接下来,我们如何将字符串appContent转化成DOM节点initialAppWrapperElement呢,这有赖于片段三中的所示的createElement方法,该方法代码如下:

// 代码片段四,所属文件:src/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; // 省略了其他代码... return appElement; }


代码片段四中的关键是,先创建一个空div,名为containerElement,然后将其内容设置为上文提到的appContent,再获取containerElement的第一个子元素,作为将要返回的DOM元素,当然还需要对这个DOM元素进行一些处理,这里省略了相关代码。这样做有什么作用呢,看见上面的那行注释appContent always wrapped with a singular div。其实就是如果appContent有多个根节点,那么这里只会获取和应用第一个节点。如果在日常代码编写过程中有相同的场景,我认为可以直接复用这三行代码。

### css资源的处理和隔离方法

代码片段四中省略了下面这几行代码。这几行代码的作用是对appElement中的style进行处理

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

关于shadow dom的陷阱
其实在代码片段四中还省略了下面几行代码:

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

这几行代码的主要功能,就是如果是严格的样式隔离,那么就判断当前环境是否支持shadow dom,在支持shadow dom的情况下,则将元素绑定到shadow dom上。shadow dom虽然可以做到很好的隔离,但是有个问题需要大家关注。那就是元素在shadow dom中是自治的,外界无法影响。但如果该元素挂载到了shadow dom外部,则无法正常运行。比如React中的很多弹框,都是直接挂载到body上的,那这种情况下就要采取措施进行规避。乾坤在关于start方法到api文档中提到了下面内容:

基于 ShadowDOM 的严格样式隔离并不是一个可以无脑使用的方案,大部分情况下都需要接入应用做一些适配后才能正常在 ShadowDOM 中运行起来

关于函数initialAppWrapperGetter 该函数存在于,流程图的第6步和第7步之间。朋友们有没有觉得奇怪,我们上文已经得到了微应用的DOM元素initialAppWrapperElement,为什么又出现一个函数来获取微应用的DOM元素?

// 代码片段五,所属文件:src/loader.ts
  const initialAppWrapperGetter = getAppWrapperGetter(
    appInstanceId,
    !!legacyRender,
    strictStyleIsolation,
    scopedCSS,
    () => initialAppWrapperElement,
  );
  /** generate app wrapper dom getter */
function getAppWrapperGetter(
  appInstanceId: string,
  useLegacyRender: boolean,
  strictStyleIsolation: boolean,
  scopedCSS: boolean,
  elementGetter: () => HTMLElement | null,
) {
  return () => {
    if (useLegacyRender) {
      // 省略一些代码...
      const appWrapper = document.getElementById(getWrapperId(appInstanceId));
      // 省略一些代码...
      return appWrapper!;
    }
    const element = elementGetter();
      // 省略一些代码
    return element!;
  };
}

从上面的代码片段五中,我们其实可以看到之所以存在这个getAppWrapperGetter方法,是为了兼容过去可以自定义渲染函数的机制,这里我们先不提这个渲染机制,可以简单理解为把一个DOM节点挂载到某个DOM节点上。这也提醒我们,在设计一个系统的时候一定要慎重,否则为了兼容低版本能正常运行,而不得不经常做一些类似的兼容措施。如果乾坤一开始就没有设计这个legacyRender这种机制,那么getAppWrapperGetter也就没有存在的必要,整个系统的程序可读性和易用性都会提升。当然乾坤作为一个优秀的微前端框架,也是逐步在发展进化,兼容低版本的行为难以避免。

沙箱机制的应用

// 代码片段六,所属文件:src/loader.ts
let global = globalContext;
  let mountSandbox = () => Promise.resolve();
  let unmountSandbox = () => Promise.resolve();
  const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
  let sandboxContainer;
  if (sandbox) {
    sandboxContainer = createSandboxContainer(
      appInstanceId,
      // FIXME should use a strict sandbox logic while remount, see https://github.com/umijs/qiankun/issues/518
      initialAppWrapperGetter,
      scopedCSS,
      useLooseSandbox,
      excludeAssetFilter,
      global,
    );
    global = sandboxContainer.instance.proxy as typeof window;
    mountSandbox = sandboxContainer.mount;
    unmountSandbox = sandboxContainer.unmount;
  }

这部分代码在流程图中第6步和第7步之间。我认为里面最核心的那行代码是global = sandboxContainer.instance.proxy as typeof window;,因为后续该微应用中进行的操作,都是这个沙箱容器中的沙箱代理对象在发挥作用。

一些生命周期中需要执行的函数

// 代码片段七,所属文件:src/loader.ts
const {
  beforeUnmount = [],
  afterUnmount = [],
  afterMount = [],
  beforeMount = [],
  beforeLoad = [],
} = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));

代码片段七,对应流程中的第7步,这些数组对象beforeUnmount、afterUnmount、afterMount、beforeMount、beforeLoad中会保存很多函数,这些函数会放到某些合适的时机去执行。那什么是合适的时机呢?上文我们提到过,微应用会暴露生命周期方法,single-spa会通过调用这些生命周期方法来控制微应用的状态。代码片段七中的这些方法,就会放到生命周期方法中去

关于数组的reduce方法的妙用:execHooksChain 代码片段七执行完后,紧接着有一行代码:

await execHooksChain(toArray(beforeLoad), app, global);
我们先不关心beforeLoad中具体有哪些方法,具体有哪些方法由代码片段七决定。我们现在只看execHooksChain这个函数:

// 代码片段八,所属文件:src/loader.ts
function execHooksChain<T extends ObjectType>(
  hooks: Array<LifeCycleFn<T>>,
  app: LoadableApp<T>,
  global = window,
): Promise<any> {
  if (hooks.length) {
    return hooks.reduce((chain, hook) => chain.then(() => hook(app, global)), Promise.resolve());
  }

  return Promise.resolve();
}

这里巧妙的利用了数组的reduce函数,试想如果不这样写应该怎么做实现相同的功能呢,我想应该是这样:

// 代码片段九
for(let i = 0; i < hooks.length; i++){
  await hooks[i](app, global);
}

微应用加载完成后的返回值 微应用加载流程执行完成返回的是一个函数,如代码所示:

  const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
    // 省略相关代码
    const parcelConfig: ParcelConfigObject = {
      // 省略相关代码
    }
    return parcelConfig;
  }

我们可能第一反应是,既然加载完成了,为什么不直接返回相关内容,反而返回一个函数呢?其实答案就在这个函数的参数remountContainer里面,因为这个返回的对象实际上就是single-spa需要的微应用暴露的的生命周期函数。我们知道微应用的生命周期方法中有mount,我们的微应用最终要挂载到某个地方去,正常情况下就是用户注册微应用时候传入的container参数。但是如果注册完成后,微应用需要挂载到别的地方去怎么办呢,因此这里返回值就是一个函数,而非直接返回对象。

parcelConfigGetter的返回对象

const parcelConfig: ParcelConfigObject = {
      name: appInstanceId,
      bootstrap,
      mount: [
        async () => {
          if (process.env.NODE_ENV === 'development') {
            const marks = performanceGetEntriesByName(markName, 'mark');
            // mark length is zero means the app is remounting
            if (marks && !marks.length) {
              performanceMark(markName);
            }
          }
        },
        async () => {
          if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
            return prevAppUnmountedDeferred.promise;
          }

          return undefined;
        },
        // initial wrapper element before app mount/remount
        async () => {
          appWrapperElement = initialAppWrapperElement;
          appWrapperGetter = getAppWrapperGetter(
            appInstanceId,
            !!legacyRender,
            strictStyleIsolation,
            scopedCSS,
            () => appWrapperElement,
          );
        },
        // 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕
        async () => {
          const useNewContainer = remountContainer !== initialContainer;
          if (useNewContainer || !appWrapperElement) {
            // element will be destroyed after unmounted, we need to recreate it if it not exist
            // or we try to remount into a new container
            appWrapperElement = createElement(appContent, strictStyleIsolation, scopedCSS, appInstanceId);
            syncAppWrapperElement2Sandbox(appWrapperElement);
          }

          render({ element: appWrapperElement, loading: true, container: remountContainer }, 'mounting');
        },
        mountSandbox,
        // exec the chain after rendering to keep the behavior with beforeLoad
        async () => execHooksChain(toArray(beforeMount), app, global),
        async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
        // finish loading after app mounted
        async () => render({ element: appWrapperElement, loading: false, container: remountContainer }, 'mounted'),
        async () => execHooksChain(toArray(afterMount), app, global),
        // initialize the unmount defer after app mounted and resolve the defer after it unmounted
        async () => {
          if (await validateSingularMode(singular, app)) {
            prevAppUnmountedDeferred = new Deferred<void>();
          }
        },
        async () => {
          if (process.env.NODE_ENV === 'development') {
            const measureName = `[qiankun] App ${appInstanceId} Loading Consuming`;
            performanceMeasure(measureName, markName);
          }
        },
      ],
      unmount: [
        async () => execHooksChain(toArray(beforeUnmount), app, global),
        async (props) => unmount({ ...props, container: appWrapperGetter() }),
        unmountSandbox,
        async () => execHooksChain(toArray(afterUnmount), app, global),
        async () => {
          render({ element: null, loading: false, container: remountContainer }, 'unmounted');
          offGlobalStateChange(appInstanceId);
          // for gc
          appWrapperElement = null;
          syncAppWrapperElement2Sandbox(appWrapperElement);
        },
        async () => {
          if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
            prevAppUnmountedDeferred.resolve();
          }
        },
      ],
    };

会发现这个返回的对象有很多内容,但是我们可以从宏观的视角来看,该对象只有4个属性,name、bootstrap、mount、unmount,没错这正是single-spa需要微应用暴露的生命周期函数。后续就是通过执行对应生命周期函数而控制微应用。因为这里相当于微应用加载的最终结果,汇聚了大量其他逻辑产生了这样一个结果对象。我不打算立即对这些函数进行逐一解析,因为内容比较零碎,如果逐一讲解不利于大家理解。所以后续文章会先逐个介绍本文尚未详细介绍的部分,在比较全面的了解乾坤后,我们会深入到single-spa,那时候会用到这些方法,我们再找合适的机会来详细讲解这里的众多方法。

关于Promise的妙用:Deferred 此时loadApp已经执行完成,返回了一个函数parcelConfigGetter,我们把视野移动到调用loadApp的地方,也就是本文的代码片段一。但代码片段一省略了我现在要讲的代码,请看这里:

registerApplication({
      name,
      app: async () => {
        // 省略代码...
        await frameworkStartedDefer.promise;
        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
        )();
        // 省略代码....
      },
有没有觉得有行代码很奇怪,那就是await frameworkStartedDefer.promise;,其实这行代码是与下面的代码片段配合使用的:

// 所属文件:src/apis.ts
export function start(opts: FrameworkConfiguration = {}) {
  // 省略了其他代码...
  frameworkStartedDefer.resolve();
}
那这个frameworkStartedDefer到底是什么呢?

// 所属文件:src/apis.ts
const frameworkStartedDefer = new Deferred<void>();
// 所属文件:src/utils.ts
export class Deferred<T> {
  promise: Promise<T>;
  resolve!: (value: T | PromiseLike<T>) => void;
  reject!: (reason?: any) => void;
  constructor() {
    this.promise = new Promise((resolve, reject) => {
      this.resolve = resolve;
      this.reject = reject;
    });
  }
}

通过控制一个Promise的resolve和reject方法,来控制分属于两个不同方法中代码的执行顺序,很巧妙。在日常开发中如果有类似场景,可以借鉴。

小结 本文介绍了乾坤微应用的注册流程,并由微应用的注册流程,引出了微应用的加载流程,我们对微应用的加载流程中的一些关键的环节进行了剖析

Edit this page
最近更新: 2025/12/2 01:46
Contributors: qdleader
qdleader
本站总访问量 129823次 | 本站访客数 12人