深入分析single-spa——导航事件与reroute

2,150 阅读8分钟

相关链接

本文是最近分析single-spa中的一篇,全部的文章如下:

  1. 深入分析single-spa——导航事件与reroute
  2. 深入分析single-spa——启动与应用管理
  3. 深入分析single-spa——事件机制
  4. 其他关于模块机制、生命周期、微前端类型的深入分析正在进行中

打开Github上的single-spa,它的About里面就有这么一句:

The router for easy microfrontends.

sing-spa可以说是一款路由驱动式的微前端框架,那我们就先从Router——也就是我们常说的路由看起,再逐渐去分析single-spa内部的路由机制。

前置知识——关于前端路由

我们无论是使用ReactVue或者Angular等开发spa应用,必然离不开Router. 在浏览器环境中,常见的Router分为两类:

  • Browser Router
  • Hash Router

Browser Router

在HTML5中,DOM的window对象通过history提供了对于浏览器会话历史的访问,允许在用户的浏览历史中进行向前和向后跳转。我们可以:

// 在history中向后跳转
window.history.back()
// 在history中向前跳转
window.history.forward()
// 或者使用go来载入会话历史中的某一个特定界面
window.history.go(-1) // 等同于back
window.history.go(1) // 等同于forward

此外,history API提供了pushState/replaceState/popState事件,用来添加和修改历史记录中的条目。<BrowserRouter>,就是使用了这些事件来保持UI和URL的一致性。

window.onpopstate = function(event) {
  // ... 
}

history.pushState({page: 1}, "title 1", "?page=1")
history.pushState({page: 2}, "title 2", "?page=2")
history.replaceState({page: 3}, "title 3", "?page=3")

Hash Router

Hash Router主要是通过监听hashchange事件,根据location.hash的变化来保持UI和URL的一致性。它也是我们经常会遇到的一种Router,具有很好的浏览器兼容性.

window.onhashchange = function (event) { 
    // ... 
}

window.addEventListener('hashchange', function (event) {
    // ...
})

关于这几种类型的事件,更多的信息可以参考History APIWindow: hashchange event.

Single-spa中的路由机制

导航事件(navigation-events)

single-spa实现应用级别的路由导航,同时提供了对于Browser RouterHash Router的支持。主要实现如下:

  • 监听hashchangepopstate事件,实现reroute
  • 重写window.addEventListenerwindow.removeEventListener,实现对于自定义事件的劫持处理
  • pushStatereplaceState事件进行自定义处理

基本流程

navigation流程.png

对于hashchangepopstate的监听处理

const capturedEventListeners = {
  hashchange: [],
  popstate: [],
};
// 需要监听的路由事件,也就是hashchange和popstate
export const routingEventsListeningTo = ["hashchange", "popstate"];
//  对于事件监听调用的实现
export function callCapturedEventListeners(eventArguments) {
  if (eventArguments) {
    const eventType = eventArguments[0].type;
    if (routingEventsListeningTo.indexOf(eventType) >= 0) {
      capturedEventListeners[eventType].forEach((listener) => {
        try {
          listener.apply(this, eventArguments);
        } catch (e) {
          setTimeout(() => {
            throw e;
          });
        }
      });
    }
  }
}

if (isInBrowser) {
  // 注册对于hashchange和popstate事件的监听
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);
  // ...
}

重写window.addEventListenerwindow.removeEventListener

在注册对于hashchange和popstate事件的监听之后,又重写了addEventListener和removeEventListener这两个办法,具体实现如下:

if (isInBrowser) {
  // ...
  // 重写addEventListener和removeEventListener
  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);
  };
  // ...
}

// urlReroute其实就是调用reroute来实现对应的路由导航操作
function urlReroute() {
  reroute([], arguments);
}

对于pushStatereplaceState的自定义处理

通过源码可以看到,single-spa通过实现了一个patchUpdateState的方法,来对window.history上的pushStatereplaceState事件添加一些自定义的逻辑:

if (isInBrowser) {
  // ...
  // 对pushState和replaceState事件,通过patchedUpdateState来添加一些自定义的逻辑
  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    window.history.replaceState,
    "replaceState"
  );
  //  ...
}

