微前端——single-spa源码学习

前言

本来是想直接去学习下qiankun的源码,但是qiankun是基于single-spa做的二次封装,通过解决了single-spa的一些弊端和不足来帮助大家能更简单、无痛的构建一个生产可用微前端架构系统。

所以我们应该先对single-spa有一个全面的认识和了解,了解它的不足和缺陷,到时候让我们带着问题去学习qiankun的底层,会有更大的帮助。

single-spa中文文档

代码库地址 https://github.com/sunlianglife/single-spa-study,可以打开代码再对照着阅读,更容易理解

关于微前端

可以分别从single-spa的文档介绍和qiankun的文档介绍初步了解

目录结构及相关文件总览

我是先在github的clone的qiankun代码,看了下package.json里面的single-spa的版本是5.9.2的,所以我就clone了对应版本的single-spa的代码

多写注释,做笔记,编写示例代码+console调试

微前端——single-spa源码学习_第1张图片

1、src/single-spa.js

single-spa的入口文件,其中就是暴露出single-spa的一些属性和方法

2、src/start.js

应用注册完之后,调用start()的逻辑

  • started——应用是否启动的标志
  • start——开启应用的方法
  • isStarted——判断应用是否启动的方法

3、src/jquery-support.js

确保jquery的支持

4、utils

utils里面的工具函数在下一节开始会介绍到

5、src/parcels/mount-parcel.js

沙箱 Parcels
single-spa的一个高级特性,与框架无关,api与注册应用一致,不同的是:parcel组件需要手动挂载,而不是通过 activity 方法被动激活。
single-spa中的微前端有两种类型

  • single-spa applications: application 模式下,子应用的切换(挂载、卸载)都是由修改路由触发的,整个切换过程由 single-spa 框架控制,子应用仅需提供正确的生命周期方法即可。
  • single-spa parcels: 不受路由控制,渲染组件的微前端。在 parcel 模式下,我们需要使用 single-spa 提供的 mountRootParcel 方法来手动挂载/更新/卸载组件

mountParcelmountRootParcel 将立即挂载parcel并返回这个parcel对象。 需要卸载需要手动调用 parcel的 unmount.
mountRootParcelmountParcel 的用法完全一样,只不过 mountParcel 方法不能直接从 single-spa 中获取,需要从子应用/组件的 mount 生命周期方法执行时传入的 props 中获取,

6、src/navigation/navigation-events.js

处理导航事件的文件,包括事件监听,自定义事件创建、事件收集、不同应用之间的跳转等

  • capturedEventListeners——导航事件的收集
  • routingEventsListeningTo——监听到浏览器导航变化的两种事件
  • navigateToUrl——导航到对应url,实现在不同注册应用之前的切换
  • patchedUpdateState——当触发replaceState和pushState方法时,对其进行一个增强
  • createPopStateEvent——创建自定义事件
  • window.addEventListener——对“hashchange”和“popstate”监听
  • parseUri——创建一个a连接的导航

7、src/navigation/reroute.js

reroute()在整个single-spa中就是负责改变app.status和执行在子应用中注册的生命周期函数。

8、src/applications/app-errors.js

异常处理的方法文件

9、src/applications/app.helpers.js

  • 定义应用各个状态的常量
  • isActive——应用是否加载完毕
  • shouldBeActive——当前路由关联的子应用是否激活
  • toName——返回应用的名称
  • isParcel——是否为Parcel模式
  • objectType——区分single-spa的两种模式 parcel || application

10、src/applications/apps.js

