微前端08 : single-spa中的reroute函数

1,236 阅读12分钟

推荐阅读

Vue3源码

微前端源码

React18源码

/******************************************************************/
/*****************     欢迎关注微信公众号:杨艺韬     *****************/
/******************************************************************/

前面在微前端07 : 对single-spa的路由管理及微应用状态管理的分析提到过,reroute函数非常重要,因为无论是注册应用还是在popstatehashchange事件被触发,都会调用这个函数。事实上,single-spa对微应用进行加载启动挂载卸载的时候,都主要是在这个函数中执行的相关逻辑。本文将会带着大家走进reroute函数,从源码层面理解single-spa是如何对微应用进行管理的。写完本文,对微前端的相关分析就暂时告一段落,至于对市面上其他主流微前端框架的分析,以及实现一个生产环境可用的微前端框架,在我完成Vue3React18Webpack5RollupVite等目前市面上的核心框架和工具的源码进行深入的分析后,再一步步带着大家实现一个生产环境可用的微前端框架。之所以这样安排,是因为如果选择使用微前端,实际上选择的一套对应的技术方案,我们在把一些主流核心框架和工具的源码进入深入分析后,后续编写相关配套的基础设施,大家才能更彻底的理解为什么代码要这样写,而不是那样写,知其然还知其所以然。这也是我做源码探究的愿望,帮助大家不再畏惧源码,而是将这些内容融入到血液中,提升工作效率和学习效率,将更多时间投入到有价值的事情上去。无论如何,源码探究是高阶开发者锤炼基本功不可逾越的门槛。好了,现在就正式进入到reroute函数中去吧。

在开始本文之前,我们将微前端07 : 对single-spa的路由管理及微应用状态管理的分析中的微应用状态切换流程图放到这里,方便大家阅读本文的时候进行回顾: single-spa的状态切换.jpg

reroute函数的核心逻辑

我们先看该函数的代码:

// 代码片段1
export function reroute(pendingPromises = [], eventArguments) {
  // 此处省略许多代码...
  const {
    appsToUnload,
    appsToUnmount,
    appsToLoad,
    appsToMount,
  } = getAppChanges();
  // 此处省略许多代码...
  if (isStarted()) {
    // 此处省略一些代码...
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    return performAppChanges();
  } else {
    appsThatChanged = appsToLoad;
    return loadApps();
  }
  // 此处省略许多代码...
}

该函数原本有将近300行代码,我们在这里对其进行了大量精简。从代码片段1中可以看出,该函数主要完成了两项工作。是通过函数getAppChanges获取在single-spa注册过的微应用,并用四个数组变量来区分这些微应用下一步将会做什么处理以及进入什么状态。是根据isStarted()的返回值进行判断,如果已经调用过single-spa暴露的start函数,则调用performAppChanged函数根据getAppChanges函数的返回值对微应用进行相应的处理,并改变相应的状态。如果微调用过start函数,则调用loadApp函数执行加载操作,关于loadApp我们已经在微前端06 : single-spa的注册机制一文进行讨论过,本文不再赘述。下面对getAppChangesperformAppChanges分别进行介绍。

getAppChanges

请先看getAppChanges函数的代码:

// 代码片段2
export function getAppChanges() {
  const appsToUnload = [],
    appsToUnmount = [],
    appsToLoad = [],
    appsToMount = [];
  // 此处省略一些代码...
  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;
    }
  });

  return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}

从代码片段2中可以看出该函数的逻辑其实很简单。定义4个数组,然后根据微应用当前所处的不同状态,推断出函数即将要进入的状态,并把即将要进入同一个状态的微应用放到一个相同的数组中。关于微应用的状态变化,可以参考本文开始处的流程图。下面对代码中关于向数组中添加相应微应用的逻辑进行简要介绍。

数组appsToLoad

我们发现处于NOT_LOADEDLOADING_SOURCE_CODE状态的微应用被放入了数组appsToLoad中,事实上appsToLoad数组中存放的微应用在后续的逻辑中即将被加载,且在加载过程中,状态会变化为LOADING_SOURCE_CODE,加载完成后,状态会变化为NOT_BOOTSTRAPPED,也就是说之前未被加载完成的微应用会在此进行加载。大家会不会觉得很奇怪,这不是在浪费资源吗,不过不用担心,因为在执行加载的函数中有这样的代码:

