【带着问题阅读源码】解析 SingleSpa 5.X 是如何管理微前端应用、处理路由的

680 阅读6分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」

前言

就目前来讲,谈及微前端不可避免的都会涉及到 single-spa 这个库,对于他在微前端中扮演的角色、所做的工作很多人却还是一知半解,本文想带你解开它的神秘面纱

阅读本文需要对 single-spa的基本使用有所了解,还没用过的同学可以阅读我之前的文章()[]

带着问题阅读

如果用一句话总结 single-spa 的作用,我认为应该是:

single-spa 为微前端提供了应用的生命周期管理

我一般都是带着问题去阅读源码 下面将围绕下面两个问题,对single-spa 进行解刨 这也是single-spa主要做的两件事情

  • 1,路由是怎么处理?
  • 2,应用的生命周期是怎么管理的?

路由是怎么处理?

在我们使用 registerApplication 注册应用时,第三个参数可以用于做子应用的路由匹配,自定义路由匹配规则,当路由发生变更的时候,singleSpa 会调用这个方法,根据window.location的变化对应用进行 mountunmount

这里就会有两个问题

1, 在我们主动调用 history.pustState 或者 history.replaceState 中是不会触发路由变化的事件 popstatehashchange的,那么 singleSpa 是怎么监听路由变化的?

2,我们知道popstatehashchange事件是可以被多个监听者监听的,但是在微前端的场景中,为了保证应用能正常加载避免和其他监听者的冲突, single-spa 的执行权应该是最高的,那么 single-spa 是怎么拿到第一个执行权的呢?

如何监听路由变化,确保第一执行权

前面说到,在我们主动调用 history.pustState 或者 history.replaceState 中是不会触发路由变化的事件 popstatehashchange

single-spa 的解决方案就是

1,对原生的 pushStatereplaceState 进行拦截,当他被调用的时候,主动调用 window.dispatchEvent 去触发事件 2,拦截 window.addEventListenerwindow.removeEventListenerpushStatereplaceState事件,自定义其触发时机

我们可以在源码中的src/navigation/navigation-events.js看到这个逻辑


if (isInBrowser){
    // 省略代码
    
    // 对原生方法进行拦截
     window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    window.history.replaceState,
    "replaceState"
  );
}

// 对原生的 window.history.pushState 和 window.history.replaceState 进行拦截
function patchedUpdateState(updateState, methodName) {
  return function () {
    const urlBefore = window.location.href;
    const result = updateState.apply(this, arguments);
    const urlAfter = window.location.href;

    if (!urlRerouteOnly || urlBefore !== urlAfter) {
      if (isStarted()) {
        // 手动发送一个 hashchange 、popstate 事件
        window.dispatchEvent(
          createPopStateEvent(window.history.state, methodName)
        );
      } else {

        reroute([]);
      }
    }

    return result;
  };
}
  window.addEventListener = function (eventName, fn) {  
    // 拦截 ["hashchange", "popstate"] 事件
    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);
  };

这样就确保了能触发路由变化事件,且 single-spa 是第一个处理路由的

在路由变化的时候, single-spa 会调用 rerouter 方法,这个很重要,应用的生命周期就在这个方法进行执行,我们这里只需先记住在 hashchangepopstate 事件中会触发 reroute方法即可

 function urlReroute() {
  reroute([], arguments);
}
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

应用的生命周期是怎么管理的?

registerApplication

我们先来看看registerApplication 具体做了啥, 很简单,就是把参数包装一下,然后创建一个状态为 NOT_LOADED 的应用,最后调用 reroute方法,除了之前说的 popstate 和 hashchange , 又多了一个调用 reroute的地方

export function registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen
  customProps
) {
  // 对传入的参数进行验证、包装处理
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );
  // 省略代码

  // 注册应用,应用状态为  NOT_LOADED
  apps.push(
    assign(
      {
        loadErrorTime: null,
        status: NOT_LOADED,
        parcels: {},
        devtools: {
          overlays: {
            options: {},
            selectors: [],
          },
        },
      },
      registration
    )
  );
  
  // 调用 reroute
  if (isInBrowser) {
    ensureJQuerySupport();
    reroute();
  }
}

除了 registerApplication ,我们在基座中必不可少的就是调用 这个方法也很简单,将 started设置为 true ,然后调用的reroute 方法 start方法start方法,

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

到目前为止,触发reroute一共有三种方式

  • start 方法
  • registerApplication 方法
  • hashchange、popstate 事件