注册子应用的方法就这里面,其他大多数是对参数的一些校验处理

  • registerApplication——注册子应用
  • getAppChanges——将子应用按照状态拆分
  • getMountedApps——获取已经挂载的应用名称
  • getAppNames——获取应用的名称
  • getAppStatus——根据名称获取应用的状态
  • checkActivityFunctions——将会调用每个应用的 activeWhen 并且返回一个根据当前路径判断那些应用应该被挂载的列表
  • unregisterApplication——应用卸载
  • unloadApplication——移除已注册的应用的目的是将其设置回 NOT_LOADED 状态,
  • immediatelyUnloadApp——立即卸载应用,调用卸载的生命周期函数
  • validateRegisterWithArguments——参数异常处理
  • validateRegisterWithConfig——验证应用的配置信息是否合法,抛出异常
  • validCustomProps——验证注册子应用的propps
  • sanitizeArguments——格式化注册子应用的属性参数
  • sanitizeLoadApp——验证注册子应用是的第二个参数一定是一个返回promise的函数
  • sanitizeCustomProps——保证props存在
  • sanitizeActiveWhen——得到一个函数,用来判断当前地址和用户的给定的baseUrl的比配关系,函数返回boolean
  • pathToActiveWhen——函数返回boolean值,判断当前路由是否匹配用户给定的路径
  • toDynamicPathValidatorRegex——根据用户提供的baseURL,生成正则表达式

11、src/applications/timeouts.js

超时的一些处理

12、src/devtools/devtools.js

暴露的属性和方法,在入口文件中导出

// 暴露的方法集合
// window.__SINGLE_SPA_DEVTOOLS__  single-spa在window中挂载的变量
if (isInBrowser && window.__SINGLE_SPA_DEVTOOLS__) {
  window.__SINGLE_SPA_DEVTOOLS__.exposedMethods = devtools;
}

13、src/lifecycles

这个文件夹下面的文件,从名字就能看出是子应用各个生命周期的执行方法,改变状态,和src/applications/app.helpers.js中定义的状态是对应的

源码分析(摘取部分核心的方法,全部代码可以去代码仓库上去看)

拿到一个陌生的项目,首先需要看的是package.jsonREADME.mdconfig文件,从目录能看出来single-spa是用rollup来打包的,打开之后在导出的配置信息里面找到入口文件src/single-spa.js

input: “./src/single-spa.js”

  • 先介绍一下utils的工具函数,好多地方会用到
    微前端——single-spa源码学习_第2张图片

  • 应用的状态常量

// App statuses
export const NOT_LOADED = "NOT_LOADED"; // single-spa应用注册了,还未加载。
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE"; // 应用代码正在被拉取。
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED"; // 应用已经加载,还未初始化。
export const BOOTSTRAPPING = "BOOTSTRAPPING"; // 生命周期函数已经执行,还未结束。
export const NOT_MOUNTED = "NOT_MOUNTED"; // 应用已经加载和初始化,还未挂载
export const MOUNTING = "MOUNTING"; // 应用正在被挂载,还未结束。
export const MOUNTED = "MOUNTED"; // 应用目前处于激活状态,已经挂载到DOM元素上。
export const UPDATING = "UPDATING"; // 更新中
export const UNMOUNTING = "UNMOUNTING"; // 应用正在被卸载,还未结束
export const UNLOADING = "UNLOADING"; // 应用正在被移除,还未结束
export const LOAD_ERROR = "LOAD_ERROR"; // 应用的加载功能返回了一个rejected的Promise。这通常是由于下载应用程序的javascript包时出现网络错误造成的。Single-spa将在用户从当前路由导航并返回后重试加载应用。
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN"; // 应用在加载、初始化、挂载或卸载过程中抛出错误,由于行为不当而被跳过,因此被隔离。其他应用将正常运行。

01|src/single-spa.js 入口文件

我们先来看single-spa给我们暴露了哪些属性和方法