// 代码片段3
export function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    if (app.loadPromise) {
      return app.loadPromise;
    }
    if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
      return app;
    }
    // ...
    return (app.loadPromise = Promise.resolve()
      .then(() => {
        // ...
        delete app.loadPromise;
        // ...
      })
      .catch((err) => {
        delete app.loadPromise;
        // ...
      }));
  });
}

也就是,代码中利用app.loadPromise做了缓存,并不会重复加载。后文会有小节详细讲解函数toLoadPromise,此处提及指示解答一些朋友们初次看代码时候心中可能的疑惑。

数组appsToUnload

我们从代码片段2中还发现,处于NOT_BOOTSTRAPPEDNOT_MOUNTED状态的微应用,如果不需要于激活状态且getAppUnloadInfo(toName(app))返回值为true,则该微应用加入到数组appsToUnload中。这里的getAppUnloadInfo函数代码如下:

// 代码片段4
export function getAppUnloadInfo(appName) {
  return appsToUnload[appName];
}

对象appsToUnload

请朋友们注意,函数getAppUnloadInfo中的appsToUnload是一个全局对象,不是函数getAppChanges中的appsToUnload数组。函数getAppUnloadInfo返回为true,则说明用户手动调用过函数unloadApplication,因为函数getAppUnloadInfo中的对象appsToUnload只会在unloadApplication的执行过程中被改变。

unloadApplication

下面是关于unloadApplication函数在single-spa文档上的官方说明:

The purpose of unloading a registered application is to set it back to a NOT_LOADED status, which means that it will be re-bootstrapped the next time it needs to mount. The main use-case for this was to allow for the hot-reloading of entire registered applications, but unloadApplication can be useful whenever you want to re-bootstrap your application.

文档中的内容,可以简单理解为,如果希望执重新执行微应用的生命周期函数bootstrap,那调用unloadApplicaton函数是一个不错的选择。其实到这里我们也可以发现,一般情况下,我们是不会轻易卸载微应用的,也就是说,流程图中MOUNTED->UNMOUNTING->UNLOADING->UNLOADED这个状态转换流程,如果不是用户手动干预,调用unloadApplicaton,是不会发生的。

toUnloadPromise

实际上,数组appsToUnload中的微应用即将被执行的主要逻辑都在函数toUnloadPromise中,请看代码:

// 代码片段5
export function toUnloadPromise(app) {
  return Promise.resolve().then(() => {
    const unloadInfo = appsToUnload[toName(app)];
    // 对象appsToUnload没有值,说明没有调用过unloadApplicaton函数,没必要继续
    if (!unloadInfo) {
      return app;
    }
    // 说明已经处于NOT_LOADED状态
    if (app.status === NOT_LOADED) {
      finishUnloadingApp(app, unloadInfo);
      return app;
    }
    // 已经在卸载中的状态,等执行结果就可以了,注意这里的promise是从对象appsToUnload上面取的
    if (app.status === UNLOADING) {
      return unloadInfo.promise.then(() => app);
    }
    // 应用的状态转换应该符合流程图所示,只有处于UNMOUNTED状态下的微应用才可以有->UNLOADING->UNLOADED的转化
    if (app.status !== NOT_MOUNTED && app.status !== LOAD_ERROR) {
      return app;
    }

    const unloadPromise =
      app.status === LOAD_ERROR
        ? Promise.resolve()
        : reasonableTime(app, "unload");

    app.status = UNLOADING;

    return unloadPromise
      .then(() => {
        finishUnloadingApp(app, unloadInfo);
        return app;
      })
      .catch((err) => {
        errorUnloadingApp(app, unloadInfo, err);
        return app;
      });
  });
}

函数toUnloadPromise中可以认为主要做了三件事:是不能符合执行条件的情况进行拦截,拦截的相关原因已经写到代码片段5的注释中;是利用reasonableTime函数真正的执行卸载的相关逻辑;是执行函数finishUnloadingApperrorUnloadingApp变更微应用的状态。变更状态的逻辑相对简单,不在本文赘述,下面分析函数reasonableTime中的源码实现。

reasonableTime

