乾坤(qiankun)

简介

乾坤是基于single-spa实现的微前端库,用来构建易拆分和解耦,可以独立开发和部署的前端个体应用架构系统。微前端具有如下特点:

1、与技术无关,不限制前端开发语言框架,只需要按照主框架方式接入即可。这样在中大型的业务开发中,可以把不同的相对独立的业务模块交给使用不同前端开发语言的团队去并行开发,降低了统一技术栈的技术门槛。

2、微应用支持独立开发和部署,具有相对独立的生命周期。传统项目开发中,如果项目耦合或者复杂度较大,通常一个业务模块和功能的上线会牵涉到其他模块的上线和发布。而使用微应用,模块可以解耦出来独立开发部署,减少了开发耦合带来的测试和部署成本。

3、独立运行时状态。微应用具有自己独立的状态和运行时,互相之间不会干扰。

4、功能升级和更新更加敏捷。对于技术框架的升级和业务的更新,传统框架会牵一发而动全身,但是通过微前端可以灵活并且渐进的升级功能和技术架构,尤其方便业务复杂场景。

总而言之,微前端相对于传统大而全的前端系统来说,实现了功能、业务的细颗粒物划分和解耦,单个微应用具有了独立灵敏的开发节奏,比较适合复杂业务场景。微前端架构旨在解决单体应用在一个相对长的时间跨度下,由于参与的人员、团队的增多、变迁,从一个普通应用演变成一个巨石应用后,随之而来的应用不可维护的问题。这类问题在企业级 Web 应用中尤其常见。

为什么不是iframe

为什么不用 iframe,这几乎是所有微前端方案第一个会被 challenge 的问题。但是大部分微前端方案又不约而同放弃了 iframe 方案,自然是有原因的,并不是为了 "炫技" 或者刻意追求 "特立独行"。

如果不考虑体验问题,iframe 几乎是最完美的微前端解决方案了。

iframe 最大的特性就是提供了浏览器原生的硬隔离方案,不论是样式隔离、js 隔离这类问题统统都能被完美解决。但他的最大问题也在于他的隔离性无法被突破,导致应用间上下文无法被共享,随之带来的开发体验、产品体验的问题。

  1. url 不同步。浏览器刷新 iframe url 状态丢失、后退前进按钮无法使用。
  2. UI 不同步,DOM 结构不共享。想象一下屏幕右下角 1/4 的 iframe 里来一个带遮罩层的弹框,同时我们要求这个弹框要浏览器居中显示,还要浏览器 resize 时自动居中..
  3. 全局上下文完全隔离,内存变量不共享。iframe 内外系统的通信、数据同步等需求,主应用的 cookie 要透传到根域名都不同的子应用中实现免登效果。
  4. 慢。每次子应用进入都是一次浏览器上下文重建、资源重新加载的过程。

其中有的问题比较好解决(问题1),有的问题我们可以睁一只眼闭一只眼(问题4),但有的问题我们则很难解决(问题3)甚至无法解决(问题2),而这些无法解决的问题恰恰又会给产品带来非常严重的体验问题, 最终导致我们舍弃了 iframe 方案。

要求

微应用分为有 webpack 构建和无 webpack 构建项目,有 webpack 的微应用(主要是指 Vue、React、Angular)需要做的事情有:

  1. 新增 public-path.js 文件,用于修改运行时的 publicPath

注意:运行时的 publicPath 和构建时的 publicPath 是不同的,两者不能等价替代。

  1. 微应用建议使用 history 模式的路由,需要设置路由 base,值和它的 activeRule 是一样的。
  2. 在入口文件最顶部引入 public-path.js,修改并导出三个生命周期函数。(bootstrap、mount、unmount)
  3. 修改 webpack 打包,允许开发环境跨域和 umd 打包。

主要的修改就是以上四个,可能会根据项目的不同情况而改变。例如,你的项目是 index.html 和其他的所有文件分开部署的,说明你们已经将构建时的 publicPath 设置为了完整路径,则不用修改运行时的 publicPath (第一步操作可省)。

webpack 构建的微应用直接将 lifecycles 挂载到 window 上即可。

常用API

主应用配置:

  • 注册微应用的基础配置信息。当浏览器 url 发生变化时,会自动检查每一个微应用注册的 activeRule 规则,符合规则的应用将会被自动激活。registerMicroApps(apps, lifeCycles?)
registerMicroApps(
  [
    {
      name: 'app1',
      entry: '//localhost:8080',
      container: '#container',
      activeRule: '/react',
      props: {
        name: 'kuitos',
      },
    },
  ],
  {
    beforeLoad: (app) => console.log('before load', app.name),
    beforeMount: [(app) => console.log('before mount', app.name)],
  },
);
  • 启动 qiankun:start(opts?)

  • 设置主应用启动后默认进入的微应用:setDefaultMountApp(appLink)

  • 第一个微应用 mount 后需要调用的方法,比如开启一些监控或者埋点脚本:runAfterFirstMounted(effect)

手动加载微应用:

  • 手动加载一个微应用,如果需要能支持主应用手动 update 微应用,需要微应用 entry 再多导出一个 update 钩子。loadMicroApp(app, configuration?)
  • 手动预加载指定的微应用静态资源。仅手动加载微应用场景需要,基于路由自动激活场景直接配置 prefetch 属性即可。prefetchApps(apps, importEntryOpts?)

全局钩子:

  • singleSpa.addErrorHandler(handleErr); singleSpa.removeErrorHandler(handleErr);

  • 添加全局的未捕获异常处理器:addGlobalUncaughtErrorHandler(handler)

  • 移除全局的未捕获异常处理器:removeGlobalUncaughtErrorHandler(handler)

  • 定义全局状态,并返回通信方法,建议在主应用使用,微应用通过 props 获取通信方法:initGlobalState(state)

源码简单解析:

入口文件index.ts

暴露上面提到的API,其他如setDefaultMountApp来自effects.ts等,最终汇总到index.ts导出。

export { loadMicroApp, registerMicroApps, start } from './apis';
export { initGlobalState } from './globalState';
export { getCurrentRunningApp as __internalGetCurrentRunningApp } from './sandbox';
export * from './errorHandler';
export * from './effects';
export * from './interfaces';
export { prefetchImmediately as prefetchApps } from './prefetch';

注册子应用

首先从最核心的api registerMicroApps看,为了避免app多次注册,会首先通过microApps和当前注册的apps将重复注册的app给过滤掉,剩下的就是最新的增量注册app(也就是未注册的apps),然后将所有的apps结果保存到microApps中,并把本次未注册的apps每个都调用registerApplication注册一下。注意:registerApplication函数来自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,
    });
  });
}