export { start } from "./start.js"; // 启动的方法
export { ensureJQuerySupport } from "./jquery-support.js"; // 确保jquery支持,可以外部传入
export {
  setBootstrapMaxTime, // 全局配置初始化超时时间。
  setMountMaxTime, // 全局配置挂载超时时间。
  setUnmountMaxTime, // 全局配置卸载超时时间
  setUnloadMaxTime, // 全局配置移除超时时间。
} from "./applications/timeouts.js";
export {
  registerApplication, // 注册子应用的方法
  unregisterApplication, // 卸载子应用
  getMountedApps, // 返回当前已经挂载的子应用的名称
  getAppStatus, // 参数:注册应用的名字,返回:应用的状态
  unloadApplication, // 移除已注册的应用
  checkActivityFunctions, // 将会调用每个应用的 mockWindowLocation 并且返回一个根据当前路判断那些应用应该被挂载的列表。
  getAppNames, // 获取应用的名称(任何状态)
  pathToActiveWhen, // 判断应用的前缀url,返回:boolean
} from "./applications/apps.js";
export { navigateToUrl } from "./navigation/navigation-events.js"; // 实现在不同注册应用之前的切换
export { triggerAppChange } from "./navigation/reroute.js"; // 返回一个Promise对象,当所有应用挂载/卸载时它执行 resolve/reject 方法,它一般被用来测试single-spa,在生产环境可能不需要。
export {
  addErrorHandler, // 添加异常处理,抛出错误
  removeErrorHandler, // 删除给定的错误处理程序函数
} from "./applications/app-errors.js";
export { mountRootParcel } from "./parcels/mount-parcel.js"; // 将会创建并挂载一个 single-spa parcel.

// 应用的状态,已备注到app.helpers.js中
export {
  NOT_LOADED,
  LOADING_SOURCE_CODE,
  NOT_BOOTSTRAPPED,
  BOOTSTRAPPING,
  NOT_MOUNTED,
  MOUNTING,
  UPDATING,
  LOAD_ERROR,
  MOUNTED,
  UNMOUNTING,
  SKIP_BECAUSE_BROKEN,
} from "./applications/app.helpers.js";

import devtools from "./devtools/devtools"; // 暴露的方法集合
import { isInBrowser } from "./utils/runtime-environment.js"; // 判断浏览器环境

// 暴露的方法集合
// window.__SINGLE_SPA_DEVTOOLS__  single-spa在window中挂载的变量
if (isInBrowser && window.__SINGLE_SPA_DEVTOOLS__) {
  window.__SINGLE_SPA_DEVTOOLS__.exposedMethods = devtools;
}

上面导出的和挂载到window上的都是我们可以在开发阶段获取到的
single-spa官网api解析

02|注册子应用 registerApplication ——src/applications/apps.js

/**
 * 
 * @param {*} appNameOrConfig 子应用的名称
 * @param {*} appOrLoadApp 应用的加载方法,返回一个应用或者promise
 * @param {*} activeWhen 纯函数,返回应用是否激活的boolean
 * @param {*} customProps 传递给子应用的props
 * 每注册一个子应用 registerApplication方 法就需要调用一次
 */
export function registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  // 格式化注册子应用的参数
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );
  // 子应用注册的防重复校验
  if (getAppNames().indexOf(registration.name) !== -1)
    throw Error(
      formatErrorMessage(
        21,
        __DEV__ &&
          `There is already an app registered with name ${registration.name}`,
        registration.name
      )
    );

  // 将各个应用的配置信息存储到apps数组中
  apps.push(
    assign(
      {
        loadErrorTime: null,
        status: NOT_LOADED,
        parcels: {},
        devtools: {
          overlays: {
            options: {},
            selectors: [],
          },
        },
      },
      registration
    )
  );
  // 浏览器环境运行
  if (isInBrowser) {
    ensureJQuerySupport();
    reroute();
  }
}

这里注意最后调用的方法reroute()后面会说到

能看出来注册方法做的事情不多,就是对接受的参数做一个格式化校验,然后将各个应用的配置信息存储到apps数组中,最后执行reroute()方法。

文件里面的其他方法及属性在第一节总览里面有介绍,在具体的可以去代码仓库看详细的,源码分析这一块只摘了大流程相关的

03|启动应用start()——src/start.js

在start被调用之前,应用先被下载,但不会初始化/挂载/卸载。

/**
 * reroute // reroute在整个single-spa就是负责改变app.status和执行在子应用中注册的生命周期函数。
 * formatErrorMessage 格式化异常信息
 * setUrlRerouteOnly // 路由的变化,应用是否从定向
 * isInBrowser 是否是浏览器环境
 */
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;

