Construction | 张小伦的网络日志

single-spa源码解析-registerApplication和start

Posted on:2020-11-21 13:28

首先将问题简化,假定主应用和子应用都已经准备好的情况下,针对应用的注册和启动这两个关键操作进行分析。

在主应用中只需要调用registerApplication即可注册子应用,调用start启动主应用。比如下面的例子

// single-spa-config.js
import { registerApplication, start } from 'single-spa';

// 使用简单参数
registerApplication(
  'app2', 
  () => import('src/app2/main.js'),
  (location) => location.pathname.startsWith('/app2'),
  { some: 'value' },
);

// 使用对象参数
registerApplication({
  name: 'app1',
  app: () => import('src/app1/main.js'),
  activeWhen: '/app1',
  customProps: {
    some: 'value',
  }
);

start();

registerApplication

先让我们来看一下函数签名

registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
)

可以传四个参数,也能传递一个对象。对象参数的效果与四个参数效果一样。因为在 registerApplication 函数的顶部就会执行来一个序列化参数的操作,统一将参数转换成约定 registration

// src/application/apps.js
export function registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );
  ...
}

序列化参数

sanitizeArguments这个方法里面的逻辑不复杂,简单来说就是将各参数序列化成需要的类型。

function sanitizeArguments(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  const usingObjectAPI = typeof appNameOrConfig === "object";

  const registration = {
    name: null,
    loadApp: null,
    activeWhen: null,
    customProps: null,
  };

  if (usingObjectAPI) {
    validateRegisterWithConfig(appNameOrConfig);
    registration.name = appNameOrConfig.name;
    registration.loadApp = appNameOrConfig.app;
    registration.activeWhen = appNameOrConfig.activeWhen;
    registration.customProps = appNameOrConfig.customProps;
  } else {

// 校验各参数类型,如果不通过就抛出错误。
    validateRegisterWithArguments(
      appNameOrConfig,
      appOrLoadApp,
      activeWhen,
      customProps
    );
    registration.name = appNameOrConfig;
    registration.loadApp = appOrLoadApp;
    registration.activeWhen = activeWhen;
    registration.customProps = customProps;
  }

  registration.loadApp = sanitizeLoadApp(registration.loadApp);
  registration.customProps = sanitizeCustomProps(registration.customProps);
  registration.activeWhen = sanitizeActiveWhen(registration.activeWhen);

  return registration;
}

将应用注入

参数序列化之后,先调用 getAppNames 方法检查是否存在重复注册的子应用,如果有则抛出错误

if (getAppNames().indexOf(registration.name) !== -1)
  throw Error(
    formatErrorMessage(
      21,
      __DEV__ &&
      `There is already an app registered with name ${registration.name}`,
      registration.name
    )
  );

如果不存在重复注册的应用,接下来就将所有的应用注册到 apps 数组中,执行 reroute()

apps.push(
  assign(
    {
      loadErrorTime: null,
      status: NOT_LOADED,
      parcels: {},
      devtools: {
        overlays: {
          options: {},
          selectors: [],
        },
      },
    },
    registration
  )
);

if (isInBrowser) {
  ensureJQuerySupport();
  reroute();
}

start

因为start方法和registeApplication方法最后都调用了reroutestart的代码比较少,所以先介绍start方法。

// src/start.js
import { reroute } from "./navigation/reroute.js";
import { formatErrorMessage } from "./applications/app-errors.js";
import { setUrlRerouteOnly } from "./navigation/navigation-events.js";
import { isInBrowser } from "./utils/runtime-environment.js";

let started = false;

export function start(opts) {
  started = true;
  if (opts && opts.urlRerouteOnly) {
    setUrlRerouteOnly(opts.urlRerouteOnly);
  }
  if (isInBrowser) {
    reroute();
  }
}

export function isStarted() {
  return started;
}

if (isInBrowser) {
  setTimeout(() => {
    if (!started) {
      console.warn(
        formatErrorMessage(
          1,
          __DEV__ &&
            `singleSpa.start() has not been called, 5000ms after single-spa was loaded. Before start() is called, apps can be declared and loaded, but not bootstrapped or mounted.`
        )
      );
    }
  }, 5000);
}