single-spa在新窗口打开注册应用可以看到,registerApplication有如下参数:

singleSpa.registerApplication({
  name: 'myApp',
  app: () => import('src/myApp/main.js'),
  activeWhen: ['/myApp', (location) => location.pathname.startsWith('/some/other/path')],
  customProps: {
    some: 'value',
  },
});

-  name
  必须是字符串。
- app
应用的定义,它可以是一个单spa生命周期的对象,加载函数或者与第二个参数相同。
- activeWhen
可以是激活函数,比如参数API、路径前缀或两者的数组。因为最常见的用例是使用`window.location` 将其URL前缀进行匹配,所以我们帮你实现了这个方法。
- customProps
可选的 customProps 属性提供传递给应用程序的 single-spa 生命周期函数的自定义props。自定义props可以是对象或返回对象的函数。使用应用程序名称和当前 window.location 作为参数调用自定义 prop 函数。

qiankun中第2个参数app表示应用的定义,最终会return一个包含生命周期的对象。其中loader(true);,表示设置loading为true,此时应用会显示加载中的状态。

  • loader - (loading: boolean) => void - 可选,loading 状态发生变化时会调用的方法。

await frameworkStartedDefer.promise;开启了一个延迟promise,会等待在之后的start()函数调用中调用frameworkStartedDefer.resolve()来结束。

之后通过调用loadApp的立即执行函数,返回registerApplication.app需要的生命周期对象。loadApp()函数来自loader.ts文件,最后return的是一个Promise<ParcelConfigObjectGetter>,最终起始返回的是如下:

export type ParcelConfigObjectGetter = (remountContainer?: string | HTMLElement) => ParcelConfigObject;

type ParcelConfigObject<ExtraProps = CustomProps> = {
    name?: string;
  } & LifeCycles<ExtraProps>;
  
export type LifeCycles<ExtraProps = {}> = {
    bootstrap: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>>;
    mount: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>>;
    unmount: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>>;
    update?: LifeCycleFn<ExtraProps> | Array<LifeCycleFn<ExtraProps>>;
  };

也就是

{
	name: ...,
	bootstrap:...,
	mount:...,
	unmount:...,
	update:...
}

最终return的时候,可以看到mount字段返回的是一个数组,先设置loader(true)显示全局loading,然后开始执行mount中的各个触发函数,最后把loading关掉。

上面函数调用了await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)函数,现在在loader.ts中详细看一下它的实现。

loadApp代码如下:

export async function loadApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {
  const { entry, name: appName } = app;
  const appInstanceId = genAppInstanceIdByName(appName);

  const markName = `[qiankun] App ${appInstanceId} Loading`;
  if (process.env.NODE_ENV === 'development') {
    performanceMark(markName);
  }

  const {
    singular = false,
    sandbox = true,
    excludeAssetFilter,
    globalContext = window,
    ...importEntryOpts
  } = configuration;

  // get the entry html content and script executor
  const { template, execScripts, assetPublicPath, getExternalScripts } = await importEntry(entry, importEntryOpts);
  // trigger external scripts loading to make sure all assets are ready before execScripts calling
  await getExternalScripts();

  // as single-spa load and bootstrap new app parallel with other apps unmounting
  // (see https://github.com/CanopyTax/single-spa/blob/master/src/navigation/reroute.js#L74)
  // we need wait to load the app until all apps are finishing unmount in singular mode
  if (await validateSingularMode(singular, app)) {
    await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
  }

  const appContent = getDefaultTplWrapper(appInstanceId, sandbox)(template);

  const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;

  if (process.env.NODE_ENV === 'development' && strictStyleIsolation) {
    console.warn(
      "[qiankun] strictStyleIsolation configuration will be removed in 3.0, pls don't depend on it or use experimentalStyleIsolation instead!",
    );
  }

  const scopedCSS = isEnableScopedCSS(sandbox);
  let initialAppWrapperElement: HTMLElement | null = createElement(
    appContent,
    strictStyleIsolation,
    scopedCSS,
    appInstanceId,
  );

  const initialContainer = 'container' in app ? app.container : undefined;
  const legacyRender = 'render' in app ? app.render : undefined;

  const render = getRender(appInstanceId, appContent, legacyRender);

  // 第一次加载设置应用可见区域 dom 结构
  // 确保每次应用加载前容器 dom 结构已经设置完毕
  render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');

  const initialAppWrapperGetter = getAppWrapperGetter(
    appInstanceId,
    !!legacyRender,
    strictStyleIsolation,
    scopedCSS,
    () => initialAppWrapperElement,
  );

  let global = globalContext;
  let mountSandbox = () => Promise.resolve();
  let unmountSandbox = () => Promise.resolve();
  const useLooseSandbox = typeof sandbox === 'object' && !!sandbox.loose;
  // enable speedy mode by default
  const speedySandbox = typeof sandbox === 'object' ? sandbox.speedy !== false : true;
  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,
      speedySandbox,
    );
    // 用沙箱的代理对象作为接下来使用的全局对象
    global = sandboxContainer.instance.proxy as typeof window;
    mountSandbox = sandboxContainer.mount;
    unmountSandbox = sandboxContainer.unmount;
  }

  const {
    beforeUnmount = [],
    afterUnmount = [],
    afterMount = [],
    beforeMount = [],
    beforeLoad = [],
  } = mergeWith({}, getAddOns(global, assetPublicPath), lifeCycles, (v1, v2) => concat(v1 ?? [], v2 ?? []));

  await execHooksChain(toArray(beforeLoad), app, global);

  // get the lifecycle hooks from module exports
  const scriptExports: any = await execScripts(global, sandbox && !useLooseSandbox, {
    scopedGlobalVariables: speedySandbox ? cachedGlobals : [],
  });
  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(
    scriptExports,
    appName,
    global,
    sandboxContainer?.instance?.latestSetProp,
  );

  const { onGlobalStateChange, setGlobalState, offGlobalStateChange }: Record<string, CallableFunction> =
    getMicroAppStateActions(appInstanceId);

  // FIXME temporary way
  const syncAppWrapperElement2Sandbox = (element: HTMLElement | null) => (initialAppWrapperElement = element);

  const parcelConfigGetter: ParcelConfigObjectGetter = (remountContainer = initialContainer) => {
    let appWrapperElement: HTMLElement | null;
    let appWrapperGetter: ReturnType<typeof getAppWrapperGetter>;

    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();
          }
        },
      ],
    };

    if (typeof update === 'function') {
      parcelConfig.update = update;
    }

    return parcelConfig;
  };

  return parcelConfigGetter;
}

