single-spa

在 MPA 模式下可以通过浏览器来实现应用状态和周期事件的处理,但是在 SPA 模式下默认无法做到技术无关的应用切换。大部分的 Web 框架(例如 React、Vue 和 Svelte 等)提供了根据路由来切换框架组件的能力,并没有提供根据路由来切换技术无关的应用的能力。为此,single-spa 提供了根据路由来切换应用的能力,并且在内部实现了应用的状态转换和周期事件,从而实现了类似于 MPA 模式下浏览器的应用切换能力。

image.png

single-spa 原理解析

整个 single-spa 源码可以被分为几个主要部分,如下所示:

  • applications:注册微应用并解析微应用的注册参数
  • start:启动微应用的生命周期函数执行
  • navigation:处理导航事件、根据导航变化计算和执行微应用的变化
  • lifecycles:异步执行微应用的生命周期函数以及相应的错误处理

applications

reroute

该函数主要是在微应用需要发生变化时触发,它会通过 getAppChanges 判断需要变化的微应用列表,然后根据外部是否调用了 start 函数来判断执行微应用的批量加载 loadApps 还是执行所有微应用的变化

export function reroute(pendingPromises = [], eventArguments) {
  // eslint-disable-next-line no-console
  console.log("[navigation/reroute.js - reroute]: reroute 函数开始执行...");
 
  // 如果当前正在执行 performAppChanges 处理应用变化,
  // 则将 eventArguments 存储到 peopleWaitingOnAppChange 数组中
  
  // 如果 performAppChanges 函数还未执行完毕,
  // 但是再次调用了 reroute 函数,
  // 那么会等待 performAppChanges 函数执行完毕
  
  // 在 performAppChanges 函数执行完毕后,
  // 会调用 finishUpAndReturn 函数,
  // 如果 peopleWaitingOnAppChange 数组中有数据,
  // 则会再次执行 reroute 函数
  // 因此这里主要用于延迟执行 reroute 函数
  if (appChangeUnderway) {
    return new Promise((resolve, reject) => {
      // 将 resolve、reject、eventArguments 存储到 peopleWaitingOnAppChange 数组中
      
      // 当 performAppChanges 函数执行完毕后,
      // 会调用 finishUpAndReturn 函数,
      // 如果 peopleWaitingOnAppChange 数组中有数据,
      // 则会再次执行 reroute 函数
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }
 
  // 获取当前应用的变化情况
  // 1. appsToUnload: 需要彻底卸载的应用
  // 2. appsToUnmount: 需要卸载的应用
  // 3. appsToLoad: 需要加载的应用
  // 4. appsToMount: 需要挂载的应用
  const { appsToUnload, appsToUnmount, appsToLoad, appsToMount } =
    getAppChanges();
  let appsThatChanged,
    navigationIsCanceled = false,
    oldUrl = currentUrl,
    newUrl = (currentUrl = window.location.href);
 
  // 如果已经调用了 start 函数启动
  if (isStarted()) {
    // 设置当前应用变化正在进行中,避免多次同时触发应用变化
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    console.log(
      "[navigation/reroute.js - reroute]: appsToUnload 数据: ",
      appsToUnload
    );
    console.log(
      "[navigation/reroute.js - reroute]: appsToUnmount 数据: ",
      appsToUnmount
    );
    console.log(
      "[navigation/reroute.js - reroute]: appsToLoad 数据: ",
      appsToLoad
    );
    console.log(
      "[navigation/reroute.js - reroute]: appsToMount 数据: ",
      appsToMount
    );
    console.log(
      "[navigation/reroute.js - reroute]: 准备执行 performAppChanges..."
    );
    return performAppChanges();
    // 如果还没有启动
    // 调用 singleSpa.registerApplication() 方法后,
    // 还没有调用 singleSpa.start() 方法之前会走到这里
  } else {
    appsThatChanged = appsToLoad;
    console.log("[navigation/reroute.js - reroute]: 准备执行 loadApps...");
    return loadApps();
  }

lifecycles

toLoadPromise 解析

toLoadPromise 本质上不是微应用提供的生命周期函数,而是注册加载 API 时需要提供的激活加载函数,它可以在 single-spa 调用 start 函数后加载,也可以在初始化微应用的路由被激活时触发加载:

export function toLoadPromise(app) {
  console.log(
    "[lifecycles/load.js - toLoadPromise]: toLoadPromise 函数开始执行…",
    app.name,
    app.status
  );
 
  // 开启微任务,异步执行微应用的加载函数
  return Promise.resolve().then(() => {
    console.log(
      "[lifecycles/load.js - toLoadPromise]: toLoadPromise Promise.resolve 开始执行, 开始检测 app.loadPromise 是否已经执行过。",
      app.name,
      app.status
    );
 
    // 如果 app.loadPromise 存在,
    // 直接返回 app.loadPromise
    // 这里可以确保同一个 app 只会执行一次 loadApp 方法
 
    // 例如注册微应用时会调用 loadApps 方法,
    // 会执行微应用的 toLoadPromise,
    // 此时会缓存 app.loadPromise
    
    // 而启动 start 函数最终调用 performAppChanges 时还会执行微应用的 toLoadPromise
    // 为了避免重复执行 app.loadPromise 方法,
    // 这里会直接返回 app.loadPromise(Promise 对象)
    if (app.loadPromise) {
      console.log(
        "[lifecycles/load.js - toLoadPromise]: 已经执行过 app.loadPromise,直接返回对应的 Promise 结果。",
        app.name,
        app.status
      );
      return app.loadPromise;
    }
 
    // 如果 app.status 不是 NOT_LOADED 和 LOAD_ERROR,直接返回 app
    if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
      return app;
    }
 
    // 将 app.status 设置为 LOADING_SOURCE_CODE
    app.status = LOADING_SOURCE_CODE;
 
    let appOpts, isUserErr;
 
    console.log(
      "[lifecycles/load.js - toLoadPromise]: app.loadPromise 开始执行…",
      app.name,
      app.status
    );
 
    // 使用 app.loadPromise 缓存 app 的加载,
    // 避免在 loadApps 以及 performAppChanges 时重复加载
    return (app.loadPromise = Promise.resolve()
      .then(() => {
        console.log(
          "[lifecycles/load.js - toLoadPromise]: 在 Promise.resolve 中开始执行 app.loadPromise…",
          app.name,
          app.status
        );
 
        console.log(
          "[lifecycles/load.js - toLoadPromise]: 准备执行 app.loadApp(registerApplication 的第二个参数 app)…",
          app.name,
          app.status
        );
        // 这里的 loadApp 其实就是 registerApplication 的第二个参数
        // 在主应用中使用 window.fetch 获取子应用的资源,
        // 执行后需要返回 Promise,
        // 并且返回的是子应用的生命周期函数对象
        const loadPromise = app.loadApp(getProps(app));
        // 如果 loadPromise 不是 Promise 对象,抛出异常
        if (!smellsLikeAPromise(loadPromise)) {
          // The name of the app will be prepended to this error message inside of the handleAppError function
          // app.loadApp 返回的不是 Promise 对象,抛出异常
          isUserErr = true;
          throw Error(
            formatErrorMessage(
              33,
              __DEV__ &&
                `single-spa loading function did not return a promise. Check the second argument to registerApplication('${toName(
                  app
                )}', loadingFunction, activityFunction)`,
              toName(app)
            )
          );
        }
 
        // 这里的 val 其实就是 loadApp 的 Promise 返回值
        // 也就是 registerApplication 的第二个参数 app 的返回值
        
        // 例如:() => import("react-micro-app"),
        // 返回的是一个 Promise
        
        // 例如:() => Promise.resolve({ bootstrap: async () => {}, mount, unmount }),
        // 返回的是一个 Promise
        
        // 所以 val 就是各个子应用的生命周期函数组成的对象,
        // 例如:{ bootstrap: async () => {}, mount, unmount }
        return loadPromise.then((val) => {
          console.log(
            "[lifecycles/load.js - toLoadPromise]: app.loadApp 执行完成,开始检测子应用的生命周期函数是否符合要求…",
            app.name,
            app.status
          );
 
          app.loadErrorTime = null;
 
          appOpts = val;
 
          let validationErrMessage, validationErrCode;
 
          if (typeof appOpts !== "object") {
            validationErrCode = 34;
            if (__DEV__) {
              validationErrMessage = `does not export anything`;
            }
          }
 
          // 判断 appOpts 的 bootstrap、mount、unmount 是否符合要求
          if (
            // ES Modules don't have the Object prototype
            // ES 模块没有 Object 原型
            Object.prototype.hasOwnProperty.call(appOpts, "bootstrap") &&
            !validLifecycleFn(appOpts.bootstrap)
          ) {
            validationErrCode = 35;
            if (__DEV__) {
              validationErrMessage = `does not export a valid bootstrap function or array of functions`;
            }
          }
 
          if (!validLifecycleFn(appOpts.mount)) {
            validationErrCode = 36;
            if (__DEV__) {
              validationErrMessage = `does not export a mount function or array of functions`;
            }
          }
 
          if (!validLifecycleFn(appOpts.unmount)) {
            validationErrCode = 37;
            if (__DEV__) {
              validationErrMessage = `does not export a unmount function or array of functions`;
            }
          }
 
          const type = objectType(appOpts);
 
          if (validationErrCode) {
            let appOptsStr;
            try {
              appOptsStr = JSON.stringify(appOpts);
            } catch {}
            console.error(
              formatErrorMessage(
                validationErrCode,
                __DEV__ &&
                  `The loading function for single-spa ${type} '${toName(
                    app
                  )}' resolved with the following, which does not have bootstrap, mount, and unmount functions`,
                type,
                toName(app),
                appOptsStr
              ),
              appOpts
            );
            handleAppError(validationErrMessage, app, SKIP_BECAUSE_BROKEN);
            return app;
          }
 
          if (appOpts.devtools && appOpts.devtools.overlays) {
            app.devtools.overlays = assign(
              {},
              app.devtools.overlays,
              appOpts.devtools.overlays
            );
          }
 
          // 设置 app 的状态为 NOT_BOOTSTRAPPED
          app.status = NOT_BOOTSTRAPPED;
          // 将 appOpts 中的周期函数扁平化
          app.bootstrap = flattenFnArray(appOpts, "bootstrap");
          app.mount = flattenFnArray(appOpts, "mount");
          app.unmount = flattenFnArray(appOpts, "unmount");
          app.unload = flattenFnArray(appOpts, "unload");
          app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
 
          // 删除 app.loadPromise,表明 app.loadPromise 已经执行完成
          // 下一次执行 toLoadPromise 时会重新执行 app.loadPromise
          delete app.loadPromise;
 
          console.log(
            "[lifecycles/load.js - toLoadPromise]:  app.loadApp 返回的周期函数解析完成,所有周期函数符合要求。",
            app.name,
            app.status
          );
 
          return app;
        });
      })
      .catch((err) => {
        delete app.loadPromise;
 
        let newStatus;
        if (isUserErr) {
          // 经常会在微前端中出现这种情况,执行 app.loadApp 时返回的不是 Promise 对象
          // 这里会将 app 的状态设置为 SKIP_BECAUSE_BROKEN
          newStatus = SKIP_BECAUSE_BROKEN;
        } else {
          // 如果 app.loadApp 执行失败,将 app 的状态设置为 LOAD_ERROR
          newStatus = LOAD_ERROR;
          app.loadErrorTime = new Date().getTime();
        }
        handleAppError(err, app, newStatus);
 
        return app;
      }));
  });
}

single-spa 的所有生命周期函数执行都是在微任务中执行,这可以确保主应用框架路由的代码先执行完毕,然后处理微应用的加载和卸载。

整体流程

image.png

在主应用中使用 registerApplication 注册微应用的执行流程如下所示,需要注意每注册一个微应用以下流程都会执行一遍:
image.png
微应用注册完成后需要调用 single-spa 的 start 函数启动,它的执行流程如下所示:
image.png

从上述流程可以发现,通过 registerApplication 注册微应用时只会执行微应用的加载逻辑(执行注册参数 app),但是调用 start 函数启动后,single-spa 会调用 performAppChanges 批量处理所有需要变化的微应用,除了会对激活的微应用批量进行异步加载,还会批量异步执行微应用的生命周期函数,包括 bootstrapmountunmount 以及 unload