// 开启的方法
/**
 * 必须在你single spa的配置中调用!在调用 start 之前, 应用会被加载, 但不会初始化,挂载或卸载。 
 * start 的原因是让你更好的控制你单页应用的性能。
 * 举个栗子,你想立即声明已经注册过的应用(开始下载那些激活应用的代码),
 * 但是实际上直到初始化AJAX(或许去获取用户的登录信息)请求完成之前不会挂载它们 。 
 * 在这个例子里,立马调用 registerApplication 方法,完成AJAX后再去调用 start方法会获得最佳性能。
 * 
 * @param {*}  opts 属性对象,可选 示例: {urlRerouteOnly: true}
 * urlRerouteOnly:默认为false的布尔值。如果设置为true,
 * 对history.pushState()和history.replaceState()的调用将不会触发单个spa重新定向路由,
 * 除非客户端路由已更改。在某些情况下,将此设置为true可以提高性能。有关更多信息,请阅读https://github.com/single-spa/single-spa/issues/484。
 */
export function start(opts) {
  started = true;
  if (opts && opts.urlRerouteOnly) {
    setUrlRerouteOnly(opts.urlRerouteOnly);
  }
  if (isInBrowser) {
    reroute();
  }
}

// 返回应用是否启动的boolean值
export function isStarted() {
  return started;
}

// 在浏览器环境中
if (isInBrowser) {
  setTimeout(() => {
    // 如果应用注册了,没有调用start方法,抛出异常,“single-spa应用加载5000后尚未调用start方法。。。。”
    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);
}

这里重点只看start()方法:更改应用启动的标志之后,也调用了reroute()方法

04|以reroute()为切入点——src/navigation/reroute.js

reroute在整个single-spa就是负责改变app.status和执行在子应用中注册的生命周期函数。

export function reroute(pendingPromises = [], eventArguments) {
  // ....省略展示

  if (isStarted()) {
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    return performAppChanges();
  } else {
    appsThatChanged = appsToLoad;
    return loadApps();
  }

  // .... 省略展示
}

在调用start方法之前会执行loadApps()方法, 调用start方法之后执行performAppChanges()方法

1、调用start之前的逻辑

  • 调用start之前也就是注册应用时触发的rerote方法
import { toLoadPromise } from "../lifecycles/load.js";
export function reroute(pendingPromises = [], eventArguments) {
  // ....省略展示

  const {
    appsToUnload, // 需要移除
    appsToUnmount, // 需要卸载
    appsToLoad, // 需要加载
    appsToMount, // 需要挂载
  } = getAppChanges(); // 得到各个状态的应用
  
  return 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;
          })
      );
    });
  }
}
  • getAppChanges()方法
// 将应用按照状态拆分
export function getAppChanges() {
  const appsToUnload = [], // 需要移除的
    appsToUnmount = [], // 需要卸载的
    appsToLoad = [], // 需要加载的
    appsToMount = []; // 需要挂载的

  // We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
  // 超时200毫秒后,重新尝试在LOAD_ERROR中下载应用程序
  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:
      case LOADING_SOURCE_CODE:
        if (appShouldBeActive) {
          appsToLoad.push(app);
        }
        break;
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED: // 挂载结束
        if (!appShouldBeActive && getAppUnloadInfo(toName(app))) {
          appsToUnload.push(app);
        } else if (appShouldBeActive) {
          appsToMount.push(app);
        }
        break;
      case MOUNTED:
        if (!appShouldBeActive) {
          appsToUnmount.push(app);
        }
        break;
      // all other statuses are ignored
    }
  });

  return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
  • toLoadPromise
// 应用代码正在被拉取的生命周期
/**
 *  通过微任务加载子应用,最终是return了一个promise出行,在注册了加载子应用的微任务.
 *  更改app.status为LOAD_SOURCE_CODE => NOT_BOOTSTRAP,当然还有可能是LOAD_ERROR
 *  执行加载函数,并将props传递给加载函数,给用户处理props的一个机会,因为这个props是一个完备的props
 *  验证加载函数的执行结果,必须为promise,且加载函数内部必须return一个对象
 *  这个对象是子应用的,对象中必须包括各个必须的生命周期函数
 *  然后将生命周期方法通过一个函数包裹并挂载到app对象上
 *  app加载完成,删除app.loadPromise
 * @param {*} app 
 */