首先听过genAppInstanceIdByName函数生成了一个当前要注册的app的实例id,起始就是一个appName的字符串,如果有多个相同名称的name,会返回${appName}_${globalAppInstanceMap[appName]}的一个id,也就是appName后面跟上一个计数器数字。

之后,通过app参数中配置的entry和configuration中配置的importEntryOpts,调用await importEntry(entry, importEntryOpts)方法获取要加在的entry文件的模板字符串。这里的importEntry就是来自这个核心的第三方库import-html-entry

import-html-entry这个库的作用起始也很简单,就是根据给得地址,通过fetch方法获取入口文件字符串模板,经过一些解析处理后,将template、scripts、styles等解析出来,然后返回回来。具体可以看:https://github.com/kuitos/import-html-entry/blob/master/src/index.js#L299,

要注意的是,qiankun的entry配置如下:

entry - string | { scripts?: string[]; styles?: string[]; html?: string } - 必选,微应用的入口。

配置为字符串时,表示微应用的访问地址,例如 https://qiankun.umijs.org/guide/。
配置为对象时,html 的值是微应用的 html 内容字符串,而不是微应用的访问地址。微应用的 publicPath 将会被设置为 /。

entry既可以是string,也可是对象,这个参数就是在import-html-entry中使用的,使用如下:

export function importEntry(entry, opts = {}) {
	const { fetch = defaultFetch, getTemplate = defaultGetTemplate, postProcessTemplate } = opts;
	const getPublicPath = opts.getPublicPath || opts.getDomain || defaultGetPublicPath;

	if (!entry) {
		throw new SyntaxError('entry should not be empty!');
	}

	// html entry
	if (typeof entry === 'string') {
		return importHTML(entry, {
			fetch,
			getPublicPath,
			getTemplate,
			postProcessTemplate,
		});
	}

	// config entry
	if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) {

		const { scripts = [], styles = [], html = '' } = entry;
		const getHTMLWithStylePlaceholder = tpl => styles.reduceRight((html, styleSrc) => `${genLinkReplaceSymbol(styleSrc)}${html}`, tpl);
		const getHTMLWithScriptPlaceholder = tpl => scripts.reduce((html, scriptSrc) => `${html}${genScriptReplaceSymbol(scriptSrc)}`, tpl);

		return getEmbedHTML(getTemplate(getHTMLWithScriptPlaceholder(getHTMLWithStylePlaceholder(html))), styles, { fetch }).then(embedHTML => ({
			template: embedHTML,
			assetPublicPath: getPublicPath(entry),
			getExternalScripts: () => getExternalScripts(scripts, fetch),
			getExternalStyleSheets: () => getExternalStyleSheets(styles, fetch),
			execScripts: (proxy, strictGlobal, opts = {}) => {
				if (!scripts.length) {
					return Promise.resolve();
				}
				return execScripts(scripts[scripts.length - 1], scripts, proxy, {
					fetch,
					strictGlobal,
					...opts,
				});
			},
		}));

	} else {
		throw new SyntaxError('entry scripts or styles should be array!');
	}
}

字符串的话直接调用importHTML,如果是对象的话,将scripts和styles拼到html上再去解析。

通过调用importEntry方法, 收到返回的入口html文件字符模板,以及需要执行的脚本。

然后调用getExternalScripts(),触发外部脚本加载以确保所有资产都准备就绪。

接下来会判断如果是单节点模式singular=true,会等待其他app都卸载后继续执行。

之后,会掉用getDefaultTplWrapper方法,并从返回值函数中传入html的字符串模板,将页面渲染到如下节点中:

也就是将模板文件用一个div 包裹。

之后,会使用到samdbox参数。在回顾一下sandbox参数的可选参数

sandbox - boolean | { strictStyleIsolation?: boolean, experimentalStyleIsolation?: boolean } - 可选,是否开启沙箱,默认为 true

默认情况下沙箱可以确保单实例场景子应用之间的样式隔离,但是无法确保主应用跟子应用、或者多实例场景的子应用样式隔离。当配置为 { strictStyleIsolation: true } 时表示开启严格的样式隔离模式。这种模式下 qiankun 会为每个微应用的容器包裹上一个 shadow dom在新窗口打开 节点,从而确保微应用的样式不会对全局造成影响。

除此以外,qiankun 还提供了一个实验性的样式隔离特性,当 experimentalStyleIsolation 被设置为 true 时,qiankun 会改写子应用所添加的样式为所有样式规则增加一个特殊的选择器规则来限定其影响范围,因此改写后的代码会表达类似为如下结构:

// 假设应用名是 react16
.app-main {
  font-size: 14px;
}

div[data-qiankun-react16] .app-main {
  font-size: 14px;
}

通过html模板、strictStyleIsolation配置来调用createElement函数,生成初始化之后的app html元素。

其中,createElement逻辑如下:

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

逻辑也很简单,就是如果使用strictStyleIsolation开启了严格的样式隔离模式,会将原本的html内容外面包裹一个shadow dom。

shadow dom的原理可以参考使用 shadow DOM在新窗口打开

之后,通过包裹完成的appContent和appInstanceId创建渲染器。

function getRender(appInstanceId: string, appContent: string, legacyRender?: HTMLContentRender) {
  const render: ElementRender = ({ element, loading, container }, phase) => {
    if (legacyRender) {
      if (process.env.NODE_ENV === 'development') {
        console.error(
          '[qiankun] Custom rendering function is deprecated and will be removed in 3.0, you can use the container element setting instead!',
        );
      }

      return legacyRender({ loading, appContent: element ? appContent : '' });
    }

    const containerElement = getContainer(container!);

    // The container might have be removed after micro app unmounted.
    // Such as the micro app unmount lifecycle called by a react componentWillUnmount lifecycle, after micro app unmounted, the react component might also be removed
    if (phase !== 'unmounted') {
      const errorMsg = (() => {
        switch (phase) {
          case 'loading':
          case 'mounting':
            return `Target container with ${container} not existed while ${appInstanceId} ${phase}!`;

          case 'mounted':
            return `Target container with ${container} not existed after ${appInstanceId} ${phase}!`;

          default:
            return `Target container with ${container} not existed while ${appInstanceId} rendering!`;
        }
      })();
      assertElementExist(containerElement, errorMsg);
    }

    if (containerElement && !containerElement.contains(element)) {
      // clear the container
      while (containerElement!.firstChild) {
        rawRemoveChild.call(containerElement, containerElement!.firstChild);
      }

      // append the element to container if it exist
      if (element) {
        rawAppendChild.call(containerElement, element);
      }
    }

    return undefined;
  };

  return render;
}

