• 微前端——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调试

    在这里插入图片描述

    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    13、src/lifecycles

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

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

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

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

    • 先介绍一下utils的工具函数,好多地方会用到
      在这里插入图片描述

    • 应用的状态常量

    // 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"; // 应用在加载、初始化、挂载或卸载过程中抛出错误,由于行为不当而被跳过,因此被隔离。其他应用将正常运行。
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49

    上面导出的和挂载到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();
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55

    这里注意最后调用的方法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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57

    这里重点只看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();
      }
    
      // .... 省略展示
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    在调用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;
              })
          );
        });
      }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 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 };
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 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;
          }));
      });
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    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();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 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);
            });
        });
      }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96

    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63

    之所以能在执行 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(() => {
            // 子应用/组件具体的挂载逻辑
            ...
        })
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    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);
              }
            }
          });
      });
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    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;
        }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这样我们可以自定义使用

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

    不足

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

    结尾

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

  • 相关阅读:
    FFmpeg入门详解之8:YUV Player简介
    word办公小技巧:方框打勾、上下标、横隔线、排序
    【Python】os包的使用教程详解
    [java]深度剖析自动装箱与拆箱
    ​Base64编码知识详解 ​
    【外卖项目实战开发四】
    vue3 - ref和reactive的区别
    GitModel|Task04|随机模拟
    ubuntu深度学习配置
    41-面向对象编程(中级部分)-2
  • 原文地址:https://blog.csdn.net/qq_41534913/article/details/127963038