除此之外 single-spa 还提供了一个主动触发的方法triggerAppChange

可见 reroute 多么重要

在这个方法中,管理了整个应用的生命周期,应用状态的流转

应用的状态

为了方便后续了解,我将应用的状态总结如下

应用状态描述
NOT_LOADED应用还没加载,默认状态
LOADING_SOURCE_CODE加载中,调用了注册应用时的第二个参数
NOT_BOOTSTRAPPED加载完成,但是还没用调用应用的 bootstrap 函数
BOOTSTRAPPING正在调用应用的 bootstrap 函数
NOT_MOUNTED调用 bootstrap 成功,还没调用 mount
MOUNTED应用的 mount 生命周期函数执行成功,应用已成功挂载
UNLOADING卸载没有进行过 mount 的应用,调用 unload 方法
UNMOUNTING调用应用 unmount 生命周期方法,调用成功后,状态变为 NOT_MOUNTED
SKIP_BECAUSE_BROKEN应用变更状态失败了,不会再进行下个状态的变更
LOAD_ERROR应用加载失败,在下次进行reroute 时,如果超过 200 毫秒,会重新加载状态失败

reroute 执行流程

下面我们分析下 reroute的执行过程

export function reroute(pendingPromises = [], eventArguments) {
  /**
   * 如果有应用正在处于状态变更的状态,将此次触发暂存起来。
   * performAppChanges 执行完后,这个变量为 false
   */
  if (appChangeUnderway) {
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }
  // appsToUnload: 当前状态为 NOT_BOOTSTRAPPED 或 NOT_MOUNTED, 且当前路由不匹配,就是应用还没 mount ,路由就已经发生了改变
  // appsToUnmount: 当前状态为 MOUNTED ,且路由不匹配
  // appsToLoad: 当前路由匹配,状态为 NOT_LOADED、 LOADING_SOURCE_CODE 、 LOAD_ERROR。如果是 LOAD_ERROR,需要满足距离上一次加载失败时间超过200毫秒
  // appsToMount: 当前状态为 NOT_BOOTSTRAPPED 或 NOT_MOUNTED,且当前路由匹配
  const {
    appsToUnload,
    appsToUnmount,
    appsToLoad,
    appsToMount,
  } = getAppChanges();

  let appsThatChanged,
    navigationIsCanceled = false,
    oldUrl = currentUrl,
    newUrl = (currentUrl = window.location.href);
  const stared = isStarted();
  if (stared) {
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    return performAppChanges();
  } else {
    appsThatChanged = appsToLoad;
    // 还没启动,去加载应用,这里不会执行挂载,只到 NOT_BOOTSTRAPPED 状态
    return loadApps();
  }
 // 省略代码

可以看到,reroute 有个队列机制,同一时间只会执行一次应用状态的变更,应用状态变量的具体逻辑在 performAppChanges



看看performAppChanges 方法做了啥

```js
  function performAppChanges() {
    return Promise.resolve().then(() => {
      // https://github.com/single-spa/single-spa/issues/545
      // 触发一些自定义事件,省略代码...、

      // 两种需要卸载的, 执行一些清理动作
      // 1, 加载完资源,还没有进行 mount, 会执行 unload 生命周期函数
      // 2, 已经 mounted,会执行 Unmount 生命周期函数
      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);

      /**
       * 执行 bootstrap 和 mount
       */
      const loadThenMountPromises = appsToLoad.map((app) => {
        return toLoadPromise(app).then((app) =>
          tryToBootstrapAndMount(app, unmountAllPromise)
        );
      });

      const mountPromises = appsToMount
        .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
        .map((appToMount) => {
          return tryToBootstrapAndMount(appToMount, unmountAllPromise);
        });
      return unmountAllPromise
        .catch((err) => {
          callAllEventListeners();
          throw err;
        })
        .then(() => {
          // 这里会去触发使用 window.addEventListener 注册的事件,为了保证不影响 single-spa 的执行,会使用一个 try/cash 包裹
          callAllEventListeners();
          
          return Promise.all(loadThenMountPromises.concat(mountPromises))
            .catch((err) => {
              pendingPromises.forEach((promise) => promise.reject(err));
              throw err;
            })
            /**
             * 在 finishUpAndReturn 中,会去检查在本次状态更变过程中,有没有触发reroute
             * 如果有,再次执行 reroute
             */
            .then(finishUpAndReturn);
        });
    });
  }

Untitled Diagram.jpeg

Untitled Diagram mount.jpeg