此外有一个legacyRender参数,在loadApp函数中也能看到,如果提供了render函数,就是用提供的render,否则使用qiankun提供的render函数。

这个render函数的作用就是把提供的container下面所有的子节点清除,并把需要渲染的element放到container下面。这个element就是上面包裹完成的微应用的页面内容元素。

之后调用getAppWrapperGetter获取appContent的包裹节点,也就是上面getDefaultTplWrapper中返回的最外层用div包裹的元素。

getAppWrapperGetter内部根据不同条件返回不同的wrapper元素。

之后就是创建沙箱的环节,调用createSandboxContainer函数。来自src/sandbox/index.ts文件夹。

沙箱

http://zoo.zhengcaiyun.cn/blog/article/qiankun

我们在使用微前端框架的时候,经常听到 js 沙箱这个词,那究竟什么是 js 沙箱,js 沙箱又是来做什么的。

在计算机安全中,沙箱(Sandbox)是一种用于隔离正在运行程序的安全机制,通常用于执行未经测试或不受信任的程序或代码,它会为待执行的程序创建一个独立的执行环境,内部程序的执行不会影响到外部程序的运行。而js沙箱也是来源与这个概念。

在前端中最直观的副作用/危害就是污染、篡改全局 window 状态。首先我们先来看一个场景,我们在A微应用中定义了一个全局变量 city,有很多业务是基于 city 变量展开的。但是突然有一天微应用B也因为业务需求定义了一个全局变量 city,这时候在A,B微应用互相切换的时候,会导致基于 city 的代码逻辑互相影响。这时我们首先想到的是在定义的时候可以互相沟通一下避免这种重复的情况,或者每个微应用定义全局变量时可以加一个自己独有的前缀。但是在微应用数量增多或者团队人员增多的时候,这个问题就会越发凸显,因为前面的提出的解决方案严重依赖沟通和对编码规则的彻底执行,这样就总会出现遗漏的状况。这时我们就要产出一种方案,达到即使两个微应用定义了相同的全局变量也不会互相影响的效果,其中一种解决方案就是 js 沙箱隔离。

那 js 沙箱的原理是什么,又是如何来解决上面的问题的。其实原理很简单,就是在不同的微应用中记录在当前微应用中定义以及改变了哪些全局变量,并且在切换微应用的时候恢复和删除之前的修改,这样就可以做到互不影响了。

目前在乾坤的代码中一共有三种沙箱实现方案。这三种方案也是随着技术的成熟和微前端的逐步发展而不断进化出来的,我们可以在这三种方案的实现源码中体会出微前端的发展历程。

SnapshotSandbox 沙箱快照方案

sanpshot沙箱是基于 diff 方式实现的沙箱,用于不支持 Proxy 的低版本浏览器

在乾坤中,所有子应用都有加载(active)和卸载(inactive)两个周期函数。

SnapshotSandbox.jpg

active(加载函数)

  1. 循环 window,把子应用加载前的 window 进行复制暂存,用于卸载时恢复初始 window。
  2. 恢复之前的变更。上次子应用运行时改变的 window 变量会再存下来,再次加载时会恢复之前的 window 变更。
  3. 修改 sandboxRunning 标识,标识子应用运行中。
active() {
    // 记录当前快照
    this.windowSnapshot = {} as Window;
    iter(window, (prop) => {
      this.windowSnapshot[prop] = window[prop];
    });

    // 恢复之前的变更
    Object.keys(this.modifyPropsMap).forEach((p: any) => {
      window[p] = this.modifyPropsMap[p];
    });

    this.sandboxRunning = true;
  }

