Single-spa与QianKun解读

概要

1. Single-spa
2. Qiankun

Single-spa

single-spa的理想化实现是使用es6 modules,以js entry的方式接入,每一个微应用都是基座app importmap的一个键值

接入Single-spa时需要先对microapp改造:

  1. 微应用路由改造(大多数情况下依赖于路由来激活微应用时)来避免各个应用间的冲突,比如添加一个特定的前缀
  2. 提供给single-spa使用的钩子,改造微应用入口,挂载点变更和生命周期函数导出
  3. 打包工具配置更改 – 要打包成一个js文件(当然也可以使用webpack的manifest,但是根本问题是无法进行分包加载等优化)

js entry的方式由于没有上下文路由/一次全量加载所有js,没法实现按需加载,首屏渲染优化,CSS独立打包,更新版本还要考虑缓存策略的问题

Single-spa 调用流程

registerApps => 处理/验证入参 => 格式化入参生成registerations => reroute() => load()

single-spa.start() => reroute() => load() if not loaded => 调用bootsraped钩子 => 变为bootstraped

路由变更 => reroute() => 判断是否active => active则mount(即启动微应用,比如new vue(),ReactDOM.render())

若inactive => unmount (销毁微应用实例)

single-spa核心方法,区分出各个状态的micro app,返回后处理每种状态,并对应状态需要调用的钩子(各微应用传入),实现状态变更

export function getAppChanges() {
  const appsToUnload = [],
    appsToUnmount = [],
    appsToLoad = [],
    appsToMount = [];

  // We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
  const currentTime = new Date().getTime();

  apps.forEach((app) => {
    const appShouldBeActive =
      app.status !== SKIP_BECAUSE_BROKEN && shouldBeActive(app);

    switch (app.status) {
      case LOAD_ERROR:
        if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
          appsToLoad.push(app);
        }
        break;
      case NOT_LOADED:
        ...

将一个微应用传入的mount钩子全部转化为promises

export function toMountPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    if (appOrParcel.status !== NOT_MOUNTED) {
      return appOrParcel;
    }

    if (!beforeFirstMountFired) {
      window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
      beforeFirstMountFired = true;
    }

    return reasonableTime(appOrParcel, "mount")
      .then(() => {
        appOrParcel.status = MOUNTED;
        ....

activeWhen传入方法时,可以实现懒加载,满足条件后才load微应用

Single-spa五个缺少的功能
  1. 需要改造微应用,上文提到的微应用改造
  2. 样式隔离,多个微应用同时挂载,样式冲突
  3. JS 隔离,多个微应用同时挂载,window冲突
  4. 资源预加载,微应用只在符合条件时开始加载
  5. 无法实现应用间的通信

Single-spa缺少这些功能也情有可原,它只专注微应用的宏观管理,不包含某些具体的功能需求

总结来说single-spa – 只做了 管理(初始化、挂载、卸载)微应用,并调用对应方法,粗暴一点讲,single-spa只是微应用的状态管理机

Qiankun

qiankun 基于Single spa封装,see more on https://github.com/umijs/qiankun

qiankun依赖import html entry包 以html entry的方式接入

基于qiankun,只需要提供微应用 hotst的url就可以接入,简单到类似于iframe

注册微应用方法,registerApplication的入参(包含钩子,配置,微应用meta)进入single-spa中被使用/调用

export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
    ...
    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,
    });
    ...
}
  1. html entry 保留微应用内部优化,减少对微应用入侵

qiankun提供了封装的load方法(最终给single-spa使用) – 通过html entry解析微应用的template, execScripts, assetPublicPath, getExternalScripts资源并加载

execScripts 即微应用的业务代码,包含export出来的三个钩子,qiankun拿到后传入single-spa

export async function loadApp<T extends ObjectType>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObjectGetter> {

    ...

  // 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();

    ...
  1. shadow root / scroped CSS 两种方式实现内置的样式隔离

创建微应用的根节点为shadow root,给元素加attribute实现scopedCSS

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

  1. built-in sandbox window.Proxy 代理window实现微应用运行时沙箱

基于 Proxy 实现的沙箱,实现window的property get/set,全局对象比如document/eval等

export default class ProxySandbox implements SandBox {
...
  constructor(name: string, globalContext = window) {
...
    const proxy = new Proxy(fakeWindow, {
      set: (target: FakeWindow, p: PropertyKey, value: any): boolean => {
...

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

      get: (target: FakeWindow, p: PropertyKey): any => {
...
        // 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;
        }
...
}

  1. built-in prefetch strategy 内置预加载逻辑,传入配置对象即可使用

根据传入的prefetch configure判断需要prefetch的时间点,监听事件,符合要求调用prefetch()
即importEntry – html entry

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);
  });
}
  1. build-in GloabState 内置全局状态,实现微应用间通信

内部实现状态机,将状态更新,订阅更新等方法通过mount钩子传给微应用

 const parcelConfig: ParcelConfigObject = {
      name: appInstanceId,
      bootstrap,
      mount: [
...
        async (props) => mount({ ...props, container: appWrapperGetter(), setGlobalState, onGlobalStateChange }),
...
      ],

...
}