// 代码片段6
export function reasonableTime(appOrParcel, lifecycle) {
  // 此处省略许多代码...
  return new Promise((resolve, reject) => {
    // 此处省略许多代码...
    appOrParcel[lifecycle](getProps(appOrParcel))
      .then((val) => {
        finished = true;
        resolve(val);
      })
      .catch((val) => {
        finished = true;
        reject(val);
      });
    // 此处省略许多代码...
  });
}

该函数可以理解为做了三件事情:是做了超时处理,我在代码片段6中省略了相关内容;是执行微应用的lifecycle变量对应的函数,就当前分析toUnloadPromise函数的场景下,这里的lifecycle对应的unload属性对应的函数。这unload属性是在函数toLoadPromise中添加的,也就是说在加载阶段让微应用具备了卸载的能力。事实上,这个unload函数是加载微应用完成后,从微应用暴露的对象中获取的。

数组appsToMount、appsToUnmount、appsToMount

从上文可知,代码片段2中,处于NOT_BOOTSTRAPPEDNOT_MOUNTED状态的微应用,如果并路由规则匹配,则该微应用将会被添加到数组appsToMount中。至于数组appsToUnmount,其分析过程和后续的执行流程和appsToUnload中的微应用的执行流程有很多的相似之处,本文不再赘述。

performAppChanges

从上文的分析中,我们知道了各个微应用所处的状态,以及接下来会执行什么样的逻辑。但这些对这些微应用进行处理的时候,有什么样的先后顺序呢,请大家进入performAppChange函数中。

核心逻辑

请先看函数performAppChanges的核心逻辑:

// 代码片段7
function performAppChanges() {
    return Promise.resolve().then(() => {
      // 此处省略许多代码...
      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(() => {
        // 此处省略许多代码...
      });
      // 此处省略许多代码...
      return unmountAllPromise
        .catch((err) => {
          callAllEventListeners();
          throw err;
        })
        .then(() => {
          callAllEventListeners();
          // 此处省略许多代码...
        });
    });
  }

省略的代码也很重要,主要是自定义事件的派发,因为过于冗长影响阅读体验先在代码片段7中省略了。总体来看该函数主要做了三件事情:是执行卸载逻辑;是执行完卸载逻辑后,再执行相关挂载逻辑;是在不同阶段派发自定义事件。关于卸载、挂载的实现逻辑和上文分析过的toUnloadPromise很相似,故不在本文赘述。下文主要讲一讲里面的callAllEventLiseners函数和single-spa中的自定义事件。

callAllEventListeners

还记得我们在微前端07 : 对single-spa的路由管理及微应用状态管理的分析提到过,将注册的hashchange、popstate事件保存在数组中了,并未调用原始的监听事件注册逻辑,performAppChanges中调用callAllEventListeners的位置就是触发这些保存在数组中的事件的合适时机。因为该函数的调用,处于所有需要卸载的微应用彻底卸载完成的位置或者注册应用时候应用加载完成。实际上这里面有一个隐藏的逻辑在里面,默认情况下触发hashchange、popstate事件马上就会执行reroute函数,换句话说hashchange、popstate事件触发后,先执行reroute函数对微应用进行处理,当卸载了该卸载的应用才批量执行后续注册的hashchange、popstate事件。事实上,这里的卸载并不一定真的卸载,因为可能路由的变化并不需要切换微应用。这样当前微应用注册的路由事件就可以触发。如果当前微应用需要被切换,就触发注册的微应用就相当于清空保存在数组中的事件。同理,在注册微应用的时候,微应用加载完成后,也应该触发保存到数组中的监听事件。

自定义事件

代码片段7中省略了很多形如下面所示的事件派发自定义事件的相关逻辑。

window.dispatchEvent(
    new CustomEvent(
        appsThatChanged.length === 0
        ? "single-spa:before-no-app-change"
        : "single-spa:before-app-change",
        getCustomEventDetail(true)
    )
);

我不准备详细对每一个事件进行介绍,这里提出来,是因为我们在乾坤中有个apirunAfterFirstMounted,实际上就是监听single-spa提供的自定义事件,事件触发后,再执行runAfterFirstMounted传入的函数参数。single-spa中有7个自定义监听事件,朋友们可以查阅single-spa API文档,再去源码中看对应逻辑即可,就不在本文赘述了。

欢迎关注微信公众号:杨艺韬,可以获取最新动态。