inactive(卸载函数)

  1. 循环 window 与之前的暂存 window 做对比,记录变更。
  2. 恢复子应用加载前的 window 状态。
  3. 修改 sandboxRunning 标识,标识子应用已卸载。
 inactive() {
    this.modifyPropsMap = {};

    iter(window, (prop) => {
      if (window[prop] !== this.windowSnapshot[prop]) {
        // 记录变更,恢复环境
        this.modifyPropsMap[prop] = window[prop];
        window[prop] = this.windowSnapshot[prop];
      }
    });

    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} origin window restore...`, Object.keys(this.modifyPropsMap));
    }

    this.sandboxRunning = false;
  }

总结

优点:实现简单易懂,代码兼容性好。 不足:每次激活,卸载都要遍历 window,性能较差。只能支持加载一个子应用。

legacySandbox 沙箱快照方案

legacySandbox.jpg

constructor

创建变量 fakeWindow(虚拟的 window),并代理 fakeWindow,在每次更改 fakeWindow 时,记录下更改记录,并存放在子应用的内存变量内。

内存变量有:

addedPropsMapInSandbox : 沙箱期间新增的全局变量, 用于卸载子应用时删除此变量 modifiedPropsOriginalValueMapInSandbox :沙箱期间更新的全局变量,用于卸载时删除修改。

currentUpdatedPropsValueMap : 所有的更改记录(新增和修改的),用于下次再加载自用时时恢复 window。

通过这三个变量就是记录先子应用以及原来环境的变化,qiankun 也能以此作为恢复环境的依据。


/**
 * 基于 Proxy 实现的沙箱
 * TODO: 为了兼容性 singular 模式下依旧使用该沙箱,等新沙箱稳定之后再切换
 */
export default class LegacySandbox implements SandBox {
  /** 沙箱期间新增的全局变量 */
  private addedPropsMapInSandbox = new Map<PropertyKey, any>();

  /** 沙箱期间更新的全局变量 */
  private modifiedPropsOriginalValueMapInSandbox = new Map<PropertyKey, any>();

  /** 持续记录更新的(新增和修改的)全局变量的 map,用于在任意时刻做 snapshot */
  private currentUpdatedPropsValueMap = new Map<PropertyKey, any>();

  name: string;

  proxy: WindowProxy;

  globalContext: typeof window;

  type: SandBoxType;

  sandboxRunning = true;

  latestSetProp: PropertyKey | null = null;

  private setWindowProp(prop: PropertyKey, value: any, toDelete?: boolean) {
    if (value === undefined && toDelete) {
      // eslint-disable-next-line no-param-reassign
      delete (this.globalContext as any)[prop];
    } else if (isPropConfigurable(this.globalContext, prop) && typeof prop !== 'symbol') {
      Object.defineProperty(this.globalContext, prop, { writable: true, configurable: true });
      // eslint-disable-next-line no-param-reassign
      (this.globalContext as any)[prop] = value;
    }
  }

  active() {
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
    }

    this.sandboxRunning = true;
  }

  inactive() {
    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [
        ...this.addedPropsMapInSandbox.keys(),
        ...this.modifiedPropsOriginalValueMapInSandbox.keys(),
      ]);
    }

    // renderSandboxSnapshot = snapshot(currentUpdatedPropsValueMapForSnapshot);
    // restore global props to initial snapshot
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));

    this.sandboxRunning = false;
  }

  constructor(name: string, globalContext = window) {
    this.name = name;
    this.globalContext = globalContext;
    this.type = SandBoxType.LegacyProxy;
    const { addedPropsMapInSandbox, modifiedPropsOriginalValueMapInSandbox, currentUpdatedPropsValueMap } = this;

    const rawWindow = globalContext;
    const fakeWindow = Object.create(null) as Window;

    const setTrap = (p: PropertyKey, value: any, originalValue: any, sync2Window = true) => {
      if (this.sandboxRunning) {
        if (!rawWindow.hasOwnProperty(p)) {
          addedPropsMapInSandbox.set(p, value);
        } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
          // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
          modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
        }

        currentUpdatedPropsValueMap.set(p, value);

        if (sync2Window) {
          // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
          (rawWindow as any)[p] = value;
        }

        this.latestSetProp = p;

        return true;
      }

      if (process.env.NODE_ENV === 'development') {
        console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
      }

      // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
      return true;
    };

    const proxy = new Proxy(fakeWindow, {
      set: (_: Window, p: PropertyKey, value: any): boolean => {
        const originalValue = (rawWindow as any)[p];
        return setTrap(p, value, originalValue, true);
      },

      get(_: Window, p: PropertyKey): any {
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // or use window.top to check if an iframe context
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }

        const value = (rawWindow as any)[p];
        return getTargetValue(rawWindow, value);
      },

      // trap in operator
      // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
      has(_: Window, p: string | number | symbol): boolean {
        return p in rawWindow;
      },

      getOwnPropertyDescriptor(_: Window, p: PropertyKey): PropertyDescriptor | undefined {
        const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
        // A property cannot be reported as non-configurable, if it does not exists as an own property of the target object
        if (descriptor && !descriptor.configurable) {
          descriptor.configurable = true;
        }
        return descriptor;
      },

      defineProperty(_: Window, p: string | symbol, attributes: PropertyDescriptor): boolean {
        const originalValue = (rawWindow as any)[p];
        const done = Reflect.defineProperty(rawWindow, p, attributes);
        const value = (rawWindow as any)[p];
        setTrap(p, value, originalValue, false);

        return done;
      },
    });

    this.proxy = proxy;
  }

  patchDocument(): void {}
}

active(加载函数)

  1. 恢复子工程上次运行时修改的全局变量。
  2. 更改标识,标记当前子应用运行中。
   active() {
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => this.setWindowProp(p, v));
    }

    this.sandboxRunning = true;
  }

inactive(卸载函数)

  1. 恢复应用加载前的全局变量。
  2. 删除沙箱本次运行中新增的全局变量。
  3. 更改标识,标记当前子应用已卸载。
inactive() {
    this.modifiedPropsOriginalValueMapInSandbox.forEach((v, p) => this.setWindowProp(p, v));
    this.addedPropsMapInSandbox.forEach((_, p) => this.setWindowProp(p, undefined, true));
    this.sandboxRunning = false;
  }

总结

优点:相比第一种,采用代理的方式修改 window, 不用再遍历 window, 性能得到提升。 不足:兼容性不如第一种,只能支持加载一个子应用。

proxySandbox 沙箱快照方案

proxySandbox.jpg

constructor

与 legacySandbox 方案一样,创建变量 fakeWindow(虚拟的 window ),并代理 fakeWindow。

每个子应用在创建时都会分配一个空的fakeWindow 变量。每当设置全局变量时,都会改变 fakeWindow 的值,同时判断如果 fakeWindows 上没有当前设置的值才会更改 window。取值时,先判断当前的 fakeWindow 里是否有要取的值,如果有,则直接返回,没有在从 window 上获取;


/**
 * 基于 Proxy 实现的沙箱
 */
export default class ProxySandbox implements SandBox {
  /** window 值变更记录 */
  private updatedValueSet = new Set<PropertyKey>();

  name: string;

  type: SandBoxType;

  proxy: WindowProxy;

  sandboxRunning = true;

  private document = document;

  latestSetProp: PropertyKey | null = null;

  active() {
    if (!this.sandboxRunning) activeSandboxCount++;
    this.sandboxRunning = true;
  }

  inactive() {
    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [
        ...this.updatedValueSet.keys(),
      ]);
    }

    if (inTest || --activeSandboxCount === 0) {
      // reset the global value to the prev value
      Object.keys(this.globalWhitelistPrevDescriptor).forEach((p) => {
        const descriptor = this.globalWhitelistPrevDescriptor[p];
        if (descriptor) {
          Object.defineProperty(this.globalContext, p, descriptor);
        } else {
          // @ts-ignore
          delete this.globalContext[p];
        }
      });
    }

    this.sandboxRunning = false;
  }

  // the descriptor of global variables in whitelist before it been modified
  globalWhitelistPrevDescriptor: { [p in (typeof globalVariableWhiteList)[number]]: PropertyDescriptor | undefined } =
    {};
  globalContext: typeof window;

  constructor(name: string, globalContext = window, opts?: { speedy: boolean }) {
    this.name = name;
    this.globalContext = globalContext;
    this.type = SandBoxType.Proxy;
    const { updatedValueSet } = this;
    const { speedy } = opts || {};

    const { fakeWindow, propertiesWithGetter } = createFakeWindow(globalContext, !!speedy);

    const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
    const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || globalContext.hasOwnProperty(key);

    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          this.registerRunningApp(name, proxy);
          // We must keep its description while the property existed in globalContext before
          if (!target.hasOwnProperty(p) && globalContext.hasOwnProperty(p)) {
            const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
            const { writable, configurable, enumerable, set } = descriptor!;
            // only writable property can be overwritten
            // here we ignored accessor descriptor of globalContext as it makes no sense to trigger its logic(which might make sandbox escaping instead)
            // we force to set value by data descriptor
            if (writable || set) {
              Object.defineProperty(target, p, { configurable, enumerable, writable: true, value });
            }
          } else {
            target[p] = value;
          }

          // sync the property to globalContext
          if (typeof p === 'string' && globalVariableWhiteList.indexOf(p) !== -1) {
            this.globalWhitelistPrevDescriptor[p] = Object.getOwnPropertyDescriptor(globalContext, p);
            // @ts-ignore
            globalContext[p] = value;
          }

          updatedValueSet.add(p);

          this.latestSetProp = p;

          return true;
        }

        if (process.env.NODE_ENV === 'development') {
          console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
        }

        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
        return true;
      },

      get: (target: FakeWindow, p: PropertyKey): any => {
        this.registerRunningApp(name, proxy);

        if (p === Symbol.unscopables) return unscopables;
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'window' || p === 'self') {
          return proxy;
        }

        // hijack globalWindow accessing with globalThis keyword
        if (p === 'globalThis' || (inTest && p === mockGlobalThis)) {
          return proxy;
        }

        if (p === 'top' || p === 'parent' || (inTest && (p === mockTop || p === mockSafariTop))) {
          // if your master app in an iframe context, allow these props escape the sandbox
          if (globalContext === globalContext.parent) {
            return proxy;
          }
          return (globalContext as any)[p];
        }

        // proxy.hasOwnProperty would invoke getter firstly, then its value represented as globalContext.hasOwnProperty
        if (p === 'hasOwnProperty') {
          return hasOwnProperty;
        }

        if (p === 'document') {
          return this.document;
        }

        if (p === 'eval') {
          return eval;
        }

        const actualTarget = propertiesWithGetter.has(p) ? globalContext : p in target ? target : globalContext;
        const value = actualTarget[p];

        // frozen value should return directly, see https://github.com/umijs/qiankun/issues/2015
        if (isPropertyFrozen(actualTarget, p)) {
          return value;
        }

        /* Some dom api must be bound to native window, otherwise it would cause exception like 'TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation'
           See this code:
             const proxy = new Proxy(window, {});
             const proxyFetch = fetch.bind(proxy);
             proxyFetch('https://qiankun.com');
        */
        const boundTarget = useNativeWindowForBindingsProps.get(p) ? nativeGlobal : globalContext;
        return getTargetValue(boundTarget, value);
      },

      // trap in operator
      // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
      has(target: FakeWindow, p: string | number | symbol): boolean {
        // property in cachedGlobalObjects must return true to avoid escape from get trap
        return p in cachedGlobalObjects || p in target || p in globalContext;
      },

      getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
        /*
         as the descriptor of top/self/window/mockTop in raw window are configurable but not in proxy target, we need to get it from target to avoid TypeError
         see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
         > A property cannot be reported as non-configurable, if it does not existed as an own property of the target object or if it exists as a configurable own property of the target object.
         */
        if (target.hasOwnProperty(p)) {
          const descriptor = Object.getOwnPropertyDescriptor(target, p);
          descriptorTargetMap.set(p, 'target');
          return descriptor;
        }

        if (globalContext.hasOwnProperty(p)) {
          const descriptor = Object.getOwnPropertyDescriptor(globalContext, p);
          descriptorTargetMap.set(p, 'globalContext');
          // A property cannot be reported as non-configurable, if it does not exist as an own property of the target object
          if (descriptor && !descriptor.configurable) {
            descriptor.configurable = true;
          }
          return descriptor;
        }

        return undefined;
      },

      // trap to support iterator with sandbox
      ownKeys(target: FakeWindow): ArrayLike<string | symbol> {
        return uniq(Reflect.ownKeys(globalContext).concat(Reflect.ownKeys(target)));
      },

      defineProperty: (target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean => {
        const from = descriptorTargetMap.get(p);
        /*
         Descriptor must be defined to native window while it comes from native window via Object.getOwnPropertyDescriptor(window, p),
         otherwise it would cause a TypeError with illegal invocation.
         */
        switch (from) {
          case 'globalContext':
            return Reflect.defineProperty(globalContext, p, attributes);
          default:
            return Reflect.defineProperty(target, p, attributes);
        }
      },

      deleteProperty: (target: FakeWindow, p: string | number | symbol): boolean => {
        this.registerRunningApp(name, proxy);
        if (target.hasOwnProperty(p)) {
          // @ts-ignore
          delete target[p];
          updatedValueSet.delete(p);

          return true;
        }

        return true;
      },

      // makes sure `window instanceof Window` returns truthy in micro app
      getPrototypeOf() {
        return Reflect.getPrototypeOf(globalContext);
      },
    });

    this.proxy = proxy;

    activeSandboxCount++;
  }

  public patchDocument(doc: Document) {
    this.document = doc;
  }

  private registerRunningApp(name: string, proxy: Window) {
    if (this.sandboxRunning) {
      const currentRunningApp = getCurrentRunningApp();
      if (!currentRunningApp || currentRunningApp.name !== name) {
        setCurrentRunningApp({ name, window: proxy });
      }
      // FIXME if you have any other good ideas
      // remove the mark in next tick, thus we can identify whether it in micro app or not
      // this approach is just a workaround, it could not cover all complex cases, such as the micro app runs in the same task context with master in some case
      nextTask(clearCurrentRunningApp);
    }
  }
}

active(加载函数)

active() {
   if (!this.sandboxRunning) activeSandboxCount++;
   this.sandboxRunning = true;
 }

inactive(卸载函数)

 inactive() {
   this.sandboxRunning = false;
 }

总结

优点:相比第二种,不用再加载和卸载时恢复全局变量,性能得到进一步提升。并且支持加载多个子应用。 不足:兼容性不如第一种。

qiankun 如何调用

文章到这里有个问题,就是除了第一种方案之外的其他两种方案如何设置全局变量。如果看代码,我要设置设置一个 window.city = "杭州",要用LegacySandbox.proxy.city = “杭州”,这明显不符合大家的书写习惯啊。但是大家都知道,在乾坤的子应用中直接用window.xxx设置我们需要的变量。其实这里的实现是通过 import-html-entry 包来实现的,它支持执行页级 js 脚本以及拉取上述 html 中所有的外联 js 并支持执行。

 function fn(window, self, globalThis) {
   // 你的 JavaScript code
 }
 const bindedFn = fn.bind(window.proxy);
 // 将子应用中的window.proxy指向window
 bindedFn(window.proxy, window.proxy, window.proxy);

因此,当我们在 JS 文件里有 window.city = "杭州" 时,实际上会变成:

 function fn(window, self, globalThis) {
   window.city = "杭州"
 }
 const bindedFn = fn.bind(window.proxy);
 bindedFn(window.proxy, window.proxy, window.proxy);

那么此时,window.city 的 window 就不是全局 window 而是 fn 的入参 window 了。又因为我们把 window.proxy 作为入参传入,所以 window.city 实际上为 window.proxy.city = "杭州"。

生命周期

创建完沙箱之后,就会创建qiankun的生命周期,包括beforeLoad、beforeMount、afterMount、beforeUnmount、afterUnmount

首先执行beforeLoad的生命周期hook。

然后从之前import-html-entry中返回的结果中调用其中的execScripts函数,返回子应用的生命周期。子应用中的生命周期包括bootstrap, mount, unmount, update等。

然后为子应用注册一个全局依赖state变化的监听onGlobalStateChange、更新store的函数setGlobalState、以及卸载监听的函数offGlobalStateChange。

最后返回一个包含配置的包裹函数parcelConfigGetter,其中就是上面提到的包含name、bootstrap、mount和unmount的对象。

注意,mount是个数组,会依次

1、执行prevAppUnmountedDeferred上的promise

2、重新初始化包裹元素appWrapperElement和最外层的包裹元素appWrapperGetter

3、根据配置创建包裹完成的子应用内容,确保每次应用加载前容器 dom 结构已经设置完毕,并触发render函数构建dom树(此时还没有挂在到页面上,属于mounting阶段)。

4、执行沙箱的mount函数(dom都创建完了,此时执行沙箱的mount刚刚好,可以拦截windows)

5、执行beforeMount的生命周期hook

6、调用子应用暴露出的mount函数,此时子应用会去挂在到页面上。

7、调用render函数,将loading关掉,并且更改此时为mounted状态,也就是挂载完成。

8、执行afterMount的生命周期hook

9、最后重置一下prevAppUnmountedDeferred

至此mount执行完成。

unmount也是个数组,回依次执行:

1、执行beforeUnmount的生命周期hook

2、调用子应用暴露出的unmount函数,此时子应用会调用卸载函数。

3、执行沙箱的unmount,恢复之前的全局状态,并暂存当前子应用的全局状态。

4、执行afterUnmount的生命周期hook

5、调用render函数,全局状态变为unmounted,卸载掉当前子应用的全局stata监听函数,并且初始化appWrapperElement为null,等待垃圾回收

6、最后,prevAppUnmountedDeferred调用resolve,表示下载完成。

至此unmount完成。

启动应用

上面已经完成了子应用的注册,并且将所有子应用的生命周期、沙箱和渲染等逻辑封装到了app里面。当执行start()函数时,代码如下:

export function start(opts: FrameworkConfiguration = {}) {
  frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
  const { prefetch, urlRerouteOnly = defaultUrlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;

  if (prefetch) {
    doPrefetchStrategy(microApps, prefetch, importEntryOpts);
  }

  frameworkConfiguration = autoDowngradeForLowVersionBrowser(frameworkConfiguration);

  startSingleSpa({ urlRerouteOnly });
  started = true;

  frameworkStartedDefer.resolve();
}

其中,当prefetch配置时,会执行doPrefetchStrategy函数。这个函数会根据配置的预加载策略却加载不同的应用。

首先检测prefetch配置为 string[] 则会在第一个微应用 mounted 后开始加载数组内的微应用资源

 if (Array.isArray(prefetchStrategy)) {
    prefetchAfterFirstMounted(appsName2Apps(prefetchStrategy as string[]), importEntryOpts);
  } 

其次如果prefetch是函数,则完全自定义应用的资源加载时机 (首屏应用及次屏应用)。

(async () => {
      // critical rendering apps would be prefetch as earlier as possible
      const { criticalAppNames = [], minorAppsName = [] } = await prefetchStrategy(apps);
      prefetchImmediately(appsName2Apps(criticalAppNames), importEntryOpts);
      prefetchAfterFirstMounted(appsName2Apps(minorAppsName), importEntryOpts);
    })();

最后其他选项根据策略分别判断

switch (prefetchStrategy) {
      case true:
        prefetchAfterFirstMounted(apps, importEntryOpts);
        break;

      case 'all':
        prefetchImmediately(apps, importEntryOpts);
        break;

      default:
        break;
    }

然后调用single-spa的start函数,开始启动微应用。

设置主应用启动后默认进入的微应用setDefaultMountApp

export function setDefaultMountApp(defaultAppLink: string) {
  // can not use addEventListener once option for ie support
  window.addEventListener('single-spa:no-app-change', function listener() {
    const mountedApps = getMountedApps();
    if (!mountedApps.length) {
      navigateToUrl(defaultAppLink);
    }

    window.removeEventListener('single-spa:no-app-change', listener);
  });
}

很简单,监听app加载完成后,直接导航到这个link,再移除这个监听函数。

第一个微应用 mount 后需要调用的方法runAfterFirstMounted

export function runAfterFirstMounted(effect: () => void) {
  // can not use addEventListener once option for ie support
  window.addEventListener('single-spa:first-mount', function listener() {
    if (process.env.NODE_ENV === 'development') {
      console.timeEnd(firstMountLogLabel);
    }

    effect();

    window.removeEventListener('single-spa:first-mount', listener);
  });
}

也很简单,就是监听single-spa的第一个app mount后,触发该effect,再移除监听。

手动加载微应用loadMicroApp

export function loadMicroApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration?: FrameworkConfiguration & { autoStart?: boolean },
  lifeCycles?: FrameworkLifeCycles<T>,
): MicroApp {
  const { props, name } = app;

  const container = 'container' in app ? app.container : undefined;
  // Must compute the container xpath at beginning to keep it consist around app running
  // If we compute it every time, the container dom structure most probably been changed and result in a different xpath value
  const containerXPath = getContainerXPath(container);
  const appContainerXPathKey = `${name}-${containerXPath}`;

  let microApp: MicroApp;
  const wrapParcelConfigForRemount = (config: ParcelConfigObject): ParcelConfigObject => {
    let microAppConfig = config;
    if (container) {
      if (containerXPath) {
        const containerMicroApps = containerMicroAppsMap.get(appContainerXPathKey);
        if (containerMicroApps?.length) {
          const mount = [
            async () => {
              // While there are multiple micro apps mounted on the same container, we must wait until the prev instances all had unmounted
              // Otherwise it will lead some concurrent issues
              const prevLoadMicroApps = containerMicroApps.slice(0, containerMicroApps.indexOf(microApp));
              const prevLoadMicroAppsWhichNotBroken = prevLoadMicroApps.filter(
                (v) => v.getStatus() !== 'LOAD_ERROR' && v.getStatus() !== 'SKIP_BECAUSE_BROKEN',
              );
              await Promise.all(prevLoadMicroAppsWhichNotBroken.map((v) => v.unmountPromise));
            },
            ...toArray(microAppConfig.mount),
          ];

          microAppConfig = {
            ...config,
            mount,
          };
        }
      }
    }

    return {
      ...microAppConfig,
      // empty bootstrap hook which should not run twice while it calling from cached micro app
      bootstrap: () => Promise.resolve(),
    };
  };

  /**
   * using name + container xpath as the micro app instance id,
   * it means if you rendering a micro app to a dom which have been rendered before,
   * the micro app would not load and evaluate its lifecycles again
   */
  const memorizedLoadingFn = async (): Promise<ParcelConfigObject> => {
    const userConfiguration = autoDowngradeForLowVersionBrowser(
      configuration ?? { ...frameworkConfiguration, singular: false },
    );
    const { $$cacheLifecycleByAppName } = userConfiguration;

    if (container) {
      // using appName as cache for internal experimental scenario
      if ($$cacheLifecycleByAppName) {
        const parcelConfigGetterPromise = appConfigPromiseGetterMap.get(name);
        if (parcelConfigGetterPromise) return wrapParcelConfigForRemount((await parcelConfigGetterPromise)(container));
      }

      if (containerXPath) {
        const parcelConfigGetterPromise = appConfigPromiseGetterMap.get(appContainerXPathKey);
        if (parcelConfigGetterPromise) return wrapParcelConfigForRemount((await parcelConfigGetterPromise)(container));
      }
    }

    const parcelConfigObjectGetterPromise = loadApp(app, userConfiguration, lifeCycles);

    if (container) {
      if ($$cacheLifecycleByAppName) {
        appConfigPromiseGetterMap.set(name, parcelConfigObjectGetterPromise);
      } else if (containerXPath) appConfigPromiseGetterMap.set(appContainerXPathKey, parcelConfigObjectGetterPromise);
    }

    return (await parcelConfigObjectGetterPromise)(container);
  };

  if (!started && configuration?.autoStart !== false) {
    // We need to invoke start method of single-spa as the popstate event should be dispatched while the main app calling pushState/replaceState automatically,
    // but in single-spa it will check the start status before it dispatch popstate
    // see https://github.com/single-spa/single-spa/blob/f28b5963be1484583a072c8145ac0b5a28d91235/src/navigation/navigation-events.js#L101
    // ref https://github.com/umijs/qiankun/pull/1071
    startSingleSpa({ urlRerouteOnly: frameworkConfiguration.urlRerouteOnly ?? defaultUrlRerouteOnly });
  }

  microApp = mountRootParcel(memorizedLoadingFn, { domElement: document.createElement('div'), ...props });

  if (container) {
    if (containerXPath) {
      // Store the microApps which they mounted on the same container
      const microAppsRef = containerMicroAppsMap.get(appContainerXPathKey) || [];
      microAppsRef.push(microApp);
      containerMicroAppsMap.set(appContainerXPathKey, microAppsRef);

      const cleanup = () => {
        const index = microAppsRef.indexOf(microApp);
        microAppsRef.splice(index, 1);
        // @ts-ignore
        microApp = null;
      };

      // gc after unmount
      microApp.unmountPromise.then(cleanup).catch(cleanup);
    }
  }

  return microApp;
}

手动加载app核心就是调用single-spa的mountRootParcel,mountRootParcel将会创建并挂载一个 single-spa parcel在新窗口打开,注意:Parcel不会自动卸载。卸载需要手动触发。

上面使用到了memorizedLoadingFn函数,它会根据name + container xpath作为微应用的实例id,判断一下如果已经之前render过,就不会重新执行,否则会执行上面提到过的loadApp函数,返回一个ParcelConfigObjectGetter(和上面一样)。

手动预加载指定的微应用静态资源prefetchApps

src/prefetch.ts


export function prefetchImmediately(apps: AppMetadata[], opts?: ImportEntryOpts): void {
  if (process.env.NODE_ENV === 'development') {
    console.log('[qiankun] prefetch starting for apps...', apps);
  }

  apps.forEach(({ entry }) => prefetch(entry, opts));
}

/**
 * prefetch assets, do nothing while in mobile network
 * @param entry
 * @param opts
 */
function prefetch(entry: Entry, opts?: ImportEntryOpts): void {
  if (!navigator.onLine || isSlowNetwork) {
    // Don't prefetch if in a slow network or offline
    return;
  }

  requestIdleCallback(async () => {
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(entry, opts);
    requestIdleCallback(getExternalStyleSheets);
    requestIdleCallback(getExternalScripts);
  });
}

预加载指定的微应用就是在浏览器空闲时执行importEntry暴露出来的脚本和样式文件。

总结

至此,乾坤核心原理大致也能看出来了。它依赖了两个核心库:single-spa和import-html-entry。其中single-spa是一个用于前端微服务的 javascript 框架,qiankun属于在其上的完善。而import-html-entry是qiankun团队抽离出来的根据入口地址对子应用进行解析功能。

在主应用中通过注册子应用的入口,调用import-html-entry通过fetch获取子应用的html并通过eval进行解析,然后为每个子应用暴露出生命周期,脚本执行数组、样式执行数组等参数。在执行 js 资源时通过 eval,会将 window 绑定到一个 Proxy 对象上,以防污染全局变量,并方便对脚本的 window 相关操作做劫持处理,达到子应用之间的脚本隔离。 如果开启沙箱,qiankun还会将当前渲染的子应用挂在到shadowdom中。最后通过路由变化触发不同的子应用渲染以及卸载等。

3种沙箱可以保证样式隔离,snapshot沙箱是遍历当前windows对象,保存到一个空对象上作为假的window。而legacy沙箱是把当前的windows做一层代理,生成一个fakewindow。proxy沙箱是为每一个子应用的生成一个实例proxy,优先作用到自己的fakewindows上,没有的属性才会透传到windows。

当然,乾坤也有很多缺陷,比如依赖共享、主应用和子应用联调困难等问题。

img