首先申明了一个 started 变量作为应用启动的标记,默认是 false,表示未启动。isStarted()返回这个标记,用来判断当前应用的状态。

start`方法接受一个options参数,目前只有一个配置:urlRerouteOnly。默认是false。如果设置成true,调用history.pushState() 和 history.replaceState() 时不会触发 reroute,除非客户端路由真的发生了变化。设置为true时在某些时候会有更好的性能。

urlRerouteOnly会在navigation-events中使用,后面再讲。

在文件的最后作了一个超时检测,在代码执行5s后isStarted状态依旧是false时抛出一个警告。

接下来看一下核心方法Reroute

reroute

默认设置 appChangeUnderwayfalse。函数每次执行时都会判断appChangeUnderwayappChangeUnderwaytrue时表示当前有reroute的任务正在执行(reroute被调用了并且其中的promsie任务还没结束),此时返回一个Promise,内部将resolve,reject和reoute的第二个参数一起 push 到peopleWaitingOnAppChange中,等当前reroute对应的任务执行完成之后在作为 pendingPromise 参数继续执行。

if (appChangeUnderway) {
  return new Promise((resolve, reject) => {
    peopleWaitingOnAppChange.push({
      resolve,
      reject,
      eventArguments,
    });
  });
}

但是appChangeUnderway初始值是false,在什么时候被修改成true的呢?接着往下看。

调用getAppChanges()方法将注册的应用按照当前各自的生命周期分组:

  1. 加载失败(LOAD_ERROR) 的app放入appToUnload
  2. 未下载(NOT_LOADED)和下载中(LOADING_SOURCE_CODE)的app放入 appsToLoad
  3. 未引导(NOT_BOOTSTRAPPED)和未挂载(NOT_MOUNTED)的app放入appsToUnload或者appsToMount
  4. 已挂载(MOUNTED)的app放入appsToUnmount

未引导(NOT_BOOTSTRAPPED)和未挂载(NOT_MOUNTED)的app会多加一个判断,当前window.location匹配activeWhen规则时放入appToMount数组,否则放入appsToUnload。

分组完毕之后,判断前文提到的started状态。如果started为true,将appChangeUnderway也设置为true,然后将app按照toUnload,toLoad,toUnmount和toMount的分组数组合并在一起,保存到appsThatChanged数组中。最后调用 performAppChanges 方法,返回执行的结果。如果started为false,将ToLoad的app赋值给appsThatChanged,然后调用loadApps方法,并返回执行的结果。

loadApps 和 performAppChanges

loadApps

先来看较为简单的loadApps()方法。

function loadApps() {
  return Promise.resolve().then(() => {
    const loadPromises = appsToLoad.map(toLoadPromise);

    return (
      Promise.all(loadPromises)
        .then(callAllEventListeners)
        // there are no mounted apps, before start() is called, so we always return []
        .then(() => [])
        .catch((err) => {
          callAllEventListeners();
          throw err;
        })
    );
  });
}

loadApps只有在启动的时候会调用一次,此时startedfalse。这个方法具体做了什么事情呢?

遍历appsToLoad中的app,最后返回一个Promise数组,通过Promise.all()一次性全部调用将app的状态设置为 LOADING_SOURCE_CODE,然后检查参数中的生命周期函数,将这些函数挂载到app上。然后再调用 callAllEventListeners 方法,劫持 hashchangepopstate这两个事件。

performAppChanges

如果应用已经启动,即 startedtrue 时,逻辑会进入到 performAppChanges 方法,这个方法有点长。

function performAppChanges() {
  return Promise.resolve().then(() => {
    // https://github.com/single-spa/single-spa/issues/545
    window.dispatchEvent(
      new CustomEvent(
        appsThatChanged.length === 0
          ? "single-spa:before-no-app-change"
          : "single-spa:before-app-change",
        getCustomEventDetail(true)
      )
    );

    window.dispatchEvent(
      new CustomEvent(
        "single-spa:before-routing-event",
        getCustomEventDetail(true, { cancelNavigation })
      )
    );

    if (navigationIsCanceled) {
      window.dispatchEvent(
        new CustomEvent(
          "single-spa:before-mount-routing-event",
          getCustomEventDetail(true)
        )
      );
      finishUpAndReturn();
      navigateToUrl(oldUrl);
      return;
    }

    const unloadPromises = appsToUnload.map(toUnloadPromise);

    const unmountUnloadPromises = appsToUnmount
      .map(toUnmountPromise)
      .map((unmountPromise) => unmountPromise.then(toUnloadPromise));

    const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);

    const unmountAllPromise = Promise.all(allUnmountPromises);

    unmountAllPromise.then(() => {
      window.dispatchEvent(
        new CustomEvent(
          "single-spa:before-mount-routing-event",
          getCustomEventDetail(true)
        )
      );
    });

    /* We load and bootstrap apps while other apps are unmounting, but we
      * wait to mount the app until all apps are finishing unmounting
      */
    const loadThenMountPromises = appsToLoad.map((app) => {
      return toLoadPromise(app).then((app) =>
        tryToBootstrapAndMount(app, unmountAllPromise)
      );
    });

    /* These are the apps that are already bootstrapped and just need
      * to be mounted. They each wait for all unmounting apps to finish up
      * before they mount.
      */
    const mountPromises = appsToMount
      .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
      .map((appToMount) => {
        return tryToBootstrapAndMount(appToMount, unmountAllPromise);
      });
    return unmountAllPromise
      .catch((err) => {
        callAllEventListeners();
        throw err;
      })
      .then(() => {
        /* Now that the apps that needed to be unmounted are unmounted, their DOM navigation
          * events (like hashchange or popstate) should have been cleaned up. So it's safe
          * to let the remaining captured event listeners to handle about the DOM event.
          */
        callAllEventListeners();

        return Promise.all(loadThenMountPromises.concat(mountPromises))
          .catch((err) => {
            pendingPromises.forEach((promise) => promise.reject(err));
            throw err;
          })
          .then(finishUpAndReturn);
      });
  });
}

可以看到,这个方法也是放在一个 Promise.resolve()中。首先触发了一些自定义事件,然后根据应用状态分别创建了对应取消操作的Promise数组。需要被移除的应用 appToUnLoad创建了unLoadPromises,需要被卸载的应用appsToUnmount先创建卸载的toUnmountPromise,再创建 unLoadPromise。最后将所有的Promise合并成一个数组通过Promise.all执行,执行完成之后触发single-spa:before-mount-routing-event事件。

至此,需要unmount和unload的app执行过程都结束了,接下来如法炮制开始load和mount对应的应用。使用 appToLoadappToMount 创建一个Promise数组,通过Promise.all执行。在app对应状态变更完成之后,调用 tryToBootstrapAndMount完成引导并挂载应用。

function tryToBootstrapAndMount(app, unmountAllPromise) {
  if (shouldBeActive(app)) {
    return toBootstrapPromise(app).then((app) =>
      unmountAllPromise.then(() =>
        shouldBeActive(app) ? toMountPromise(app) : app
      )
    );
  } else {
    return unmountAllPromise.then(() => app);
  }
}

引导并挂载成功之后触发一次single-spa:routing-event事件,根据变化的app数量决定触发single-spa:no-app-change事件还是single-spa:app-change事件。最后将appChangeUnderway设置为false,保证后续的reroute()调用能够执行,此时也标志着当前这次reroute调用的执行已结束,最后检查当前是否还有pengding中的任务,有的话继续执行。

if (peopleWaitingOnAppChange.length > 0) {
  /* While we were rerouting, someone else triggered another reroute that got queued.
    * So we need reroute again.
    */
  const nextPendingPromises = peopleWaitingOnAppChange;
  peopleWaitingOnAppChange = [];
  reroute(nextPendingPromises);
}