patchUpdateState中,主要做了以下处理:

  • 判断urlRerouteOnly或者url是否发生了变化
  • 判断single-spa是否已经启动
    • 如果single-spa已经启动,则会派发一个对应的事件
    • 如果未启动,则会触发reroute
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()) {
       	// 派发对应的事件
        window.dispatchEvent(
          createPopStateEvent(window.history.state, methodName)
        );
      } else {
        // 调用reroute
        reroute([]);
      }
    }

    return result;
  };
}

// cratePopStateEvent主要用来创建一个PopStateEvent,并添加singleSpa和singleSpaTrigger标识
function createPopStateEvent(state, originalMethodName) {
  let evt;
  try {
    evt = new PopStateEvent("popstate", { state });
  } catch (err) {
    evt = document.createEvent("PopStateEvent");
    evt.initPopStateEvent("popstate", false, false, state);
  }
  evt.singleSpa = true;
  evt.singleSpaTrigger = originalMethodName;
  return evt;
}

reroute

之前在navigation-events的介绍里面,我们可以发现:

  • 有声明过callCapturedEventListeners,但是并没有调用;真实的调用是在reroute.js中触发的
  • patchUpdateState的实现中,在single-spa没有启动的情况下,会触发reroute
  • 对于hashchangepopstate的监听,注册了一个urlReroute的方法,这里也会触发reroute

那我们来看看reroute吧。

关于reroute

reroute 是 single-spa 的核心方法。该方法更新微应用的状态,触发微前端应用的生命周期函数,并发出一系列自定义事件。

触发时机
  • 手动调用:在进行微前端应用注册和调用start方法的时候,触发reroute执行
  • 自动触发:在navigation-events中监听路由事件发生变化,自动触发reroute执行

基本流程

reroute流程.png

  • 判断appChangeUnderway,如果其为true,则存储reroute开始执行后的路由变化,待本次reroute执行后再进行处理
  • 通过getAppChanges获取apps中各个应用的状态,并进行分类
  • 判断single-spa是否已经启动,如果已经启动,则调用performAppChanges;否则调用loadApps
export function reroute(pendingPromises = [], eventArguments) {
  /* 通过变量appChangeUnderway用来判断当前是否正在执行,其初始值为false;
  *	 如果其值为true,则通过peopleWaitingOnAppChange存放reroute开始执行后的路由变化;并返回一个Promise,待本次reroute执行完成后再进行处理
  */
  if (appChangeUnderway) {
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }
  // 通过getAppChanges获取微前端应用状态,并分为4类
  const {
    appsToUnload,
    appsToUnmount,
    appsToLoad,
    appsToMount,
  } = getAppChanges();
  let appsThatChanged,
    navigationIsCanceled = false,
    oldUrl = currentUrl,
    newUrl = (currentUrl = window.location.href);
  // 判断single-spa是否已经启动
  if (isStarted()) {
    //  appChangeUnderway 改为true,获取appThatChanged列表,并执行performAppChanges
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    return performAppChanges();
  } else {
    // 如果single-spa没有启动,则执行loadApps
    appsThatChanged = appsToLoad;
    return loadApps();
  }
}

获取需要改变的微前端应用——getAppChanges

在之前的代码里面,我们可以看到在reroute中调用了getAppChanges方法来获取状态有变化的微前端应用。首先我们可以看下关于应用状态的定义:

// App statuses, 定义在app.helpers.js中,这里针对在getAppChanges中使用到的状态做了一些解释
// 初始状态,代表微前端应用的资源未加载
export const NOT_LOADED = "NOT_LOADED";
// 代表正在加载微前端应用的源代码
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE";
// NOT_LOADED的下一个状态,表示未初始化
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED";
export const BOOTSTRAPPING = "BOOTSTRAPPING";
// NOT_BOOTSTRAPPED的下一个状态,表示微前端应用相关代码未执行/未加载到界面上
export const NOT_MOUNTED = "NOT_MOUNTED";
export const MOUNTING = "MOUNTING";
// 表示微前端应用代码已经执行/已经加载到界面上
export const MOUNTED = "MOUNTED";
export const UPDATING = "UPDATING";
export const UNMOUNTING = "UNMOUNTING";
export const UNLOADING = "UNLOADING";
export const LOAD_ERROR = "LOAD_ERROR";
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN";