export function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    if (app.loadPromise) {
      // app已经在被加载
      return app.loadPromise;
    }

    // 状态为NOT_LOADED和LOAD_ERROR的app才可以被加载
    if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
      return app;
    }

    app.status = LOADING_SOURCE_CODE;

    let appOpts, isUserErr;

    return (app.loadPromise = Promise.resolve()
      .then(() => {
        // 执行app的加载函数,并给子应用传递props => 用户自定义的customProps和内置的比如应用的名称、singleSpa实例
        const loadPromise = app.loadApp(getProps(app));
        if (!smellsLikeAPromise(loadPromise)) {
          // The name of the app will be prepended to this error message inside of the handleAppError function
          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)
            )
          );
        }
        return loadPromise.then((val) => {
          app.loadErrorTime = null;

          // window.singleSpa
          appOpts = val;

          let validationErrMessage, validationErrCode;

          if (typeof appOpts !== "object") {
            validationErrCode = 34;
            if (__DEV__) {
              validationErrMessage = `does not export anything`;
            }
          }

          // 必须导出bootstrap生命周期函数 
          if (
            // ES Modules don't have the Object prototype
            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`;
            }
          }

          // 必须导出mount生命周期函数 
          if (!validLifecycleFn(appOpts.mount)) {
            validationErrCode = 36;
            if (__DEV__) {
              validationErrMessage = `does not export a mount function or array of functions`;
            }
          }

          // 必须导出unmount生命周期函数 
          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.status = NOT_BOOTSTRAPPED;
          // 在app对象上挂载生命周期方法,每个方法都接收一个props作为参数,方法内部执行子应用导出的生命周期函数,并确保生命周期函数返回一个promise
          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);

          delete app.loadPromise;

          return app;
        });
      })
      .catch((err) => {
        delete app.loadPromise;

        let newStatus;
        if (isUserErr) {
          newStatus = SKIP_BECAUSE_BROKEN;
        } else {
          newStatus = LOAD_ERROR;
          app.loadErrorTime = new Date().getTime();
        }
        handleAppError(err, app, newStatus);

        return app;
      }));
  });
}

2、调用start之后

  • reroute
import { toLoadPromise } from "../lifecycles/load.js";
export function reroute(pendingPromises = [], eventArguments) {
  // ....省略展示

  const {
    appsToUnload, // 需要移除
    appsToUnmount, // 需要卸载
    appsToLoad, // 需要加载
    appsToMount, // 需要挂载
  } = getAppChanges(); // 得到各个状态的应用
  
  appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
  return performAppChanges();
}
  • 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;
      }

      // 移除应用 => 更改应用状态,执行unload生命周期函数,执行一些清理动作
      // 其实一般情况下这里没有真的移除应用
      const unloadPromises = appsToUnload.map(toUnloadPromise);
      // 卸载应用,更改状态,执行unmount生命周期函数
      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
       * 这个原因其实是因为这些操作都是通过注册不同的微任务实现的,而JS是单线程执行,
       * 所以自然后续的只能等待前面的执行完了才能执行
       * 这里一般情况下其实不会执行,只有手动执行了unloadApplication方法才会二次加载
       */
      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);
        });
    });
  }

05|监听路由变化

其实说了这么多,到底是在哪里监听的路由变化呢,看这个文件src/navigation/navigation-events.js

if (isInBrowser) {
  // We will trigger an app change for any routing events.
  // 在浏览器环境对 hashchange 和 popstate的触发做一个监听
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

  // Monkeypatch addEventListener so that we can ensure correct timing
  const originalAddEventListener = window.addEventListener;
  const originalRemoveEventListener = window.removeEventListener;
  // 监听事件触发的时候,对触发的事件做一个收集
  window.addEventListener = function (eventName, fn) {
    if (typeof fn === "function") {
      if (
        routingEventsListeningTo.indexOf(eventName) >= 0 &&
        !find(capturedEventListeners[eventName], (listener) => listener === fn)
      ) {
        capturedEventListeners[eventName].push(fn);
        return;
      }
    }

    return originalAddEventListener.apply(this, arguments);
  };

  // 移除事件响应的对收集的事件做删除
  window.removeEventListener = function (eventName, listenerFn) {
    if (typeof listenerFn === "function") {
      if (routingEventsListeningTo.indexOf(eventName) >= 0) {
        capturedEventListeners[eventName] = capturedEventListeners[
          eventName
        ].filter((fn) => fn !== listenerFn);
        return;
      }
    }

    return originalRemoveEventListener.apply(this, arguments);
  };

  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    window.history.replaceState,
    "replaceState"
  );

  if (window.singleSpaNavigate) {
    console.warn(
      formatErrorMessage(
        41,
        __DEV__ &&
          "single-spa has been loaded twice on the page. This can result in unexpected behavior."
      )
    );
  } else {
    /* For convenience in `onclick` attributes, we expose a global function for navigating to
     * whatever an  tag's href is.
     */
    window.singleSpaNavigate = navigateToUrl;
  }
}

这段代码不是放在方法里面导出调用的,而是直接这样写,是什么意思呢

文件通过引入建立依赖关系,在最后打包输出为bundle文件时,这段代码是存在全局作用域的,所用当引入single-spa的时候这些会自动执行

在使用 window.history 时,如果执行 pushState(repalceState) 方法,是不会触发 popstate 事件的,而 single-spa 通过一种巧妙的方式,实现了执行 pushState(replaceState) 方法可触发 popstate 事件

/**
 * 因为上面只对hashChange和popState事件做了监听,所以当触发replaceState和pushState方法时,对其进行一个增强,保证其内部逻辑不变的同时,执行自定义事件
 * @param {*} updateState | 浏览器的replaceState和pushState方法触发
 * @param {*} methodName | 字符串 ‘replaceState‘ || 'pushState'
 * @returns 
 */
function patchedUpdateState(updateState, methodName) {
  return function () {
    // 跳转之前的url
    const urlBefore = window.location.href;
    // 劫持使用传入的updateState方法,保证原来的功能不失效
    const result = updateState.apply(this, arguments);
    const urlAfter = window.location.href;

    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      if (isStarted()) {
        // fire an artificial popstate event once single-spa is started,
        // so that single-spa applications know about routing that
        // occurs in a different application

        // 如过开启了start方法,则不会调用reroute方法
        // window.dispatchEvent 触发自定义事件,
        window.dispatchEvent(
          // 创建自定义事件
          createPopStateEvent(window.history.state, methodName)
        );
      } else {
        // do not fire an artificial popstate event before single-spa is started,
        // since no single-spa applications need to know about routing events
        // outside of their own router.
        reroute([]);
      }
    }

    return result;
  };
}

/**
 * 创建自定义事件
 * @param {*} state window.history.state
 * @param {*} originalMethodName  方法名 replaceState || pushState
 * @returns 
 */
function createPopStateEvent(state, originalMethodName) {
  // https://github.com/single-spa/single-spa/issues/224 and https://github.com/single-spa/single-spa-angular/issues/49
  // We need a popstate event even though the browser doesn't do one by default when you call replaceState, so that
  // all the applications can reroute. We explicitly identify this extraneous event by setting singleSpa=true and
  // singleSpaTrigger= on the event instance.
  let evt;
  try {
    // 创建 popstate 自定义事件,当触发 replaceState || pushState 时, 监听popstate就能触发
    evt = new PopStateEvent("popstate", { state });
  } catch (err) {
    // IE 11 compatibility https://github.com/single-spa/single-spa/issues/299
    // https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-html5e/bd560f47-b349-4d2c-baa8-f1560fb489dd
    evt = document.createEvent("PopStateEvent");
    evt.initPopStateEvent("popstate", false, false, state);
  }
  evt.singleSpa = true;
  evt.singleSpaTrigger = originalMethodName;
  return evt;
}

之所以能在执行 pushState、replaceState 方法时,触发 popstate 事件,是因为 single-spa在这里 重写了 window.history 的 pushState 和 replaceState 方法。在执行 pushState、replaceState 方法时,会通过原生方法 – PopStateEvent 构建一个事件对象,然后调用 window.dispatchEvent 方法,手动触发 popState 事件。

06|流程梳理

当我们启动应用时,会调用registerApplication注册子应用和start开启应用,这两个方法内部都调用了reroute函数

  • 其中registerApplication注册子应用,对应用的信息进行配置包裹到apps中
  • start方法执行时通过urlRerouteOnly判断是否要监听url路由变化,然后调用reroute方法
  • 与此同时全局对浏览器的hashchange 和 popstate的触发做一个监听,并通过createPopStateEvent自定义popstate事件的方式对replaceState和pushState进行重写。所以我们通过history.replaceState或者history.pushState本质上还是触发了我们监听的popstate事件,从而触发reroute。
  • reroute方法内部调用getAppChanges,该方法会遍历apps应用数组,根据shouldBeActive方法判断window.location匹配的app激活规则判断子应用是已激活,返回不同状态的应用
  • 然后reroute方法根据started变量的状态走了两个分支,如果started是未开启状态会调用loadApps函数执行app.loadApp来实际加载子应用。再调用callAllEventListeners遍历执行路由收集的函数
  • 如果started是开启状态则调用performAppChanges方法先卸载需要卸载的应用,再执行appsToLoad、appsToMount加载启动挂载应用,期间子应用的生命周期函数会挂载到app配置对象的属性上,在指定的情况下执行

关注的点 Q&A

1、single-spa 是如何工作的

single-spa 有两种使用模式:application 和 parcel

  • application

    application 模式下,先通过 registerApplication 注册子应用,然后在基座应用挂载完成以后执行 start 方法, 这样基座应用就可以根据 url 的变化来进行子应用切换,激活对应的子应用。

  • parcel
    取到组件的生命周期方法,然后通过 mountRootParcel 方法直接挂载。

    mountRootParcel 方法会返回一个 parcel 实例对象,内部包含 update、unmount 方法。当我们需要更新组件时,直接调用 parcel 对象的 update 方法,就可以触发组件的 update 生命周期方法;当我们需要卸载组件时,直接调用 parcel 对象的 unmount 方法。
    在执行 mountRootParcel 方法时,传入的第二个参数,会作为组件 mount 生命周期方法的入参;在执行 parcel.update 方法时,传入的参数,会作为组件 update 生命周期方法的入参。

2、如何通信

父组件 —— parcel

父组件通过props透传

具体的前面也有简单提到:在执行 mountRootParcel 方法时,传入的第二个参数,会作为组件 mount 生命周期方法的入参;在执行 parcel.update 方法时,传入的参数,会作为组件 update 生命周期方法的入参。
就像平时开发组件:子组件回调伏组件某个方法这种方式,我们在父组件定一个方法传给parcel组件,parcel组件就可以在需要的时候执行这个方法通知父组件更新

parcel组件之间的通信

这种其实也是 parcel 组件和父组件之间的通信。 parcel 组件可以通过父组件传递的方法,触发父组件的更新,父组件更新以后,在触发另一个parcel 组件的更新。

基座应用和子应用的通信

在基座应用注册子应用的时候,可以给每个子应用定义一个customProps,这个会作为mount方法的入参数,里面也可以包裹回调的方法,当子应用需要通知基座应用更新时,可以执行这个方法

子应用的通信

也是基于和基座应用通信的这种方式

3、为什么子应用导出的生命周期函数都是一个promise

子应用使用

export function mount(props) {
    return Promise.resolve().then(() => {
        // 子应用/组件具体的挂载逻辑
        ...
    })
}

single-spa——src/lifecycles/mount.js中执行逻辑

// 应用挂载完的生命周期
export function toMountPromise(appOrParcel, hardFail) {
  return Promise.resolve().then(() => {
    if (appOrParcel.status !== NOT_MOUNTED) {
      return appOrParcel;
    }

    // single-spa其实在不同的阶段提供了相应的自定义事件,让用户可以做一些事情
    if (!beforeFirstMountFired) {
      window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
      beforeFirstMountFired = true;
    }

    // 执行子应用的生命周期方法
    return reasonableTime(appOrParcel, "mount")
      .then(() => {
        appOrParcel.status = MOUNTED;

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

        return appOrParcel;
      })
      .catch((err) => {
        // If we fail to mount the appOrParcel, we should attempt to unmount it before putting in SKIP_BECAUSE_BROKEN
        // We temporarily put the appOrParcel into MOUNTED status so that toUnmountPromise actually attempts to unmount it
        // instead of just doing a no-op.
        appOrParcel.status = MOUNTED;
        return toUnmountPromise(appOrParcel, true).then(
          setSkipBecauseBroken,
          setSkipBecauseBroken
        );

        function setSkipBecauseBroken() {
          if (!hardFail) {
            handleAppError(err, appOrParcel, SKIP_BECAUSE_BROKEN);
            return appOrParcel;
          } else {
            throw transformErr(err, appOrParcel, SKIP_BECAUSE_BROKEN);
          }
        }
      });
  });
}

4、single-spa 生命周期 hooks

single-spa 定义了一些生命周期 hooks,可以帮助我们在子应用/组件生命周期中执行自定义操作,这些 hooks 包括:

  • single-spa:before-first-mount:第一次挂载子应用/组件之前触发,之后就不会再触发
  • single-spa:first-mount:第一次挂载子应用/组件之后触发,之后就不会再触发
  • single-spa:before-no-app-change:application 模式下,修改 url 会触发子应用的切换。如果路由注册表中没有匹配当前 url 的子应用,那么 single-spa:before-no-app-change 事件会触发
  • single-spa:before-app-change:修改 url 导致子应用切换时,如果路由注册表中有匹配当前 url 的子应用, single-spa:before-app-change 事件会触发。
  • single-spa:before-routing-event:application 模式下, hashchange、popstate 触发以后,single-spa:before-routing-event 事件就会触发。
  • single-spa:before-mount-routing-event:application 模式下, 旧的子应用卸载完成之后,新的子应用挂载之前触发。
  • single-spa:no-app-change:application 模式下,执行performAppChanges方法里面,在single-spa:before-app-change触发以后触发
  • single-spa:app-change:application 模式下,执行performAppChanges方法里面,在single-spa:before-app-change触发以后触发
  • single-spa:routing-event:application 模式下, single-spa:app-change / single-spa:no-app-change 触发以后, single-spa:routing-event 触发。

例如:single-spa——src/lifecycles/mount.js

// single-spa其实在不同的阶段提供了相应的自定义事件,让用户可以做一些事情
    if (!beforeFirstMountFired) {
      window.dispatchEvent(new CustomEvent("single-spa:before-first-mount"));
      beforeFirstMountFired = true;
    }

这样我们可以自定义使用

window.addEventListener('single-spa:before-first-mount', event => {...})

不足

  • single-spa 采用 JS Entry 的方式接入微应用,对微应用的入侵太强
    ○ 微应用路由改造,添加一个特定的前缀
    ○ 微应用入口改造,挂载点变更和生命周期函数导出
    ○ 打包工具配置更改
  • 通信问题
    通过注册微应用时给微应用注入一些状态信息,剩下的只能用户自己去实现,实现方式上面也有提到几种通信方式
  • 资源预加载
    single-spa会将微应用打包成一个js文件
  • js隔离
    js全局对象污染的问题
  • 样式隔离问题
    只能通过约定命名的方式去做规范实现

结尾

single-spa是一个很好的微前端基础框架,阿里的qiankun就是基于single-spa实现的,在它的基础上做了一层封装和解决了一些缺陷。接下来会去学习下qiankun的源码。

你可能感兴趣的:(qiankun微前端实战,前端,微前端,single-spa)