在getAppChanges中主要定义了4类应用,并进行了相应的分类:

  • appsToUnload: 针对处于NOT_BOOTSTRAPPEDNOT_MOUNTED状态,而且和当前url不匹配的应用
  • appsToUnmount:针对处于MOUNTED状态,而且和当前url不匹配的应用
  • appsToLoad:针对处于NOT_LOADEDLOADING_SOURCE_CODE转台,而且和当前url匹配的应用
  • appsToMount:与appsToUnload相对,针对处于NOT_BOOTSTRAPPEDNOT_MOUNTED状态,而且和当前url匹配的应用

具体源码如下:

export function getAppChanges() {
  const appsToUnload = [],
    appsToUnmount = [],
    appsToLoad = [],
    appsToMount = [];

  // We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
  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 };
}

在获取到状态有变更的微前端应用之后,就需要去执行具体的操作了。

执行微前端应用的变化——performAppChanges

执行变化,主要是通过CustomEvent进行自定义事件的派发,以及进行后续的处理:

  • 根据appsThatChanged的数量,来派发single-spa:before-no-app-change或者single-spa:before-app-change事件
  • 派发single-spa:before-routing-event事件
  • 如果导航已取消
    • 派发single-spa:before-mount-routing-event事件
    • 调用finishUpAndReturn
    • 调用naviagteToUrl, 返回之前的url
  • 对各种状态的微前端应用进行处理
  • 最终调用finishUpAndReturn
  function performAppChanges() {
    return Promise.resolve().then(() => {
      // 根据appsThatChanged的数量,来派发自定义事件
      window.dispatchEvent(
        new CustomEvent(
          appsThatChanged.length === 0
            ? "single-spa:before-no-app-change"
            : "single-spa:before-app-change",
          getCustomEventDetail(true)
        )
      );
     // 派发single-spa:before-routing-event事件
      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;
      }
      // 对各种状态的应用进行处理
      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(() => {
        window.dispatchEvent(
          new CustomEvent(
            "single-spa:before-mount-routing-event",
            getCustomEventDetail(true)
          )
        );
      });
      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(() => {
          callAllEventListeners();
          return Promise.all(loadThenMountPromises.concat(mountPromises))
            .catch((err) => {
              pendingPromises.forEach((promise) => promise.reject(err));
              throw err;
            })
            .then(finishUpAndReturn); // 最终调用finishUpAndReturn
        });
    });
  }

关于不同状态的微前端应用的处理,以及自定义事件,稍后会单独写一篇来分析,这里就先不展开了。

最后的处理——finishUpAndReturn

之前在reroute的流程分析中,还有两点:

  • 记录了appChangeUnderway,初始值为false,在判断isStarted() === true后,将其设置为true
  • 根据appChangeUnderway === true,将本次reroute后的路由变化记录到peopleWaitingOnAppChange中,等待后续处理

而finishUpAndReturn作为reroute的结束代码,并发出一些自定义的结束事件,对appChangeUnderway重新赋值,并处理之前记录在peopleWaitingOnAppChange中的路由事件,其返回值为mounted apps. 源码如下:

  function finishUpAndReturn() {
    // 获取mounted apps
    const returnValue = getMountedApps();
    pendingPromises.forEach((promise) => promise.resolve(returnValue));
	// 发布自定义事件
    try {
      const appChangeEventName =
        appsThatChanged.length === 0
          ? "single-spa:no-app-change"
          : "single-spa:app-change";
      window.dispatchEvent(
        new CustomEvent(appChangeEventName, getCustomEventDetail())
      );
      window.dispatchEvent(
        new CustomEvent("single-spa:routing-event", getCustomEventDetail())
      );
    } catch (err) {
      setTimeout(() => {
        throw err;
      });
    }
    // 重置appChangeUnderway的值为false,以便在后续调用reroute
    appChangeUnderway = false;
    // 对之前记录在peopleWaitingOnAppChange中的记录,调用reroute进行处理
    if (peopleWaitingOnAppChange.length > 0) {
      const nextPendingPromises = peopleWaitingOnAppChange;
      peopleWaitingOnAppChange = [];
      reroute(nextPendingPromises);
    }
	// 返回之前获取到的mounted apps
    return returnValue;
  }

参考资料: